API Design
Chapter 2

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.

RESTETagsIdempotencyPagination VersioningAuthRate limitsAsync jobs

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.

Anatomy of a resource URI https:// api.shop.com /v1 /users /42 /orders /9 host version collection resource id sub‑collection + id Read it as: “order 9 that belongs to user 42, served by v1 of the API.” Plurals for collections; ids select one member.
A resource URI encodes identity and containment — no verbs, no query‑string actions.

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.

MethodSemanticsSafe?Idempotent?
GETRead a resource / collectionyesyes
HEADLike GET but headers only (existence, size, ETag)yesyes
POSTCreate a subordinate / trigger processingnono
PUTCreate‑or‑replace at a known URI (full representation)noyes
PATCHPartial updatenonot necessarily
DELETERemove a resourcenoyes
Why PATCH is only sometimes idempotent

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

CodeClassUse it when…
200 OK2xxSuccessful read or update with a body.
201 Created2xxA resource was created; return Location of the new resource.
202 Accepted2xxWork was queued; result is not ready yet (async job).
204 No Content2xxSuccess with nothing to return (e.g. DELETE).
301 Moved Permanently3xxResource relocated; clients should update the URL.
304 Not Modified3xxConditional GET; the client's cached copy (ETag/If‑None‑Match) is still valid.
400 Bad Request4xxMalformed syntax the server cannot parse.
401 Unauthorized4xxMissing/invalid credentials (really "unauthenticated").
403 Forbidden4xxAuthenticated but not allowed (authorization failed).
404 Not Found4xxNo such resource (or hidden for privacy).
409 Conflict4xxState conflict — duplicate create, version clash, idempotency‑key reuse with a different body.
412 Precondition Failed4xxAn If-Match/If-Unmodified-Since guard didn't hold (optimistic concurrency).
422 Unprocessable Entity4xxSyntactically valid but semantically wrong (validation failed).
429 Too Many Requests4xxRate/quota limit hit; include Retry-After.
500 Internal Server Error5xxUnhandled server fault — a bug, not the client's problem.
502 Bad Gateway5xxAn upstream returned an invalid response.
503 Service Unavailable5xxOverloaded / in maintenance; Retry-After tells the client when to come back.
504 Gateway Timeout5xxAn upstream didn't answer in time.
Rule of thumb for the class

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.

Say this: “I model resources as nouns and let the HTTP method carry the verb, because the safe/idempotent guarantees are what let proxies cache and clients retry without corrupting state.”

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 vs If-Match — same tag, opposite question

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.

Say this: “I return an ETag on every resource. Reads use 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.

The at‑least‑once reality

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Expire keys. Store keys with a TTL (e.g. 24h). Long enough to cover any realistic retry window, short enough to bound storage.
Client Payments API Key Store POST /charges · Idempotency‑Key: K1 lookup K1 → miss charge card store K1 → 201 body 201 Created (charge_abc) ⚠ response lost → client times out & retries POST /charges · Idempotency‑Key: K1 (same) lookup K1 → HIT 201 Created (charge_abc) — replayed, no 2nd charge
Idempotency‑Key flow: the retry returns the cached result, so the card is charged exactly once.

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." }
Persist the key with the side effect, atomically

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").

DimensionOffset / limitCursor / keyset
Request shape?limit=20&offset=40?limit=20&cursor=eyJpZCI6…
Deep‑page costO(n) — DB must scan & skip offset rowsO(1) — indexed seek to the key
Stability under writesdrifts — an insert shifts every page; rows skipped/duplicatedstable — anchored to a sort key, not a position
Random access ("jump to page 500")yesno — sequential only
Total countEasy but expensive on big tablesUsually omitted on purpose
Best forSmall, mostly‑static sets; admin tables with page numbersLarge, high‑write feeds; infinite scroll; APIs at scale
Why cursors win 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.

Cursor walks an ordered set (sorted by created_at, id) #10110:00 #10210:01 #10310:02 ◀ cursor #10410:03 #10510:04 #10610:05 ◀ next #10710:06 later page 1 → returns cursor = encode(10:02, 103) page 2 → WHERE (created_at,id) > (10:02,103) LIMIT 3
The cursor is the last row seen; page 2 seeks strictly past it — no offset scan, no drift when new rows arrive.

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.

