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.
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
| Environment | Base URL | Notes |
|---|---|---|
| Demo | https://safecab-api.yousef-hosseini.workers.dev |
Seeded with Johannesburg data. Returns the OTP in the response, so never put real personal data in it. |
| Local | http://localhost:8787 |
pnpm dev. Relaxed rate limits and a deterministic AI mock. |
| Production | https://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.
| Token | Lifetime | Notes |
|---|---|---|
| Access | 15 minutes | JWT. Send as Authorization: Bearer. Bound to your deviceId. |
| Refresh | 60 days | Single use, rotates on every refresh. |
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
- Money is integer cents in ZAR. No floats, anywhere. Displayed prices include 15% VAT; receipts break the VAT out of the total rather than adding it on top.
- Times are epoch milliseconds, UTC. South Africa is UTC+2 with no daylight saving.
- Idempotency. Anything that creates a trip or moves money takes an
idempotencyKey. Retrying with the same key returns the original result — a flaky connection must never send two cars or take two payments. - Errors carry a stable
code. Switch on that, never on the message, so we can reword copy without breaking an app three releases behind.
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.
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.
- The PIN is never sent to the driver's client — it is redacted server-side per audience.
- Three wrong attempts locks the trip and raises a safety alert.
- A trip cannot reach
in_progresswithoutpin_verified. That guard lives in the shared state machine, so every surface inherits it.
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.
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 frame | Meaning |
|---|---|
welcome | Snapshot on connect, redacted for your audience. |
trip_state | The state machine moved. |
location | A position fix. 4 s moving, 15 s stationary, 1 s during an incident. |
eta | Remaining seconds and metres. |
safety_event | A detector fired. See the safety model below. |
prompt | The rider is being asked something — “are you okay?”, “did you arrive safely?” |
handoff_request | A 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.
| Call | What it does |
|---|---|
POST/v1/driver/session/start | Multipart selfie. 403 keeps the driver offline. |
POST/v1/driver/heartbeat | Position. Also migrates the driver between geographic cells. |
GET/v1/driver/offers | Pending offers, for a phone that was in a tunnel when the push landed. |
POST/v1/driver/offers/{tripId}/accept | First one home wins. |
POST/v1/driver/trips/{tripId}/{action} | en-route, arrived, pin, start, arrived-destination, complete. |
POST/v1/driver/trips/{tripId}/location | Buffered 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 }
}
| Severity | What happens |
|---|---|
info | Stored, visible in replay. |
warn | Push to the rider. Guardians too, on a kids trip. |
alert | “Are you okay?” to the rider, then guardians and the ops desk. |
critical | An 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: 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.
- Pickup needs a photo and the PIN before the car can move.
- Drop-off completes only against an approved receiver, verified by their standing code or by a guardian confirming in-app, inside the drop-off geofence.
- Any mismatch stops the handover, pages every guardian, and the driver is told to wait.
- Up to three guardians watch the same live stream.
- Cash is refused outright on a kids trip. A child should never handle money or end up in a fare dispute at the kerb.
Payments
Ask GET/v1/payments/config at startup rather
than hard-coding a payment menu you might not be able to honour.
| Method | Rail | Notes |
|---|---|---|
| Wallet | Internal ledger | Double-entry. Top up once, ride without re-entering a card. |
| Card | Paystack | Primary ZAR acquiring, tokenised, 3DS. |
| Apple Pay / Google Pay | Stripe | Card networks behind a device wallet — the same PaymentIntent as a card. |
| Instant EFT | Ozow | For the large segment with a bank account but no card they will store. |
| Cash | — | Adults 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"] } } }
| Code | Status | Meaning |
|---|---|---|
validation_failed | 400 | detail lists the offending paths. |
insufficient_funds | 400 | Carries the shortfall and the ways out of it. |
outside_service_area | 400 | We do not operate at that pickup point yet. |
cash_not_allowed_for_kids | 400 | By design, not a configuration error. |
unauthorised | 401 | Missing or expired access token. |
forbidden | 403 | Authenticated, but not for this trip or this role. |
trip_already_active | 409 | One trip per rider at a time. |
rate_limited | 429 | detail.retryAfterSeconds. |
Rate limits
| Action | Limit |
|---|---|
| OTP request, per phone | 5 per hour |
| OTP request, per IP | 20 per hour |
| Trip creation, per rider | 10 per 5 minutes |
| Top-up, per rider | 10 per hour |
| Panic button, per rider | 30 per minute |
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