API Design
A well‑designed API is a contract you can evolve without breaking callers. This chapter is the interview‑ready mental model: resource‑oriented REST, the safety guarantees behind each HTTP method, how to make writes safe to retry, how to page over millions of rows, how to version without pain, how to authenticate and rate‑limit — and, as the centerpiece, how to model long‑running work with the async job pattern.
Interviewers use API questions to probe whether you think in contracts and failure modes rather than endpoints. The through‑line for every decision below is the same: assume the network will drop, duplicate, and reorder your calls, and design a surface that stays correct anyway.
REST principles
REST is an architectural style, not a spec. In practice a "RESTful" HTTP API means five habits: model nouns not verbs, use a hierarchical URI to express containment, give each HTTP method its defined semantics, keep the server stateless between requests, and return representations the client negotiates. HATEOAS (hypermedia links that let a client discover next actions) is the purest form; most production APIs adopt a pragmatic subset.
Resources are nouns
Endpoints name things — /orders, /users/42 — never actions. The verb lives in the HTTP method.
Hierarchy in the URI
Containment reads left to right: /users/42/orders/9 is order 9 belonging to user 42.
Stateless
Every request carries all context (auth, params). No server‑side session affinity, so any node can serve any request.
Representations
A resource has one identity but many representations (JSON, CSV). Clients pick via Accept; servers echo Content-Type.
Content negotiation
Accept, Accept-Encoding, Accept-Language let one URL serve gzip, English, JSON to one caller and CSV to another.
HATEOAS (light)
Include next/self/cancel links in the body so clients follow relations instead of hard‑coding URLs.
Method semantics: safe & idempotent
Two properties drive everything downstream (caching, retries, proxies). Safe = read‑only, no observable state change. Idempotent = making the call N times has the same effect as making it once. Retries and network middleware rely on these guarantees.
| Method | Semantics | Safe? | Idempotent? |
|---|---|---|---|
| GET | Read a resource / collection | yes | yes |
| HEAD | Like GET but headers only (existence, size, ETag) | yes | yes |
| POST | Create a subordinate / trigger processing | no | no |
| PUT | Create‑or‑replace at a known URI (full representation) | no | yes |
| PATCH | Partial update | no | not necessarily |
| DELETE | Remove a resource | no | yes |
A JSON‑Merge‑Patch that sets {"status":"paid"} is idempotent. A JSON‑Patch that says {"op":"add","path":"/tags/-","value":"vip"} appends every time you replay it — not idempotent. Design partial updates as absolute assignments when you want safe retries.
Status codes that actually matter
| Code | Class | Use it when… |
|---|---|---|
| 200 OK | 2xx | Successful read or update with a body. |
| 201 Created | 2xx | A resource was created; return Location of the new resource. |
| 202 Accepted | 2xx | Work was queued; result is not ready yet (async job). |
| 204 No Content | 2xx | Success with nothing to return (e.g. DELETE). |
| 301 Moved Permanently | 3xx | Resource relocated; clients should update the URL. |
| 304 Not Modified | 3xx | Conditional GET; the client's cached copy (ETag/If‑None‑Match) is still valid. |
| 400 Bad Request | 4xx | Malformed syntax the server cannot parse. |
| 401 Unauthorized | 4xx | Missing/invalid credentials (really "unauthenticated"). |
| 403 Forbidden | 4xx | Authenticated but not allowed (authorization failed). |
| 404 Not Found | 4xx | No such resource (or hidden for privacy). |
| 409 Conflict | 4xx | State conflict — duplicate create, version clash, idempotency‑key reuse with a different body. |
| 412 Precondition Failed | 4xx | An If-Match/If-Unmodified-Since guard didn't hold (optimistic concurrency). |
| 422 Unprocessable Entity | 4xx | Syntactically valid but semantically wrong (validation failed). |
| 429 Too Many Requests | 4xx | Rate/quota limit hit; include Retry-After. |
| 500 Internal Server Error | 5xx | Unhandled server fault — a bug, not the client's problem. |
| 502 Bad Gateway | 5xx | An upstream returned an invalid response. |
| 503 Service Unavailable | 5xx | Overloaded / in maintenance; Retry-After tells the client when to come back. |
| 504 Gateway Timeout | 5xx | An upstream didn't answer in time. |
4xx = you (the client) can fix it by changing the request. 5xx = we (the server) failed; the same request may succeed on retry. Only 5xx and 429/503 are safe to retry automatically — and only for idempotent operations.
Resource‑oriented vs RPC‑in‑the‑URL
✅ Do — resources + methods
POST /users/42/orders — create an order
DELETE /orders/9 — cancel it
PATCH /orders/9 {"status":"shipped"}
Small, uniform vocabulary; caches, proxies and clients all understand the verbs.
🚫 Avoid — verbs in the path
POST /createOrder?user=42
GET /deleteOrder?id=9 GET mutates!
POST /order/updateStatusToShipped
Every action is a new bespoke endpoint; nothing is cacheable or predictable.
ETags & conditional requests
An ETag (“entity tag”) is a short fingerprint of a resource’s current version that the server returns in a response header — e.g. ETag: "a1b2c3". It’s typically a hash of the body, a version number, or the row’s updated_at timestamp. The client stores it and hands it back on later requests so the server can answer “is the copy you have still current?” — without re‑sending or blindly overwriting the whole resource. One little header powers two independent features: cheap caching and safe concurrent writes.
Strong vs weak
A strong ETag ("a1b2c3") changes if a single byte changes — required for range requests. A weak ETag (W/"a1b2c3") means “semantically equivalent” and may stay the same across cosmetic differences like whitespace or gzip. Use strong tags for concurrency control.
How it’s generated
Common recipes: a hash of the serialized body (e.g. sha1), a monotonic version column, or the row’s updated_at. It only has to (a) change whenever the resource changes and (b) be cheap to compute.
Use 1 — conditional GET (caching, saves bandwidth)
The client caches the body together with its ETag. Next time it says “only send it if it changed” via the If-None-Match header. If nothing changed, the server replies 304 Not Modified with an empty body and the client reuses its cached copy. You still pay one round trip, but skip re‑downloading (and the server skips re‑serializing) the payload — which is why CDNs love ETags.
# First request — server returns the body + its fingerprint
GET /v1/items/42
← 200 OK
ETag: "a1b2c3"
# Later — client asks "only resend if the tag changed"
GET /v1/items/42
If-None-Match: "a1b2c3"
← 304 Not Modified (empty body — reuse the cached copy)
Use 2 — conditional write (optimistic concurrency, prevents lost updates)
Two clients both read version "a1b2c3", both edit, both save. Without a guard, the second write silently clobbers the first — the classic lost update. With If-Match, a write only applies if the server’s ETag still matches the one the client read; otherwise it’s rejected with 412 Precondition Failed and the client must re‑read and retry. It’s a lock‑free way to serialize edits (“optimistic” = assume no conflict, verify at write time).
# Client edits the copy it read at version "a1b2c3"
PUT /v1/items/42
If-Match: "a1b2c3"
{ ...updated fields... }
← 200 OK server still had "a1b2c3" (applied; a new ETag is returned)
← 412 Precondition Failed someone else wrote first (re-read & retry)
If-None-Match is for reads: “send it only if my copy is stale” → 304 when it’s still fresh. If-Match is for writes: “apply only if my copy is still current” → 412 when someone changed it first. Same fingerprint, mirror‑image conditions.
If-None-Match for cheap 304 revalidation at the CDN; writes use If-Match for optimistic concurrency — that’s how I prevent lost updates without holding a database lock.”Idempotency in APIs
The classic failure: a client POSTs /charges, the server processes it, but the 200 is lost on the way back. The client times out and retries — and now the customer is charged twice. POST is not idempotent, so the fix is an application‑level idempotency key.
Any retrying client (or message queue) gives you at‑least‑once delivery. You can't stop duplicates from arriving — you can only make the server deduplicate them. That's what an idempotency key buys you.
The Idempotency‑Key pattern
- Client mints a unique key. A UUID per logical operation, sent as
Idempotency-Key: 3f0c…. The same key is reused on every retry of that operation. - Server looks it up. First time: the key is unknown → process the request, then persist
key → (status, response body, request fingerprint)atomically with the side effect. - Replay on retry. Same key seen again → skip the work and replay the stored response verbatim. The charge happens exactly once; the client gets a consistent answer.
- Conflict guard. Same key but a different request body (different amount) → the client is confused or malicious → reject with 422/409. Never silently apply the new body.
- Expire keys. Store keys with a TTL (e.g. 24h). Long enough to cover any realistic retry window, short enough to bound storage.
HTTP example
POST /v1/charges HTTP/1.1
Host: api.shop.com
Authorization: Bearer eyJhbGciOi...
Idempotency-Key: 3f0c1e9a-77b2-4f10-9a1e-2b4c9f0d5e21
Content-Type: application/json
{ "amount": 4200, "currency": "usd", "source": "card_9k2" }
--- first response ---
HTTP/1.1 201 Created
Location: /v1/charges/charge_abc
Idempotency-Replayed: false
--- retry with the SAME key ---
HTTP/1.1 201 Created
Location: /v1/charges/charge_abc
Idempotency-Replayed: true # cached, card NOT charged again
--- same key, different body ---
HTTP/1.1 422 Unprocessable Entity
{ "type": "idempotency_key_reuse",
"detail": "Key already used with a different request body." }
If you charge the card, then crash before writing the key, the retry re‑charges. Write the key record and the domain change in the same transaction (or use the key row as a dedup lock before the side effect). Idempotency that isn't atomic is just a comforting illusion.
Pagination
You never return an unbounded collection. The question is how the client walks it. Two families dominate: offset/limit (page numbers) and cursor/keyset (a pointer to "where I left off").
| Dimension | Offset / limit | Cursor / keyset |
|---|---|---|
| Request shape | ?limit=20&offset=40 | ?limit=20&cursor=eyJpZCI6… |
| Deep‑page cost | O(n) — DB must scan & skip offset rows | O(1) — indexed seek to the key |
| Stability under writes | drifts — an insert shifts every page; rows skipped/duplicated | stable — anchored to a sort key, not a position |
| Random access ("jump to page 500") | yes | no — sequential only |
| Total count | Easy but expensive on big tables | Usually omitted on purpose |
| Best for | Small, mostly‑static sets; admin tables with page numbers | Large, high‑write feeds; infinite scroll; APIs at scale |
Offset pagination re‑scans from the top every page, so page 10,000 gets slower and slower, and an insert at the head makes you re‑see or miss rows. A cursor is an opaque, base64 token that encodes the last (sort_key, id) you saw; the DB does an indexed WHERE (created_at, id) < (:ts, :id) seek — constant time and immune to inserts elsewhere.
HTTP — offset (page numbers)
GET /v1/orders?limit=20&offset=40
HTTP/1.1 200 OK
{ "data": [ ... 20 orders ... ],
"page": { "limit": 20, "offset": 40, "total": 1043 } }
HTTP — cursor (opaque token)
GET /v1/orders?limit=20
HTTP/1.1 200 OK
{ "data": [ ... 20 orders ... ],
"page": {
"next": "eyJjcmVhdGVkX2F0IjoiMTA6MDIiLCJpZCI6MTAzfQ==",
"has_more": true
},
"links": { "next": "/v1/orders?limit=20&cursor=eyJjcmVh..." } }
# follow the token; never let the client fabricate a cursor
GET /v1/orders?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...
✅ Do
Sort on an immutable, unique key ((created_at, id)) so the cursor is deterministic.
Return opaque tokens — clients can't build or tamper with them.
Expose has_more; skip a SELECT COUNT(*) on hot paths.
🚫 Avoid
Sorting on a non‑unique field alone (ties → rows straddle page boundaries).
Deep offset on huge tables in a hot API path.
Returning an exact total on every page “because it's nice.”
Versioning
The moment a third party depends on your API, you can't change its shape freely. Versioning gives you a way to ship breaking changes without breaking existing callers. Three placements are common; pick one and be consistent.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URI path | /v1/orders |
Obvious, cache‑friendly, trivial to route & browse. | URL for “the same” resource changes; arguably un‑RESTful (identity shifts). |
| Header (media type) | Accept: application/vnd.api+json;version=1 |
URL stays stable; version is content negotiation, the “pure” way. | Invisible in a browser/log; harder to test by hand; easy to forget. |
| Query param | /orders?version=1 |
Easy to try in a browser; optional with a default. | Pollutes the query space; caching & routing get muddier. |
It's the least surprising for callers and easiest to route at the gateway. Reserve header versioning for hypermedia‑heavy APIs where you truly want one canonical URL per resource.
Additive vs breaking changes
✅ Backward‑compatible (no new version)
Add a new optional field to a response.
Add a new endpoint or a new optional query param.
Add a new enum value — if clients were told to tolerate unknowns.
Relax a validation rule (accept more input).
🚫 Breaking (needs a new version)
Remove/rename a field, or change its type.
Make an optional field required; tighten validation.
Change default behavior, status codes, or error shapes.
Change pagination, auth, or resource semantics.
Be conservative in what you send, liberal in what you accept. Tell clients to ignore unknown fields and tolerate new enum values — then most evolution is additive and you rarely cut a new version.
Deprecation policy
- Announce. Mark responses with
Deprecation: trueand aSunset: Sat, 31 Jan 2026 23:59:59 GMTheader pointing at the removal date; link docs viaLink: <…>; rel="deprecation". - Overlap. Run old and new versions in parallel for a published window (often 6–12 months for public APIs).
- Measure. Track calls per version per client so you know who still needs to migrate — and can reach out.
- Remove. After sunset, return 410 Gone (or 404) for the retired version. No surprises, because you warned in‑band and out‑of‑band.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Jan 2026 23:59:59 GMT
Link: <https://api.shop.com/docs/v2-migration>; rel="deprecation"
Authentication & authorization
AuthN (authentication) answers “who are you?”; AuthZ (authorization) answers “what may you do?”. A 401 means we don't know you; a 403 means we know you but you're not allowed. Validate identity at the gateway, then pass a trusted principal to internal services.
API keys
A long random secret per client. Simple, good for server‑to‑server and low‑risk read APIs. No user identity, no expiry by default — rotate and scope them.
OAuth2 client‑credentials
Machine‑to‑machine: exchange client_id+secret for a short‑lived access token. No end user involved; scopes limit what the token can do.
JWT bearer tokens
Self‑describing header.payload.signature. The resource server validates the signature + claims without a DB call — stateless auth at scale.
mTLS
Both sides present certificates; identity is the cert. Ideal for internal service‑to‑service traffic inside a mesh where you control both ends.
Anatomy of a JWT
A JWT is three base64url segments joined by dots. The middle segment carries claims; the signature (HMAC or RSA/EC) proves it wasn't tampered with.
# header . payload . signature
eyJhbGciOiJSUzI1NiJ9 . eyJpc3MiOiJhdXRoLnNob3AuY29tIiwi... . NHVaYe26MbtO...
# decoded payload (claims)
{
"iss": "https://auth.shop.com", # issuer — who minted it
"aud": "https://api.shop.com", # audience — who it's for
"sub": "client_9k2", # subject — the principal
"scope": "orders:read orders:write",# granted permissions
"exp": 1767225599, # expiry (unix) — short‑lived
"iat": 1767221999
}
On each request: (1) verify the signature against the issuer's published key; (2) check exp is in the future; (3) check aud matches your API; (4) check iss is trusted; (5) enforce the required scope. Skipping any one turns “stateless auth” into “anyone can forge a token.” Keep access tokens short‑lived and use a refresh token to get new ones.
Token request & use
POST /oauth/token HTTP/1.1
Host: auth.shop.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=svc_a&client_secret=•••&scope=orders:read
HTTP/1.1 200 OK
{ "access_token": "eyJhbGciOi...", "token_type": "Bearer",
"expires_in": 3600, "scope": "orders:read" }
# then call the API
GET /v1/orders HTTP/1.1
Authorization: Bearer eyJhbGciOi...
Rate limits (the API surface)
Rate limiting protects you from abuse and from one noisy client starving everyone else. Here we cover the HTTP contract you expose; the algorithms (token bucket, sliding window) live in the Design Drills chapter.
429 + Retry‑After
When a client exceeds its limit, return 429 and a Retry-After telling it exactly how long to back off.
Limit headers
Advertise budget on every response: X-RateLimit-Limit, -Remaining, -Reset so good clients self‑throttle.
Tiers & cost weights
Different quotas per API key/plan; charge expensive endpoints more “tokens” than cheap ones (cost‑weighted limits).
# within budget
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 3
X-RateLimit-Reset: 1767222060
# over budget
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1767222060
Content-Type: application/problem+json
{ "type": "https://api.shop.com/errors/rate-limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Quota of 1000 req/min exhausted. Retry in 30s." }
A well‑behaved client should never need to hit a 429 — the Remaining/Reset headers let it pace itself. When it does get 429'd, Retry-After removes all guesswork about backoff timing.
The async job / long‑running task pattern
Some work can't finish inside one HTTP request — a video transcode, a report build, a bulk import. Holding the connection open for minutes is fragile (timeouts, retries re‑trigger the work, no progress signal). The fix is to turn the operation into a resource: accept the work, hand back a task id, and let the client track it.
Step 1 — accept the work (202)
The client POSTs to a collection of tasks. The server enqueues the job and immediately returns 202 Accepted with the new task's id and a Location header pointing at the status resource. Send the Idempotency-Key here too, so a retried submit doesn't enqueue the job twice.
POST /v1/tasks HTTP/1.1
Host: api.shop.com
Authorization: Bearer eyJ...
Idempotency-Key: 9b1f-...-c3
Content-Type: application/json
{ "type": "report.export", "range": "2026-Q2", "format": "csv" }
HTTP/1.1 202 Accepted
Location: /v1/tasks/task_7Ka9
Content-Type: application/json
{ "id": "task_7Ka9", "status": "queued", "progress": 0 }
Step 2 — poll the task (state machine)
The client follows Location and GETs the task until it reaches a terminal state. The status field is a small state machine.
# still working
GET /v1/tasks/task_7Ka9
HTTP/1.1 200 OK
{ "id": "task_7Ka9", "status": "running", "progress": 62 }
# finished — includes where to fetch the result
GET /v1/tasks/task_7Ka9
HTTP/1.1 200 OK
{ "id": "task_7Ka9", "status": "succeeded", "progress": 100,
"result": { "href": "/v1/tasks/task_7Ka9/result" } }
# fetch the actual output
GET /v1/tasks/task_7Ka9/result
HTTP/1.1 200 OK
Content-Type: text/csv
...report bytes...
Return a Retry-After on the status resource so clients poll at a sane cadence (and back off as the task ages). Better still, offer both polling and webhooks so latency‑sensitive clients don't hammer you.
Step 3 — webhook callback (the push alternative)
Instead of polling, the client registers a callback URL. When the task finishes, your server POSTs the event to the client. This is efficient but shifts reliability burden onto you: the receiver may be down, slow, or duplicate‑sensitive.
Sign it (HMAC)
Include an X-Signature = HMAC‑SHA256(body, shared_secret) so the receiver can verify the payload really came from you and wasn't tampered with.
Retry with backoff
Non‑2xx or timeout → retry on an exponential schedule. After N attempts, give up and dead‑letter the event for manual replay.
Idempotent on the receiver
Every event carries a unique event_id. Because you retry, the receiver must dedupe on that id — at‑least‑once again.
Ordered? Don't assume
Retries reorder delivery. Put a sequence/timestamp in the event and let receivers ignore stale ones.
# server → client callback when the task completes
POST https://client.example.com/hooks/tasks HTTP/1.1
Content-Type: application/json
X-Event-Id: evt_5m2p
X-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015...
X-Delivery-Attempt: 1
{ "event_id": "evt_5m2p", "type": "task.succeeded",
"task": { "id": "task_7Ka9", "status": "succeeded",
"result": { "href": "/v1/tasks/task_7Ka9/result" } } }
# receiver must ACK fast with 2xx; anything else = redelivery
HTTP/1.1 200 OK
Poll vs webhook
| Aspect | Polling (client pulls) | Webhook (server pushes) |
|---|---|---|
| Latency to result | Up to one poll interval | Near‑instant |
| Wasted calls | Many empty GETs | None — fires once (plus retries) |
| Client infra | None — just make requests | Needs a public HTTPS endpoint |
| Reliability owner | Client (keep polling) | Server (retry, sign, dead‑letter) |
| Firewall friendliness | Works everywhere | Blocked behind NAT/private nets |
| Dedup needed? | No (idempotent GET) | Yes — at‑least‑once delivery |
Webhooks for low latency, polling as the reliable fallback (and for clients that can't expose an endpoint). If a webhook exhausts its retries, the task's status resource is still the source of truth — the client can always GET it.
Webhook retry schedule & dead‑letter
- Attempt, then back off. Deliver; on failure retry after ~1s, 5s, 30s, 2m, 10m, 1h… (exponential + jitter) to avoid thundering herds.
- Cap the attempts. After, say, 8 tries over ~24h, stop retrying that delivery.
- Dead‑letter it. Park the undelivered event so it can be inspected and manually redelivered once the receiver recovers.
- Expose redelivery. Let clients trigger a replay (
POST /webhooks/deliveries/{id}/retry) — closing the loop without losing data.
Structured errors (RFC 9457 problem+json)
Whether a task fails or a request is rejected, return a machine‑readable error body, not a bare string. RFC 9457 (which obsoletes the older RFC 7807) standardizes application/problem+json: type, title, status, detail, instance, plus any custom extension members.
# a failed task surfaces the error in its status resource
GET /v1/tasks/task_7Ka9
HTTP/1.1 200 OK
{ "id": "task_7Ka9", "status": "failed",
"error": {
"type": "https://api.shop.com/errors/source-unavailable",
"title": "Data source unavailable",
"status": 503,
"detail": "Warehouse DB timed out after 3 retries.",
"instance": "/v1/tasks/task_7Ka9",
"retryable": true
} }
End‑to‑end: submit → poll → result (+ webhook)
Cheat sheet & talking points
Cheat sheet — API Design
- REST: nouns in the URI, verbs as HTTP methods, stateless, negotiate representations.
- Safe/idempotent:
GET/HEADsafe+idempotent;PUT/DELETEidempotent;POSTneither;PATCHonly if writes are absolute. - Status classes: 4xx = client can fix it; 5xx = server failed (retry‑able). Retry only idempotent ops, and only on 5xx / 429 / 503.
- Idempotency: client sends
Idempotency-Key; server stores key→response atomically, replays on retry, 409/422 on same‑key‑different‑body. - Pagination: cursor/keyset over offset for scale — O(1) seeks, stable under inserts; opaque tokens, unique sort key, skip
COUNT(*). - Versioning: URI
/v1is simplest; additive changes need no bump; breaking ones do. Deprecate withSunset/Deprecation+ an overlap window. - Auth: AuthN (401) ≠ AuthZ (403). Short‑lived JWTs; validate sig + exp + aud + scope at the gateway. mTLS for service‑to‑service.
- Rate limits: 429 +
Retry-After; advertiseX-RateLimit-Limit/Remaining/Reset; per‑key tiers & cost weights. - Async jobs: 202 + task id +
Location→ poll status state machine → fetch result; or signed, retried, idempotent webhook. Errors asproblem+json.