StrategyExampleProsCons
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.
Most teams ship URI versioning

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 valueif 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.

Robustness principle

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

  1. Announce. Mark responses with Deprecation: true and a Sunset: Sat, 31 Jan 2026 23:59:59 GMT header pointing at the removal date; link docs via Link: <…>; rel="deprecation".
  2. Overlap. Run old and new versions in parallel for a published window (often 6–12 months for public APIs).
  3. Measure. Track calls per version per client so you know who still needs to migrate — and can reach out.
  4. 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
}
Validate every token — don't just decode it

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.

Client Auth Server Resource API POST /token · client_id + secret · grant=client_credentials verify + sign JWT 200 · access_token (JWT, exp 1h) GET /orders · Authorization: Bearer <JWT> validate sig+exp+aud+scope 200 OK · data
OAuth2 client‑credentials: get a short‑lived token once, present it as a bearer token; the API validates the signature and claims locally.

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...
Say this: “I authenticate at the gateway and hand a verified principal downstream. Tokens are short‑lived JWTs; every service validates signature, expiry, audience and scope locally, so auth stays stateless and horizontally scalable.”

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." }
Make the limit machine‑readable

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.

202
Accepted (work queued)
GET
Poll task status
Webhook
Push on completion
9457
problem+json errors

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.

queued running succeeded failed canceled worker picks up done error DELETE /tasks/id Terminal states are final — succeeded / failed / canceled never transition again.
Task lifecycle: queued → running → { succeeded | failed | canceled }. Terminal states carry a result URL or a structured error.
# 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...
Polling hygiene

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

AspectPolling (client pulls)Webhook (server pushes)
Latency to resultUp to one poll intervalNear‑instant
Wasted callsMany empty GETsNone — fires once (plus retries)
Client infraNone — just make requestsNeeds a public HTTPS endpoint
Reliability ownerClient (keep polling)Server (retry, sign, dead‑letter)
Firewall friendlinessWorks everywhereBlocked behind NAT/private nets
Dedup needed?No (idempotent GET)Yes — at‑least‑once delivery
Offer both

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

  1. Attempt, then back off. Deliver; on failure retry after ~1s, 5s, 30s, 2m, 10m, 1h… (exponential + jitter) to avoid thundering herds.
  2. Cap the attempts. After, say, 8 tries over ~24h, stop retrying that delivery.
  3. Dead‑letter it. Park the undelivered event so it can be inspected and manually redelivered once the receiver recovers.
  4. 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)

Client API Worker POST /tasks (report.export) enqueue job 202 Accepted · id=task_7Ka9 · Location processing… GET /tasks/task_7Ka9 200 · status=running · progress=62 job done → mark succeeded parallel path ↓ webhook push (signed, retried, idempotent) POST client /hooks · task.succeeded · X‑Signature 200 OK (ack) GET /tasks/task_7Ka9 200 · status=succeeded · result.href GET /tasks/task_7Ka9/result → 200 CSV
The full pattern: 202 to accept, poll the status resource to completion, fetch the result — with a signed webhook as a parallel low‑latency notification. Either path leads to the same source‑of‑truth task resource.
Say this: “For anything longer than a request budget I return 202 with a task resource. Clients poll the status state machine or subscribe to a signed, retried, idempotent webhook — and the task resource stays the single source of truth, so the two paths never disagree.”

Cheat sheet & talking points

Cheat sheet — API Design

  • REST: nouns in the URI, verbs as HTTP methods, stateless, negotiate representations.
  • Safe/idempotent: GET/HEAD safe+idempotent; PUT/DELETE idempotent; POST neither; PATCH only 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 /v1 is simplest; additive changes need no bump; breaking ones do. Deprecate with Sunset/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; advertise X-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 as problem+json.
Say this: “Every write in my API is safe to retry — either the method is idempotent, or I add an idempotency key. That single discipline is what makes the whole surface robust against the unreliable network.”