API design
Read firstScaling from one server
An API is the one part of your system you cannot change quietly. The database behind it can be replaced over a weekend. The API is compiled into mobile apps that people will not update for a year, into partners' code you have never seen, and into scripts nobody remembers writing. Every design question on this site has an API step, and this chapter is the reasoning those steps share: how to page, how to make retries safe, how to change things without breaking anyone, and what each status code should mean.
Your mobile app retries POST /orders when a request times out. What can go wrong, and what would you add to the API to make it safe?
Pick a style first#
Four shapes cover almost everything:
- REST over HTTP and JSON. Resources with URLs, standard verbs, readable with curl. The default for anything a browser, a partner or another team will call.
- gRPC. Typed methods over HTTP/2 with a generated client. Faster and stricter, and awkward from a browser. A good fit between your own services.
- GraphQL. One endpoint where the client asks for exactly the fields it needs. Helps when many different screens need different slices of the same data, and makes caching and rate limiting harder.
- Events and webhooks. You call them when something happens, instead of them asking you. Webhooks vs polling covers when that is worth it.
REST vs GraphQL vs gRPC compares the first three in depth. In a design round, say which one you picked and why in one sentence, then move on. The rest of this chapter assumes REST, because it is what interviewers expect on the whiteboard.
- StripeOften quoted for idempotency keys and dated API versions
- GitHubREST and GraphQL APIs side by side
- GraphQLQuery language: the client picks the fields
- gRPCTyped calls over HTTP/2, from Google
Status codes that mean something#
A status code is the part of the answer every client, proxy and dashboard can read without parsing your body. Use few, and use them precisely. These are the ones the design pages on this site rely on:
| Code | Means | Where you have seen it |
|---|---|---|
| 201 Created | It now exists | A hold on seats, a new job |
| 202 Accepted | Taken, not finished | A payment, a notification, a ride request |
| 204 No Content | Done, nothing to say | A driver's location update |
| 304 Not Modified | Your copy is still right | A crawler's recrawl with an etag |
| 400 Bad Request | Your request is malformed | Missing fields |
| 401 Unauthorized | I do not know who you are | No token, or an expired one |
| 403 Forbidden | I know you, and you may not | A user acting on another user's account |
| 404 Not Found | Nothing here for you | Also used instead of 403 to avoid leaking what exists |
| 409 Conflict | Someone got there first | The last tatkal seat, seat G5 |
| 410 Gone | It existed and never will again | An expired hold |
| 412 Precondition Failed | It changed since you read it | An edit with an old version |
| 429 Too Many Requests | Slow down | The rate limiter, with Retry-After |
| 503 Service Unavailable | Not now, try later | A server shedding load |
The split that matters most is 4xx against 5xx. A 4xx says "the request is wrong, retrying the same thing will not help". A 5xx says "something is wrong on our side, a retry may work". A client's retry logic is built on that line, so a validation error returned as a 500 creates retries that can never succeed.
Pagination#
Any list that can grow needs pages. The obvious way, "skip the first 20, give me the next 10", has two problems: it gets slower the deeper you go, because the database still walks every skipped row, and it shows the wrong posts when the list changes between pages. Try the second problem.
A cursor fixes both. The response includes an opaque token for "where you stopped", usually the last item's sort key, and the next request asks for items after it. The database jumps straight to that point with an index, so page 1,000 costs the same as page 1, and items added or removed above the cursor do not move anything below it.
GET /v1/feed?limit=20&cursor=eyJpZCI6MTZ9
200 OK
{ "items": [ ... ], "next_cursor": "eyJpZCI6MTF9" }Make the cursor opaque, a string the client passes back without reading. If it is a plain id, clients will start building their own, and you can never change how it works.
Retries need idempotency#
Networks drop responses. The client cannot tell "my request never arrived" from "it arrived, it worked, and the answer was lost", so the only safe client behaviour is to retry, and the only safe server behaviour is to make retries harmless.
Some methods are already safe to repeat. GET reads, PUT sets a value, DELETE removes a thing: doing any of them twice ends in the same state as doing it once. That property is called idempotent. POST creates something, and two POSTs create two things: two orders, two payments.
The fix is an idempotency key. The client makes a random id once, when the user taps "Pay", and sends it with every retry of that tap. The server remembers each key with its response for a while, a day is common, and answers a repeated key with the stored response instead of running the work again. Idempotency keys builds one step by step.
POST /v1/orders
Idempotency-Key: 5e1a7c2b-0d4f-4a8e-9b3a-2f6c1d9e8a07
{ "items": [ ... ] }Change without breaking anyone#
Old clients will call your API for years. A few rules keep you from breaking them:
- Only add. A new optional field, a new endpoint, a new value a client can ignore. Never remove a field, rename it, change its type, or give an old name a new meaning.
- Tell clients to ignore what they do not know. An unknown field or a new enum value in a response should be skipped, not crash the app. Put that in the docs on day one.
- Version when you must break. Either a version in the path, like
/v2/orders, which is plain and easy to route, or a dated version a client pins in a header, which lets you make small breaking changes without a whole new API. Stripe is the well known example of the dated style. - Retire with notice. Announce a date, send a
Sunsetheader with it on every response from the old version, and watch the traffic fall before you switch it off.
One shape for every error#
Every error from every endpoint should look the same, so a client can handle them in one place. RFC 9457, "Problem Details for HTTP APIs", is a standard shape worth copying:
{
"type": "https://api.example.com/errors/seat-taken",
"title": "Seat already taken",
"status": 409,
"detail": "FLR-G5 was held by someone else.",
"request_id": "req_8f3a91"
}The request_id is the most useful field you will add. When a user reports a problem, it
finds the exact request in your logs and traces in seconds. The
observability chapter picks this up.
Long jobs and concurrent edits#
Two situations come up in nearly every design round.
The work takes longer than a request should. Return 202 Accepted with an id straight away, and give the client a way to find out how it went: a status URL to poll, a webhook, or both. A ride request, a payment and a video upload all work this way, because nobody can hold a request open while three drivers are asked in turn.
Two people edit the same thing. Return a version with every read, as an ETag header. The
client sends it back on the update as If-Match. If the thing has changed since, the server
refuses with 412 Precondition Failed instead of overwriting someone else's edit. It is the
version column trick from optimistic locking,
carried over HTTP.
Break it#
Read every step as text
- launch · Healthy. One app version, small lists, and a network that mostly works. Every shortcut in the API is invisible.
- bad network · Down. Users on patchy mobile networks. The app retries POST /orders on every timeout, and some of those first requests had worked, so customers are charged twice. Support tickets arrive before any dashboard notices.
- deep pages · Degraded. A partner scrapes your catalogue with OFFSET paging. Page 50,000 makes the database walk a million rows to throw them away, and it does it for every page after that.
- a renamed field · Down. A cleanup renames price to amount. The new app is fine, and every phone that has not updated, a large share of users for months, crashes on the order screen.
- repaired · Healthy. Idempotency keys on every POST that creates something. Cursor pagination with an index behind it. Additive changes only, with a dated version and a Sunset header for the rare breaking one. Each fix is cheap on day one and expensive later.
Trade-offs#
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Cursor pagination | Page 1,000 is as fast as page 1, and nothing repeats or goes missing when the list changes. | No "jump to page 37", and the cursor has to be built on an indexed sort key. | Any feed, timeline or list that grows or changes while people read it. |
| Idempotency keys on POST | Clients can always retry, so a dropped response never becomes a duplicate. | A store of recent keys and responses, and a little logic in every create endpoint. | Anything that moves money, books something or sends something. |
| Version in the path | Plain to read, route and cache. | A breaking change means a whole new version, so small breaks pile up waiting for one. | Public APIs that change rarely. |
| One error shape with a request id | Clients handle errors in one place, and support finds the request in seconds. | Discipline across every team and every endpoint. | From the first endpoint. Retrofitting it is the hard part. |
Interview replay#
Checkpoint#
1. Which of these is not idempotent by definition?
2. Two new posts arrive while a user is on page 1 of an offset paged feed. What does page 2 show?
3. Same API, now used by a partner's nightly job that pages through ten million products. What changes first?
- 4xx means "do not retry", 5xx means "a retry may work". Client retry logic depends on it.
- POST twice is two things. Idempotency keys make retries safe.
- Cursor, not offset, for anything that grows or changes while people read it.
- Only add. Never remove, rename or reuse a field in a live version.
An API is the part you cannot change quietly, because old clients call it for years. I would pick REST for anything outside the company and gRPC between my own services, and then four habits matter more than the style. Status codes mean something: 4xx is the client's problem and should not be retried, 5xx is mine and may be, 202 is the honest answer for work still in progress, and 409 and 412 are how conflicts and stale edits come back. Lists use cursor pagination, an opaque token built from an indexed sort key, because offsets slow down with depth and repeat or skip items when the list changes. Every POST that creates something takes an idempotency key, so a client can always retry a timeout without creating a second order. Changes are additive only, with a new version and a Sunset header for the rare breaking one. And every error comes back in one shape with a request id, so a user's complaint finds its trace in seconds.