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:
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 OKEach method carries a meaning clients and proxies rely on:
| Method | Changes anything? | Safe to repeat? | Typical use |
|---|---|---|---|
GET | No | Yes | Read |
PUT | Yes | Yes: setting the same value twice ends the same | Replace a whole resource |
DELETE | Yes | Yes: it's gone either way | Remove |
PATCH | Yes | Not necessarily | Change some fields |
POST | Yes | No: twice can mean two links | Create, 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:
| Code | Meaning | Snip example |
|---|---|---|
| 200, 201, 204 | Worked | Link returned, created, deleted |
| 302 | Go elsewhere | The redirect itself |
| 400 | Your request is malformed | long_url isn't a URL |
| 401 / 403 | Not signed in / not allowed | Deleting someone else's link |
| 404 | No such thing | Unknown code |
| 409 | Conflicts with current state | Custom code already taken |
| 429 | Too many requests, slow down | Rate limit hit |
| 500, 503 | Our fault; may be worth retrying | Database down |
{
"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:
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 endpoint | Removing or renaming a field or endpoint |
| Adding an optional request field | Making an optional field required |
| Adding a field to a response | Changing 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
2017API 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 ↗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?
Try it
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.
Recap
Interview questions
How would you design pagination for an API that lists a user's items?
How do you evolve an API without breaking existing clients?