API design
How to choose an API style, shape REST endpoints, paginate lists, make retries safe with idempotency keys, and change an API without breaking the people who use it.
On this page
An API is a contract. Once other code depends on it, every mistake is expensive to fix. In a design discussion, the API step is short, but it shows whether you can name the nouns of the system and say clearly what each call does.
Crux: model the API around the resources the caller cares about, make each call predictable and safe to repeat, and only ever add to it.
Choose a style
Pick one and move on. The choice rarely decides the design.
- REST over HTTP is the default. Resources are nouns in the path, the HTTP verb says what to do, and the status code says what happened. Easy to cache, test and explain.
- GraphQL lets the client name the exact fields it wants from one endpoint. Use it when many different clients (web, phone, partners) need different slices of the same data. Cost: caching is harder, one query can be very expensive unless you limit depth and cost, and a naive server runs one database query per item in a list (the N+1 problem, fixed by batching). Permissions must also be checked per field, not only per endpoint.
- gRPC is a typed, binary contract between your own services. Use it for internal calls where speed and a strict schema matter. Not convenient for browsers or third parties.
RPC-style calls model actions (chargeCard) instead of resources, which suits internal commands. Real-time pushes (WebSocket, server-sent events) are separate tools, covered in the networking lesson.
Say "REST, unless something forces another choice", and name what would force it. Do not pick GraphQL without a concrete over-fetching or many-clients problem to point to.
Shape REST endpoints
Use plural nouns for resources, and let the verb carry the action.
Rules that keep an API predictable:
- Nouns, not verbs, in the path.
POST /links, notPOST /createLink. When an action does not fit a noun, model it as a resource (POST /orders/42/cancellation). - Identity comes from the auth token, never from the request body. A client that sends
"userId": 7can lie. The server takes the caller from the token. - Use status codes for what they mean.
200ok,201created,204ok with no body,400bad input,401not signed in,403not allowed,404not found,409conflict,429too many requests,500and above for server faults. - Keep the body small and the same shape. The same field has the same name and type everywhere. Money as an integer in the smallest unit (cents) plus a currency code, times in a single standard format (ISO 8601, in UTC).
- Put each input where it belongs. A value that identifies the resource goes in the path (
/links/{code}). Optional filters, sorting and paging go in the query string (?status=active&sort=-created_at). The data being created or changed goes in the body. Never put secrets in a URL, because URLs end up in logs. - Be consistent. The same word means the same thing on every endpoint, and the same mistake gives the same kind of answer. A new caller should be able to guess the next endpoint.
- Keep the server stateless. Each request carries what is needed (the token, the cursor), so any server can answer it and servers can be added freely.
- Use one error shape. Return the same envelope for every failure: a stable machine-readable
code("alias_taken"), a humanmessageand, where useful, the offending field. Callers branch on the code and never on message text. - Filter and sort with the same names everywhere, and allow only fields that have an index; otherwise a harmless-looking
?sort=becomes a full scan. - Validate at the edge. Reject bad input with a clear
400and a message the caller can act on.
Safe means it changes nothing (GET). Idempotent means repeating it has the same effect as doing it once (GET, PUT, DELETE). POST is neither, which is why it needs the next trick.
Make retries safe: idempotency keys
A client sends POST /payments, the connection drops, and it does not know whether the payment happened. Retrying risks charging twice. Not retrying risks losing the payment.
The fix is an idempotency key: the client generates a unique ID once per intended action and sends it with every attempt.
POST /payments
Idempotency-Key: 6f1c... (a random UUID the client made)
The server stores the key with the result. If the same key arrives again, it returns the stored result and does nothing new. Details to get right:
- Store the key and the outcome in the same transaction as the work, or a crash between them breaks the guarantee.
- Keep keys for a limited time (for example 24 hours, an assumption) so storage does not grow forever.
- Reject a reused key that comes with a different body.
Paginate every list
A list endpoint that returns "everything" works on day one and falls over on day 300. Always set a page size and a maximum.
Offset pagination (?offset=3&limit=3) is simple but has two problems. If rows are added or removed between pages, the reader sees repeats or gaps. And the database must walk past every skipped row: an offset of 10 million means reading 10 million rows to throw them away.
Cursor pagination (?after=<last id seen>&limit=3) asks for "the rows after this one". It uses an index to jump straight there, so page 1,000 costs the same as page 1, and inserts do not cause repeats.
Return an opaque next cursor in the response ("next": "eyJpZCI6Nn0") and let the client treat it as a black box, so you can change what is inside it later.
The cost of cursors: no "jump to page 57", and you need a stable sort order (for example by created_at then id).
Change it without breaking people
Once anyone depends on an API, treat it like a public promise.
- Additive changes are safe. New optional fields, new endpoints, new enum values (if clients are told to expect them).
- Breaking changes need a new version. Removing or renaming a field, changing a type, changing what a value means. Put the version in the path (
/v2/links: visible, easy to route and test) or in a header (cleaner URLs, easier to forget). In an interview, path versioning is the simple answer. Run both versions for a while. - Deprecate before you remove. Announce, measure who still calls the old one, then retire it.
- Rate limit and say so. Return
429with aRetry-Afterheader, and document the limit. It protects the system and tells honest clients how to behave. Limit per user (or key), per IP for anonymous traffic, and tighter on costly endpoints such as login and search.
### Who is calling - Authentication says who the caller is; authorization says what they may do. Keep both on the server, on every request. - API keys identify a program (a partner, another service). Long-lived, so store them hashed and allow rotation. Tokens such as a signed JWT identify a signed-in user: short-lived, carry the user and their claims, and are checked without a database call. Do not use a bare API key to stand for an end user. - Role-based access (a user is an owner, editor or viewer) is enough for most designs. Also check ownership on each object: GET /links/7/clicks must prove link 7 is the caller's, or any user can read any link by changing a number.
A worked example: the link shortener
Core features: create a short link, follow it, see its clicks. Three resources worth naming: links, clicks, and the caller (from the token).
POST /links body: { url, customAlias?, expiresAt? }
→ 201 { code, shortUrl }
GET /{code} → 302 to the long URL (404 if missing or expired)
GET /links/{code}/clicks?after=<cursor>&limit=50
→ 200 { items, next }
DELETE /links/{code} → 204
Notes you would say out loud:
- The redirect lives at the root path so the short URL is as short as possible.
302(temporary) lets us count every click. A301(permanent) can be cached by the browser, so later clicks would never reach us. That is a trade-off between speed and counting, and worth naming.- The owner of a link comes from the token, so
POST /linkshas noownerIdin the body. - Creating a link with a
customAliasthat exists returns409.
Common mistakes
- Verbs in paths and one endpoint that does everything.
- Trusting a user ID from the body.
- Returning an unbounded list.
- Offset pagination on a big, changing table.
- A
POSTthat charges, sends or books, with no idempotency key. - Renaming a field "because it is cleaner" and breaking every client.
- Free-form error messages that callers have to parse, or a different error shape per endpoint.
- Checking that a caller is signed in but not that the object is theirs.
- Secrets or personal data in query strings.
- Defaulting to GraphQL with no over-fetching problem to solve.
- Spending the interview on the API style and leaving the data model unfinished.
Check yourself
- When would you pick GraphQL or gRPC over REST?
- Which HTTP verbs are idempotent, and why does
POSTneed an idempotency key? - Why does the user ID come from the token and not the body?
- Give two reasons cursor pagination beats offset on a large table.
- What is a breaking change, and how do you ship one? Path or header for the version, and why?
- Where does each input go: path, query or body? Give one example of each.
- An API key and a user token: which fits a partner integration, which a signed-in user, and why?
- What is the N+1 problem in GraphQL?
- Why might the redirect in a link shortener be a
302and not a301?