Design the Uber API

Riders, drivers, dispatch, trip lifecycle, surge. Real-time matching with geospatial constraints.

System Advanced
16 min read
api-design uber geospatial websockets
Companies this resembles: Uber

Context#

The Uber API is the canonical real-time matching problem: two populations (riders and drivers) move through a 2D city, the system continually pairs them up, and a trip is a finite-state lifecycle that has to be observed by both parties simultaneously. The interviewer wants to see whether you can design the public contract on top of a dispatch system without trying to design the dispatch algorithm in the room.

The HLD overlap is unavoidable — Uber’s H3 hexagonal grid, the supply-and-demand surge model, the dispatch matcher itself. We acknowledge that and cut: this writeup is the wire contract between two clients (rider app, driver app) and the platform. We narrate the geospatial substrate but don’t design it.

The interviewer’s hidden objectives:

  • Can you separate rider-side from driver-side API surfaces and keep them coherent?
  • Can you draw the trip state machine that both clients observe and act on?
  • Can you write real-time location updates as a WebSocket / streaming endpoint, not as a per-second POST loop?
  • Can you defend surge pricing as a separate price-multiplier resource, not baked into the estimate?
  • Can you handle idempotency on POST /rides:request so a flaky network doesn’t double-book?

Scope cuts: the dispatch algorithm internals (matcher, batching, dynamic forecasting), payment splitting (Uber Pay vs cash vs split fares), ratings, the rider promotions / coupons system. We design the trip-creation and trip-observation contract.

Requirements (functional and non-functional)#

Functional — in scope:

  • Rider: get a fare estimate + ETA for a pickup-dropoff pair.
  • Rider: request a ride; the API holds the request and returns a trip id immediately.
  • Rider: observe trip state in real time (driver location, ETA, status changes).
  • Rider: cancel a trip (before / during).
  • Driver: receive offered rides; accept or decline.
  • Driver: post location updates while online.
  • Driver: transition trip state (en route, arrived, started, completed).
  • Surge pricing as a city-grid-time-of-day price multiplier endpoint.

Functional — out of scope:

  • The dispatch matcher itself (the algorithm that picks driver D for request R).
  • Payment splitting (Uber Pay, fare splitting, corporate accounts).
  • The ratings system (post-trip, async, separate API).
  • Promotions / coupons / Uber Cash (a separate billing concern).
  • Uber Eats / Freight / Health (different verticals, different APIs).

Non-functional:

  • Estimate latency: <= 200 ms p95. The user is mid-tap.
  • Match latency: <= 4 s p95 from POST /rides:request to a driver assigned.
  • Location-stream latency: sub-second from driver’s device to rider’s screen during an active trip.
  • Availability: 99.95% on the rider request path; 99.9% on driver location updates.
  • Throughput: 50k ride requests per minute peak in a major metro; 500k concurrent active trips globally.

Use case diagram#

┌──────────────┐ ┌──────────────┐
│ Rider │ │ Driver │
└──────┬───────┘ └──────┬───────┘
│ │
┌──────┼────────────┐ ┌──────┼─────────────┐
▼ ▼ ▼ ▼ ▼ ▼
[estimate][request][watch trip] [go online][accept ride][update loc]
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Uber Public API │
└──────────────────────────────────┬───────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Dispatch │ │ Geospatial │ │ Pricing │
│ (matcher) │ │ index │ │ service │
└──────────────┘ │ (H3 cells) │ └──────────────┘
└──────────────┘

Two distinct actor types with distinct surfaces; the platform mediates.

Class diagram#

┌──────────────────────┐ ┌──────────────────────┐
│ Rider │ │ Driver │
├──────────────────────┤ ├──────────────────────┤
│ id : string │ │ id : string │
│ phone : string │ │ phone : string │
│ payment_methods : [] │ │ vehicle : Vehicle │
└──────────┬───────────┘ │ status : enum │
│ │ online | offline | │
│ │ en_route | on_trip │
│ │ location : GeoPoint │
│ └──────────┬───────────┘
│ 1 │ 0..1
▼ * ▼ *
┌──────────────────────────────────────────────────────┐
│ Trip │
├──────────────────────────────────────────────────────┤
│ id : ULID │
│ rider_id : Rider │
│ driver_id : Driver? │
│ pickup : GeoPoint + address │
│ dropoff : GeoPoint + address │
│ vehicle_class : enum (uberx, comfort, black, xl) │
│ status : enum (state machine below) │
│ requested_at : ts │
│ matched_at : ts? │
│ started_at : ts? │
│ completed_at : ts? │
│ fare : Money? (final, set at completion) │
│ surge_multiplier : float │
│ route : Route? (driver-to-pickup, then pickup-to-dropoff) │
└──────────────────────────────────────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ Estimate │ │ Surge │
├──────────────────────┤ ├──────────────────────┤
│ vehicle_class : enum │ │ h3_cell : string │
│ fare_low : Money │ │ multiplier : float │
│ fare_high : Money │ │ valid_until : ts │
│ eta_seconds : int │ └──────────────────────┘
│ surge_multiplier : float │
│ estimate_token : str │ ─── opaque, attached to subsequent /request
└──────────────────────┘

