SafeCab for developers

One API, and the whole product is built on it

The rider app, the driver app, the web app and the ops console speak nothing but this API. If you can build it, so can we — there is no private back channel.

Phone-first authOTP over SMS. No passwords, device-bound tokens.
Realtime by defaultWebSocket trip streams that survive a tunnel.
Safety as dataOne event envelope for every detector and alert.
South African railsPaystack, Ozow, Stripe, Apple Pay, Google Pay.

Quickstart

Four calls take you from nothing to a moving car. Run these against the demo environment.

# 1. Ask for a sign-in code. The demo echoes it back; production sends an SMS.
curl -sX POST "$API/v1/auth/otp/request" \
  -H 'content-type: application/json' \
  -d '{"phone":"082 123 4567","surface":"web"}'
# -> {"sent":true,"expiresInSeconds":300,"devCode":"418320"}

# 2. Exchange it for tokens. deviceId must be stable per install.
curl -sX POST "$API/v1/auth/otp/verify" \
  -H 'content-type: application/json' \
  -d '{"phone":"082 123 4567","code":"418320","deviceId":"my-device-0001"}'
# -> {"accessToken":"eyJ...","refreshToken":"...","expiresIn":900}

# 3. Who is around?
curl -s "$API/v1/trips/nearby?lat=-26.1076&lng=28.0567" \
  -H "authorization: Bearer $TOKEN"

# 4. Book. The response carries the PIN the driver must be told out loud.
curl -sX POST "$API/v1/trips" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{
    "pickup":  {"lat":-26.1076,"lng":28.0567,"address":"Sandton City"},
    "dropoff": {"lat":-26.1467,"lng":28.0436,"address":"Rosebank Mall"},
    "rideType":"standard",
    "idempotencyKey":"quickstart-0001"
  }'

Try it now

Runs against the live demo from your browser. Use a South African number; the demo returns the code instead of sending an SMS.

Ready.

Environments

EnvironmentBase URLNotes
Demohttps://safecab-api.yousef-hosseini.workers.dev Seeded with Johannesburg data. Returns the OTP in the response, so never put real personal data in it.
Localhttp://localhost:8787 pnpm dev. Relaxed rate limits and a deterministic AI mock.
Productionhttps://api.safecab.app Real SMS, real background checks, live Workers AI. By arrangement.

Authentication

South African riders are phone-first, so there are no passwords anywhere in this product. You request a code, you exchange it for tokens, and you refresh.

TokenLifetimeNotes
Access15 minutesJWT. Send as Authorization: Bearer. Bound to your deviceId.
Refresh60 daysSingle use, rotates on every refresh.
Reuse detection. Refresh tokens rotate, and replaying one that has already been used revokes the entire token family rather than just failing. A replayed refresh token almost always means it was stolen, and the honest response to that is to end every session it belongs to.

The sign-in response is deliberately identical whether or not the number is registered. Telling an unauthenticated caller “this number has a SafeCab account” is a safety problem for exactly the people this product exists to protect.

Conventions

Booking a trip

POST/v1/trips/estimate prices a trip over real roads with traffic applied. POST/v1/trips books it and starts dispatch.

Both sides then confirm. The Guardian accepts the trip; the rider accepts that specific Guardian with POST/v1/trips/{id}/confirm after seeing their name, rating, car, colour and plate.

The car cannot move until both have agreed. driver_en_route is guarded on the rider's confirmation in the state machine itself, not in a route handler — so a rider who does not like what they see can walk away before a car is ever tracked toward them, and no client bug or admin action can skip it.

The PIN handshake

Booking returns a four-digit pin. The driver must enter it before the trip can start. This is the cheapest possible defence against the most common e-hailing attack in South Africa: somebody who is not your driver pulling up and saying your name.

Guardians nearby

GET/v1/trips/nearby?lat=&lng=&rideType=

Returns idle Guardians, the current surge, and which ride tiers are servable at that point. Pass a driverId back as preferredDriverId when booking to offer that Guardian first — they still have to pass every safety filter for the ride type, so this reorders the queue without weakening anything.

Positions are deliberately coarse. Driver positions are snapped to about 150 m, and the response carries a first name, a rating and a car — never a plate, a phone number or a surname. A rider needs to see that cars are nearby and how far away they are. Nobody needs a live pin on a named person. The full details arrive once that Guardian has accepted the trip and the two people are actually going to meet.

Realtime trip stream

WS/v1/trips/{tripId}/ws?token=&lastSeq=

One socket per trip, shared by the rider, the driver, up to three guardians, the ops desk and read-only share viewers. Every server frame carries a monotonic seq. Reconnect with the last one you processed and the server replays what you missed.

const ws = new WebSocket(
  `${API.replace(/^http/, 'ws')}/v1/trips/${tripId}/ws?token=${accessToken}&lastSeq=${lastSeq}`
);

ws.onmessage = (event) => {
  const { type, seq, payload } = JSON.parse(event.data);
  lastSeq = Math.max(lastSeq, seq);

  switch (type) {
    case 'welcome':      render(payload.snapshot); break;
    case 'trip_state':   render(payload.snapshot); break;
    case 'location':     moveCar(payload.driver); break;
    case 'eta':          showEta(payload.etaSeconds); break;
    case 'safety_event': onSafety(payload.event); break;
    case 'prompt':       ask(payload.kind, payload.timeoutSeconds); break;
  }
};

