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
  1. 01Choose a style
  2. 02Shape REST endpoints
  3. 03Make retries safe: idempotency keys
  4. 04Paginate every list
  5. 05Change it without breaking people
  6. 06A worked example: the link shortener
  7. 07Common mistakes
  8. Check yourself

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.

methodpathdoessafe to repeat? POST/linkscreate oneno GET/links/{code}read oneyes GET/links?after=…list a pageyes PUT/links/{code}replace oneyes PATCH/links/{code}change partsnot always DELETE/links/{code}remove oneyes the verb says what you mean; the path says what it is about
Six calls on one resource. The warm rows are the ones you must think about before letting a client retry.

Rules that keep an API predictable:

  • Nouns, not verbs, in the path. POST /links, not POST /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": 7 can lie. The server takes the caller from the token.
  • Use status codes for what they mean. 200 ok, 201 created, 204 ok with no body, 400 bad input, 401 not signed in, 403 not allowed, 404 not found, 409 conflict, 429 too many requests, 500 and 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 human message and, 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 400 and 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.

page 1 showed123 X arrives firstX123456 offset = 33453 again after = 3456clean an offset counts positions; a cursor remembers a place
An offset counts positions, so a new row at the front pushes row 3 onto the second page. A cursor remembers a place.

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 429 with a Retry-After header, 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.

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. A 301 (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 /links has no ownerId in the body.
  • Creating a link with a customAlias that exists returns 409.

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 POST that 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 POST need 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 302 and not a 301?