sehaj/learn
System design/03 Networking and APIs

03.04 · concept · 13 min

API design: resources, pagination, versioning, errors

Designing Snip's public API: resources and verbs, status codes and errors, paging through long lists, and changing the API without breaking anyone.

An API is a promise

Once other people's code calls your API, every detail becomes something you've promised: field names, status codes, the order of results, what an error looks like. Changing any of them can break someone's integration at 3 a.m. Good API design is mostly about making promises you can keep for years.

Resources and verbs

Name the things (nouns) and let HTTP's methods say what to do with them:

http
POST   /links                 create a link            → 201 Created
GET    /links/{code}          read one link            → 200 OK
GET    /links?owner=me        list my links            → 200 OK
DELETE /links/{code}          delete a link            → 204 No Content
GET    /links/{code}/stats    click counts for a link  → 200 OK

Each method carries a meaning clients and proxies rely on:

MethodChanges anything?Safe to repeat?Typical use
GETNoYesRead
PUTYesYes: setting the same value twice ends the sameReplace a whole resource
DELETEYesYes: it's gone either wayRemove
PATCHYesNot necessarilyChange some fields
POSTYesNo: twice can mean two linksCreate, or actions

That last row matters more than it looks. If a client's POST /links times out, it can't tell whether the link was created. Retrying might create a duplicate. 03.10 Idempotency keys fixes that with idempotency keys.

Status codes and errors

Use the status code for the broad outcome, and a consistent body for the details. Clients write code against both:

CodeMeaningSnip example
200, 201, 204WorkedLink returned, created, deleted
302Go elsewhereThe redirect itself
400Your request is malformedlong_url isn't a URL
401 / 403Not signed in / not allowedDeleting someone else's link
404No such thingUnknown code
409Conflicts with current stateCustom code already taken
429Too many requests, slow downRate limit hit
500, 503Our fault; may be worth retryingDatabase down
json
{
  "error": {
    "type": "invalid_request",
    "code": "code_taken",
    "message": "The short code 'launch' is already in use.",
    "param": "custom_code"
  }
}

A machine-readable code lets clients handle each case; the message is for humans. The 4xx/5xx split also tells clients something important: 4xx means don't retry the same request; 5xx means a retry might work.

Paging through long lists

An owner with 50,000 links can't get them in one response. There are two common ways to page:

Offset pagination: GET /links?limit=50&offset=100. Simple, and lets users jump to page 7. But the database still walks past the first 100 rows to find the next 50, which gets slow deep into a list. And if a new link is added while someone is paging, everything shifts by one: they see an item twice, or miss one.

Cursor pagination: the response includes an opaque next_cursor, and the client sends it back:

http
GET /links?limit=50
→ { "data": [ … ], "next_cursor": "eyJjcmVhdGVkX2F0Ijo…" }

GET /links?limit=50&cursor=eyJjcmVhdGVkX2F0Ijo…

The cursor encodes where the last page ended (say, the last link's creation time and code), so the server asks for "the next 50 after this one" using an index. It's fast at any depth and stable when items are added. You lose "jump to page 7", which most APIs don't need. Snip uses cursors; 05.07 Keyset pagination shows the query behind them.

Changing the API without breaking anyone

Some changes are safe for existing clients, and some aren't:

Safe (additive)Breaking
Adding a new endpointRemoving or renaming a field or endpoint
Adding an optional request fieldMaking an optional field required
Adding a field to a responseChanging a field's type or meaning
Adding a new error code (if clients handle unknown ones)Changing which status code a case returns

Make additive changes freely. For breaking ones, you need versioning: old clients keep the old behaviour while new ones opt in. Two common forms:

  • In the URL: /v1/links, then /v2/links. Easy to see and route, but each major version is a big step, and you run both for years.
  • By date, per client: each account is pinned to the API version that was current when it started, and can move forward when ready. More work inside, much smoother for users. That's Stripe's approach, below.

Seen in production

Stripe

2017

API versioning

Stripe names versions by release date, such as 2017-05-24. An account is pinned to the newest version on its first API request, and can override it per request with a Stripe-Version header. Each breaking change is written as a module that transforms a response back to the previous version, and these are applied in order back to the caller's version. Stripe said this had carried almost a hundred backwards-incompatible upgrades while keeping compatibility with every version since 2011.

What we foundPinning each account to a dated version, and translating responses backwards in code, let Stripe keep every old version working.

APIs as infrastructure: future-proofing Stripe with versioningstripe.com ↗
Predict first

Snip's 'list my links' API uses offset pagination, newest first. While a user pages through, another of their links is created. What do they see?

Commit to an answer before you read on.

Try it

Design it yourself firstjuniorapi-design

Design the public REST API for a Pastebin: create a paste (with optional expiry), read one, delete one, and list your own. Write each endpoint with its method, path, request and response, plus the error format.

10:00

Recap

Interview questions

MidfreqMust-knowDesign

How would you design pagination for an API that lists a user's items?

MidfreqGood to knowConcept

How do you evolve an API without breaking existing clients?

Report a mistakeEdit this page