That replay is not a nicety. Load-shedding takes mobile towers down for hours at a time in South Africa, and a guardian watching a child's trip must not be left looking at a frozen map when the signal comes back.

Server frameMeaning
welcomeSnapshot on connect, redacted for your audience.
trip_stateThe state machine moved.
locationA position fix. 4 s moving, 15 s stationary, 1 s during an incident.
etaRemaining seconds and metres.
safety_eventA detector fired. See the safety model below.
promptThe rider is being asked something — “are you okay?”, “did you arrive safely?”
handoff_requestA child is being handed over; the driver needs a photo and a code.

Driver integration

A shift starts with a liveness selfie matched against the driver's enrolled licence photo — the one check that stops an approved account being worked by somebody else, which is what makes every other check meaningful. It fails closed.

CallWhat it does
POST/v1/driver/session/startMultipart selfie. 403 keeps the driver offline.
POST/v1/driver/heartbeatPosition. Also migrates the driver between geographic cells.
GET/v1/driver/offersPending offers, for a phone that was in a tunnel when the push landed.
POST/v1/driver/offers/{tripId}/acceptFirst one home wins.
POST/v1/driver/trips/{tripId}/{action}en-route, arrived, pin, start, arrived-destination, complete.
POST/v1/driver/trips/{tripId}/locationBuffered fixes replayed in order after a signal drop.

The safety model

Every detector emits the same envelope, so the ops desk, the guardian view and trip replay all consume one shape.

{
  "id": "sev_01m11...",
  "tripId": "trp_01m11...",
  "at": 1787830000000,
  "kind": "route_deviation",
  "severity": "alert",
  "source": "detector",
  "data": { "offRouteMeters": 940, "sustainedSeconds": 78 },
  "location": { "lat": -26.118, "lng": 28.061 }
}
SeverityWhat happens
infoStored, visible in replay.
warnPush to the rider. Guardians too, on a kids trip.
alert“Are you okay?” to the rider, then guardians and the ops desk.
criticalAn incident opens: ops, trusted contacts, armed response, evidence capture.

Detectors run inside the trip's own Durable Object, synchronously on each location frame, so the critical path never waits on a queue. Budget from detector to an operator seeing it: under two seconds.

Panic button

POST/v1/trips/{tripId}/sos

Silent mode is the important one. With silent: true everything happens — the incident opens, ops is paged, contacts are alerted, armed response is called — while the rider's device shows nothing at all. The response body is byte-identical to a normal SOS, so somebody watching over the rider's shoulder learns nothing.

There is also a covert text channel: a rider can set a safe phrase, and sending it in trip chat raises a critical event without acknowledging anything to anyone. A person under duress cannot press a red button in front of the person threatening them, but they can type a sentence.

The driver is never told that an SOS was raised, at any severity.

Kids and handovers

A guardian creates child profiles, and a child rides on their own account with their own wallet — never a payment method, never a free-text address.

Payments

Ask GET/v1/payments/config at startup rather than hard-coding a payment menu you might not be able to honour.

MethodRailNotes
WalletInternal ledgerDouble-entry. Top up once, ride without re-entering a card.
CardPaystackPrimary ZAR acquiring, tokenised, 3DS.
Apple Pay / Google PayStripeCard networks behind a device wallet — the same PaymentIntent as a card.
Instant EFTOzowFor the large segment with a bank account but no card they will store.
CashAdults only. Never on a kids trip.

Card details never reach SafeCab. /v1/payments/cards/setup returns a Stripe SetupIntent client secret that your client confirms directly with Stripe, which is what keeps this platform out of PCI scope.

Webhooks

POST/v1/webhooks/{provider}

The handler verifies the signature, stores the raw body, and enqueues. Processing happens off the request, so a slow ledger write can never make us miss a provider's retry window, and a stored body means any event can be replayed. Duplicate deliveries are acknowledged, not reprocessed.

Errors

{ "error": { "code": "insufficient_funds",
             "message": "Top up your wallet to book this trip.",
             "detail": { "amountCents": 8570, "balanceCents": 2000,
                         "shortfallCents": 6570,
                         "alternatives": ["topup","cash","card"] } } }
CodeStatusMeaning
validation_failed400detail lists the offending paths.
insufficient_funds400Carries the shortfall and the ways out of it.
outside_service_area400We do not operate at that pickup point yet.
cash_not_allowed_for_kids400By design, not a configuration error.
unauthorised401Missing or expired access token.
forbidden403Authenticated, but not for this trip or this role.
trip_already_active409One trip per rider at a time.
rate_limited429detail.retryAfterSeconds.

Rate limits

ActionLimit
OTP request, per phone5 per hour
OTP request, per IP20 per hour
Trip creation, per rider10 per 5 minutes
Top-up, per rider10 per hour
Panic button, per rider30 per minute
On that last one. The panic button is limited only high enough to stop a stuck client hammering the edge, never low enough to stop a person. No human presses it thirty times a minute, and the one who tries is the one who needs it most.

OpenAPI

The full machine-readable description is generated from the running deployment, so it can never drift from what is actually deployed: openapi.json. Point your generator at it.

npx @hey-api/openapi-ts -i "$API/v1/openapi.json" -o ./src/safecab