Design the Uber API
Riders, drivers, dispatch, trip lifecycle, surge. Real-time matching with geospatial constraints.
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:requestso 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 p95fromPOST /rides:requestto 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:
| Method | Path | Purpose |
|---|---|---|
POST | /v1/rides:estimate | Fare + ETA preview (returns estimate_token) |
POST | /v1/rides:request | Create a ride request; returns trip id |
GET | /v1/rides/{id} | Current trip state (polling alternative) |
WS | /v1/rides/{id}/track | Real-time updates: status + driver location |
POST | /v1/rides/{id}:cancel | Cancel a trip |
Driver side:
| Method | Path | Purpose |
|---|---|---|
POST | /v1/drivers/{id}:go_online | Become available for dispatch |
POST | /v1/drivers/{id}:go_offline | Leave the dispatch pool |
WS | /v1/drivers/{id}/dispatch | Receive offers; bidirectional ack |
WS | /v1/drivers/{id}/location | Push location updates (1 Hz when online, 4 Hz on trip) |
POST | /v1/trips/{id}:accept | Accept an offer |
POST | /v1/trips/{id}:decline | Decline an offer |
POST | /v1/trips/{id}:transition | State transition (arrived / start / complete) |
Common:
| Method | Path | Purpose |
|---|---|---|
GET | /v1/pricing/surge | Current 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)#
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 upgradeGET /v1/rides/01HF...M3K/track HTTP/2Upgrade: websocketAuthorization: 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.
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"])package main
import ( "bytes" "encoding/json" "fmt" "net/http"
"github.com/google/uuid")
const API = "https://api.uber.example.com/v1"const Token = "eyJhbGciOi..."
type GeoPoint struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` Address string `json:"address,omitempty"`}
type Estimate struct { FareLow struct{ Amount int } `json:"fare_low"` FareHigh struct{ Amount int } `json:"fare_high"` EtaSeconds int `json:"eta_seconds"` EstimateToken string `json:"estimate_token"`}
func post(path string, body any, idem string) ([]byte, error) { b, _ := json.Marshal(body) req, _ := http.NewRequest("POST", API+path, bytes.NewReader(b)) req.Header.Set("Authorization", "Bearer "+Token) req.Header.Set("Content-Type", "application/json") if idem != "" { req.Header.Set("Idempotency-Key", idem) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() out := new(bytes.Buffer) out.ReadFrom(resp.Body) return out.Bytes(), nil}
func main() { pickup := GeoPoint{37.7749, -122.4194, "1 Market St"} dropoff := GeoPoint{37.7833, -122.4167, "Union Square"}
raw, _ := post("/rides:estimate", map[string]any{ "pickup": pickup, "dropoff": dropoff, "vehicle_class": "uberx", }, "") var est Estimate json.Unmarshal(raw, &est) fmt.Printf("fare %d-%d eta %ds\n", est.FareLow.Amount, est.FareHigh.Amount, est.EtaSeconds)
raw, _ = post("/rides:request", map[string]any{ "estimate_token": est.EstimateToken, "pickup": pickup, "dropoff": dropoff, "vehicle_class": "uberx", "payment_method_id": "pm_card_visa", }, uuid.NewString()) fmt.Println(string(raw))}import { randomUUID } from "node:crypto";
const API = "https://api.uber.example.com/v1";const TOKEN = process.env.UBER_TOKEN;const h = (idem) => ({ Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...(idem ? { "Idempotency-Key": idem } : {}),});
export async function estimate(pickup, dropoff, vehicleClass = "uberx") { const r = await fetch(`${API}/rides:estimate`, { method: "POST", headers: h(), body: JSON.stringify({ pickup, dropoff, vehicle_class: vehicleClass }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json();}
export async function requestRide(est, pickup, dropoff, paymentMethodId) { const r = await fetch(`${API}/rides:request`, { method: "POST", headers: h(randomUUID()), body: JSON.stringify({ estimate_token: est.estimate_token, pickup, dropoff, vehicle_class: "uberx", payment_method_id: paymentMethodId, }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json();}
const pickup = { lat: 37.7749, lng: -122.4194, address: "1 Market St" };const dropoff = { lat: 37.7833, lng: -122.4167, address: "Union Square" };const e = await estimate(pickup, dropoff);console.log("fare", e.fare_low.amount, "-", e.fare_high.amount, "eta", e.eta_seconds);const trip = await requestRide(e, pickup, dropoff, "pm_card_visa");console.log("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#
| Decision | Why | Cost if requirements change |
|---|---|---|
estimate_token pins fare to request | Prevents surge-jump between view and tap | Token must be short-lived (60 s) |
Idempotency-Key on /rides:request | Network retry must not double-book | One header per request |
| WebSocket for tracking | Sub-second location updates | More server state than HTTP polling |
| Driver-side and rider-side as separate surfaces | Different auth, different rate limits, different rate of change | Two SDKs to maintain |
:verb for state transitions (:cancel, :accept, :arrive) | Cleaner than overloading PATCH | Slight deviation from pure REST orthodoxy |
| Surge as a separate read endpoint | Cacheable per H3 cell + minute | Riders see “surge” as a UI affordance, not a fait accompli |
| Trip status as enum, not arbitrary string | Public contract; clients can switch on it | Adding 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_ridersto the Trip and shifts pickup-dropoff ordering. Same shape, additional state in the trip. - Scheduled rides.
POST /rides:requestwithscheduled_at: 2026-05-30T08:00Zand aReservedinitial state. Dispatch wakes up 15 min before scheduled time. - Driver-initiated re-route. Optional
POST /trips/{id}:rerouteif 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
cargopayload type instead of a passenger. The pickup-dropoff API is unchanged. - B2B / Uber for Business.
POST /rides:requestwithaccount: org_*to bill a corporate account; permission scoping via OAuth2 scopes on the corporate token.
Mock interview follow-ups#
- “Why a separate
estimatethenrequestinstead 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 viaestimate_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_matchand 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 torequestedwithre_dispatching: trueso 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.
Related#
- Design the Google Maps API — the geospatial sibling; routing and ETA primitives live there.
- WebSockets — Bidirectional Streaming — the transport behind tracking; deserves its own writeup.
- Event-Driven Architecture Protocols — webhook + push patterns this API uses internally.
- The Role of Idempotency in API Design — why
Idempotency-Keyon/rides:requestis mandatory. - The API-Design Walk-through — the seven-step recipe this writeup followed.