The estimate_token is a thin trick that pins the displayed fare to the actual request — a few-second-old estimate can’t be quietly upgraded by a surge change between view and tap.

Sequence diagram (key flows)#

The request-to-match-to-arrival flow:

RiderApp Gateway RideAPI Dispatch H3Index DriverApp Pricing
│ POST /rides:estimate │ │ │ │ │
│────────────►│ │ │ │ │ │
│ │ auth │ │ │ │ │
│ │──────►│ │ │ │ │
│ │ │ query supply │ │ │ │
│ │ │─────────────────────────►│ │ │
│ │ │ nearby K │ │ │ │
│ │ │◄─────────────────────────│ │ │
│ │ │ get surge │ │ │ │
│ │ │─────────────────────────────────────────────────► │
│ │ │ multiplier │ │ │ │
│ │ │◄───────────────────────────────────────────────── │
│ estimate + token │ │ │ │ │
│◄────────────│ │ │ │ │ │
│ │
│ POST /rides:request {token, pickup, dropoff} │
│──────────────────────────────────────────►│ │
│ 202 + trip_id (status: matching) │ │
│◄──────────────────────────────────────────│ │
│ │ score candidates │
│ │─────────►│ │
│ │ offer │ │
│ │─────────────────►│ │
│ │ accept │ │ │
│ │◄─────────────────│ │
│ │ trip.status = matched │
│ │ │
│ WS /rides/{id}/track → status updates + driver location │
│◄─────────────────────────────────────────────────────────────────────│

The WebSocket on /rides/{id}/track is the rider’s live view; it’s also the channel the driver app uses to push location updates (via a separate WS at /drivers/{id}/track).

Activity diagram (for non-trivial state)#

The trip state machine — observed by both rider and driver, mutated by API actions on each side:

[POST /rides:request]
┌────────────┐
│ Requested │ (matching in progress)
└─────┬──────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
no driver driver rider
in 60s accepts cancels
│ │ │
▼ ▼ ▼
┌─────────┐ ┌────────────┐ ┌──────────┐
│ No- │ │ Matched │ │Cancelled │
│ Match │ └──────┬─────┘ │ (no fee) │
│ (refund)│ │ └──────────┘
└─────────┘ ▼
┌────────────────┐
│DriverEnRoute │
└──────┬─────────┘
┌───────┴────────┐
▼ ▼
driver arrives rider cancels
│ │
▼ ▼
┌────────────┐ ┌──────────────┐
│ Arrived │ │ Cancelled │
└──────┬─────┘ │ (cancel fee │
│ │ may apply) │
▼ └──────────────┘
┌────────────┐
│ InProgress │
└──────┬─────┘
┌────────────┐
│ Completed │ (fare finalised)
└────────────┘

The “Arrived” sub-state matters because that’s when the cancellation fee policy kicks in. Riders can cancel free until the driver is within a small radius of pickup; after that, a fee applies.

API implementation#

Endpoint catalogue#

Rider side:

MethodPathPurpose
POST/v1/rides:estimateFare + ETA preview (returns estimate_token)
POST/v1/rides:requestCreate a ride request; returns trip id
GET/v1/rides/{id}Current trip state (polling alternative)
WS/v1/rides/{id}/trackReal-time updates: status + driver location
POST/v1/rides/{id}:cancelCancel a trip

Driver side:

MethodPathPurpose
POST/v1/drivers/{id}:go_onlineBecome available for dispatch
POST/v1/drivers/{id}:go_offlineLeave the dispatch pool
WS/v1/drivers/{id}/dispatchReceive offers; bidirectional ack
WS/v1/drivers/{id}/locationPush location updates (1 Hz when online, 4 Hz on trip)
POST/v1/trips/{id}:acceptAccept an offer
POST/v1/trips/{id}:declineDecline an offer
POST/v1/trips/{id}:transitionState transition (arrived / start / complete)

Common:

MethodPathPurpose
GET/v1/pricing/surgeCurrent surge multipliers for an area

Fifteen endpoints total, cleanly split into rider / driver / common surfaces. The :verb suffix style (custom methods) marks state-transition actions that don’t fit pure CRUD.

OpenAPI schema (excerpt)#

