Library
11 min read

API design

CoreAPI designNetworkingUpdated

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 answer

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.

You will hear these named
  • 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:

CodeMeansWhere you have seen it
201 CreatedIt now existsA hold on seats, a new job
202 AcceptedTaken, not finishedA payment, a notification, a ride request
204 No ContentDone, nothing to sayA driver's location update
304 Not ModifiedYour copy is still rightA crawler's recrawl with an etag
400 Bad RequestYour request is malformedMissing fields
401 UnauthorizedI do not know who you areNo token, or an expired one
403 ForbiddenI know you, and you may notA user acting on another user's account
404 Not FoundNothing here for youAlso used instead of 403 to avoid leaking what exists
409 ConflictSomeone got there firstThe last tatkal seat, seat G5
410 GoneIt existed and never will againAn expired hold
412 Precondition FailedIt changed since you read itAn edit with an old version
429 Too Many RequestsSlow downThe rate limiter, with Retry-After
503 Service UnavailableNot now, try laterA 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.

Paging through a feed that keeps moving
next: SELECT ... ORDER BY id DESC LIMIT 5 OFFSET 0
Load page 1. Then let two new posts arrive, and load page 2. Then try deleting a post you have seen before the next page.
Posts are numbered by id, newest first, five to a page. A real cursor is usually an opaque string that encodes the last id and timestamp, so clients cannot build their own.

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.

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

http
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 Sunset header 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:

json
{
  "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#

An API in the wild
launch
launchbad networkdeep pagesa renamed fieldrepaired
Healthy. One app version, small lists, and a network that mostly works. Every shortcut in the API is invisible.
Read every step as text
  1. launch · Healthy. One app version, small lists, and a network that mostly works. Every shortcut in the API is invisible.
  2. 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.
  3. 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.
  4. 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.
  5. 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#

ChoiceWhat you gainWhat you payPick it when
Cursor paginationPage 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 POSTClients 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 pathPlain 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 idClients 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#

Interviewer
Design the API for placing an order.
Checking for more than a URL and a body.
You
POST /v1/orders with the items and an Idempotency-Key header the app makes once per tap. If the order can be confirmed straight away it returns 201 with the order. If payment is still in progress it returns 202 with the order id, and the client polls GET /v1/orders/{id} or gets a webhook. Errors come back in one shape with a request id.
Idempotency, the honest status code and the error shape, all in the first answer.
Interviewer
How does the client page through order history?
Pagination.
You
A cursor, not an offset. The response has the items and an opaque next_cursor built from the last item's created time and id. The next request passes it back, and the database seeks straight to it on an index. Offset paging gets slower with depth and repeats or skips items when new orders arrive while someone is scrolling.
Picks a cursor and gives both reasons.
Interviewer
Product wants to rename "price" to "amount".
Versioning.
You
I would add amount and keep returning price as well, documented as deprecated. Old apps keep working and new apps read the new field. Removing price is a breaking change, so it waits for the next version, and only once traffic from apps that read it has dropped to almost nothing.
Add, never rename, and let the traffic decide when to remove.

Checkpoint#

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?

Worth memorising
  • 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.
Say this in 60 seconds

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.