OpenAPI 3.1 — Uber API
paths:
/v1/rides:estimate:
post:
operationId: estimateRide
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [pickup, dropoff]
properties:
pickup: { $ref: '#/components/schemas/GeoPoint' }
dropoff: { $ref: '#/components/schemas/GeoPoint' }
vehicle_class:
type: string
enum: [uberx, comfort, black, xl, share]
responses:
'200':
description: Estimate
content:
application/json:
schema: { $ref: '#/components/schemas/Estimate' }
/v1/rides:request:
post:
operationId: requestRide
parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [estimate_token, pickup, dropoff, payment_method_id]
properties:
estimate_token: { type: string }
pickup: { $ref: '#/components/schemas/GeoPoint' }
dropoff: { $ref: '#/components/schemas/GeoPoint' }
vehicle_class: { type: string }
payment_method_id: { type: string }
notes_for_driver: { type: string, maxLength: 200 }
responses:
'202':
description: Trip created; matching in progress
content:
application/json:
schema:
type: object
properties:
id: { type: string }
status: { type: string, enum: [requested] }
track_url: { type: string, format: uri }
/v1/rides/{id}:
get:
operationId: getTrip
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Current trip state
content:
application/json:
schema: { $ref: '#/components/schemas/Trip' }
components:
schemas:
GeoPoint:
type: object
required: [lat, lng]
properties:
lat: { type: number, format: float, minimum: -90, maximum: 90 }
lng: { type: number, format: float, minimum: -180, maximum: 180 }
address: { type: string }
Estimate:
type: object
required: [fare_low, fare_high, eta_seconds, estimate_token]
properties:
fare_low: { type: object, properties: { amount: { type: integer }, currency: { type: string } } }
fare_high: { type: object, properties: { amount: { type: integer }, currency: { type: string } } }
eta_seconds: { type: integer }
surge_multiplier: { type: number, format: float }
estimate_token: { type: string }
Trip:
type: object
required: [id, status, pickup, dropoff]
properties:
id: { type: string }
status:
type: string
enum: [requested, matched, driver_en_route, arrived, in_progress, completed, cancelled, no_match]
pickup: { $ref: '#/components/schemas/GeoPoint' }
dropoff: { $ref: '#/components/schemas/GeoPoint' }
driver:
type: object
nullable: true
properties:
name: { type: string }
rating: { type: number }
vehicle:
type: object
properties:
make: { type: string }
model: { type: string }
plate: { type: string }
color: { type: string }
location:
$ref: '#/components/schemas/GeoPoint'
fare:
type: object
nullable: true
properties:
amount: { type: integer }
currency: { type: string }
surge_multiplier: { type: number }

Wire-level: the track WebSocket#

The rider’s app opens one WebSocket per active trip. The server pushes a stream of small JSON frames.

Client → Server: WebSocket upgrade
GET /v1/rides/01HF...M3K/track HTTP/2
Upgrade: websocket
Authorization: Bearer eyJhbGciOi...
Server → Client (frames):
{"type":"status","data":{"status":"matched","matched_at":"2026-05-30T10:15:02Z","driver":{"name":"Alex","rating":4.92,"vehicle":{"plate":"7XKJ234","make":"Toyota","model":"Camry","color":"silver"}}}}
{"type":"location","data":{"driver":{"lat":37.7749,"lng":-122.4194},"eta_seconds":182}}
{"type":"location","data":{"driver":{"lat":37.7751,"lng":-122.4188},"eta_seconds":178}}
{"type":"status","data":{"status":"arrived","at":"2026-05-30T10:18:33Z"}}
{"type":"status","data":{"status":"in_progress","at":"2026-05-30T10:19:01Z"}}
...
{"type":"status","data":{"status":"completed","fare":{"amount":2450,"currency":"usd"}}}

The driver-side /drivers/{id}/location socket is the reverse direction: client → server pushes location frames, the server acks every Nth frame for keepalive. H3 hex cell indexing on the server side lets dispatch find nearby drivers in O(1) per pickup cell + ring lookup.

Client samples — three languages#

Fetch estimate, then request a ride.

Estimate + request — Python
import os, uuid, requests
API = "https://api.uber.example.com/v1"
TOKEN = os.environ["UBER_TOKEN"]
H = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
def estimate(pickup, dropoff, vehicle_class="uberx"):
r = requests.post(
f"{API}/rides:estimate",
json={"pickup": pickup, "dropoff": dropoff, "vehicle_class": vehicle_class},
headers=H, timeout=3,
)
r.raise_for_status()
return r.json()
def request_ride(est, pickup, dropoff, payment_method_id):
body = {
"estimate_token": est["estimate_token"],
"pickup": pickup, "dropoff": dropoff,
"vehicle_class": "uberx",
"payment_method_id": payment_method_id,
}
r = requests.post(
f"{API}/rides:request",
json=body,
headers={**H, "Idempotency-Key": str(uuid.uuid4())},
timeout=5,
)
r.raise_for_status()
return r.json()
pickup = {"lat": 37.7749, "lng": -122.4194, "address": "1 Market St"}
dropoff = {"lat": 37.7833, "lng": -122.4167, "address": "Union Square"}
e = estimate(pickup, dropoff)
print("fare", e["fare_low"]["amount"], "-", e["fare_high"]["amount"], "eta", e["eta_seconds"])
trip = request_ride(e, pickup, dropoff, "pm_card_visa")
print("trip", trip["id"], "track:", trip["track_url"])

Geospatial substrate: H3#

Uber’s H3 hexagonal grid is the indexing system the dispatch layer uses to find nearby drivers cheaply. Each driver location maps to an H3 cell id (a 64-bit integer at the chosen resolution — typically resolution 9, ~150 m edge in cities). A pickup request computes the H3 cell, then walks a k-ring (the cells within k steps) to gather candidate drivers. Hexagons are preferable to squares for this — uniform neighbour distances and no diagonal-vs-side asymmetry.

The API contract does not expose H3 cell ids. They’re an implementation detail.

Trade-offs and extensions#

DecisionWhyCost if requirements change
estimate_token pins fare to requestPrevents surge-jump between view and tapToken must be short-lived (60 s)
Idempotency-Key on /rides:requestNetwork retry must not double-bookOne header per request
WebSocket for trackingSub-second location updatesMore server state than HTTP polling
Driver-side and rider-side as separate surfacesDifferent auth, different rate limits, different rate of changeTwo SDKs to maintain
:verb for state transitions (:cancel, :accept, :arrive)Cleaner than overloading PATCHSlight deviation from pure REST orthodoxy
Surge as a separate read endpointCacheable per H3 cell + minuteRiders see “surge” as a UI affordance, not a fait accompli
Trip status as enum, not arbitrary stringPublic contract; clients can switch on itAdding new states is a versioned change

Likely follow-up extensions and the shape of the answer:

  • Pool / Share rides. Multiple riders share a vehicle. Adds co_riders to the Trip and shifts pickup-dropoff ordering. Same shape, additional state in the trip.
  • Scheduled rides. POST /rides:request with scheduled_at: 2026-05-30T08:00Z and a Reserved initial state. Dispatch wakes up 15 min before scheduled time.
  • Driver-initiated re-route. Optional POST /trips/{id}:reroute if rider asks to add a stop mid-trip. The fare estimate updates, the rider must accept the new fare bracket on the track WS.
  • Delivery (Uber Eats / Direct). Same trip primitive with a cargo payload type instead of a passenger. The pickup-dropoff API is unchanged.
  • B2B / Uber for Business. POST /rides:request with account: org_* to bill a corporate account; permission scoping via OAuth2 scopes on the corporate token.

Mock interview follow-ups#

  • “Why a separate estimate then request instead of one call?” — Users want to see the fare before committing. An estimate is cheap (read-only); a request books a driver (write). Also lets the system pin the displayed price via estimate_token.
  • “How do you handle a request when there’s no driver nearby?” — After a 60 s match attempt with no acceptance, the trip transitions to no_match and the API returns no fare. The rider can re-request, which gets a new estimate (likely with a higher surge).
  • “What if a driver accepts but then disappears (closed app, lost signal)?” — The driver-side WebSocket has a keepalive. If the connection drops for more than 30 s during driver_en_route, dispatch un-assigns the driver and re-runs the matcher. The trip status briefly reverts to requested with re_dispatching: true so the rider’s app shows “finding new driver”.
  • “How does surge work on the wire?”GET /pricing/surge?lat=&lng= returns the current multiplier for the requesting cell plus a TTL. The estimate endpoint also returns the multiplier embedded in the fare quote. The dispatch layer pins the multiplier at request time, not completion time — riders are never surprised.
  • “How do you prevent drivers from gaming the system by toggling online to game incentives?” — Out of API scope. The contract just exposes online/offline transitions; abuse detection is a back-end pipeline that may eventually demote a driver via a separate admin path.
  • “WebSocket vs polling for tracking — why WebSocket?” — 1-Hz GPS updates at 500k concurrent trips is 500k req/s if you poll. WebSocket holds the connection open and pushes; the server-side cost is one bytes-per-frame, not a full HTTP round trip. Mobile battery cost is also significantly lower.
  • “How does the API deal with a rider cancelling 100ms after the driver accepts?” — Race condition. The platform serializes the state transition: whichever event hits the trip’s state-store first wins. If accept lands first, cancellation is processed normally with the cancellation-fee policy. If cancel lands first, the driver gets a “rider cancelled” push and is returned to the dispatch pool.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.