Concepts & Notes
API Design Basics
The contract an HTTP API makes with its callers — what GET, PUT and POST each promise, how to model resources, and the cross-cutting decisions (status codes, pagination, versioning) that decide whether the API survives contact with real clients.
An API is a contract, and the thing that makes HTTP APIs different from ordinary function calls is that infrastructure you don’t control acts on that contract. Browsers prefetch. CDNs cache. Client libraries retry. Every one of those behaviours is keyed off the verb you chose.
So the verbs come first, and everything else is consequence.
The verb contract
Three properties, and every design decision below follows from them:
| Verb | Safe | Idempotent | Cacheable |
|---|---|---|---|
GET |
Yes | Yes | Yes |
PUT |
No | Yes | No |
POST |
No | No | No |
- Safe — no side effect the caller is responsible for. Logging, analytics and cache warming don’t count; nothing changes that a user would mind happening twice.
- Idempotent — repeating the request lands on the same state. Not “returns the same response” — the same effect.
- Cacheable — a proxy, CDN or browser may serve a stored copy without asking you.
These are promises to machines, not notes for humans. Break one and something you don’t own does the wrong thing.
GET — retrieval
Fetch a representation. Change nothing.
GET /v1/books/9780571364688
GET /v1/books?author=ishiguro&limit=20
Because GET is safe and idempotent, you get a large amount of free behaviour:
HTTP caching and ETags,
CDN edge caching, bookmarkable and shareable URLs, browser prefetch, and automatic
retry on timeout in most client libraries.
That free behaviour is also the trap. A GET that mutates state will eventually be
triggered by something that never intended to:
GET /books/123/delete ← a crawler will find this
← a link prefetcher will fire it
← a proxy will retry it on timeout
Nothing in your application code is wrong in that scenario. The verb lied, and
infrastructure believed it. If it changes state, it is not a GET.
PUT — write to a known identifier
The client knows the identifier and sends the complete resource. Repeating it is harmless.
PUT /v1/books/9780571364688
{ "title": "Klara and the Sun", "price": 1299, "stock": 4 }
Two things define PUT: the caller supplies the identity, and the body is a
full replacement rather than a delta. Together those make it idempotent — send it
five times and the resource ends up identical to sending it once.
That idempotency is worth engineering toward, because it makes retries free:
PUT /v1/books/{isbn} → retry safely, no bookkeeping
POST /v1/books → retry may create a second book
Whenever the data has a natural key — an ISBN, an email address, an account
number — prefer PUT on that key. You get retry safety with no extra machinery,
which is the natural idempotency
path rather than the client-generated-key path.
A full replacement also invites a lost update — two clients each PUT a whole
resource and the later one silently discards the earlier’s change. The fix is
optimistic concurrency with If-Match:
the client echoes the ETag it read, and the server rejects the write if the resource
moved on.
POST — everything else
Not a retrieval, and no caller-supplied identity. Repeating it may do the work twice.
POST covers three genuinely different jobs, and it’s worth knowing which one you’re
doing:
1. Create where the server assigns the ID.
POST /v1/orders
→ 201 Created
Location: /v1/orders/8f3c1a
The response should carry the new resource’s location. This is the non-idempotent
case in its purest form: replay it and you get a second order — which is exactly why
POST is where idempotency keys
become necessary rather than optional.
2. Actions that aren’t resource mutations. Triggering a sync, sending an email,
re-running a job. There’s no noun to PUT to, and the operation isn’t idempotent, so
POST is correct rather than a compromise.
POST /v1/books/9780571364688/refresh
3. Reads whose input won’t fit in a URL — the one real gray area, below.
Reads that don’t fit in a URL
GET is right for reads, until the input stops fitting. Three cases force the issue:
- Size — no spec limit, but ~2000 characters is the safe ceiling across browsers, proxies and server defaults. A 100-item batch or a faceted search blows past it.
- Secrecy — URLs leak into access logs, browser history,
Refererheaders, analytics and CDN logs. A token or an email address in a query string is a real exposure even under TLS. - Structure — nested filters and boolean expressions don’t flatten into query params without inventing an encoding.
A body on GET is
not the escape hatch: proxies strip it, some servers ignore it, and the fetch()
spec forbids it. So the convention is a POST that performs a read, named so the
intent is unmistakable:
POST /v1/books:batchGet
{ "isbns": ["9780571364688", "9780571225408"] }
The trade you’re making, explicitly: you give up HTTP caching, ETags, CDN caching
and shareable URLs, and you buy payload size and secrecy. Worth it for a 100-item
batch; not worth it for a single-resource read.
Caching doesn’t disappear — it moves server-side. Cache per item, keyed by identifier, and assemble batch responses from those entries. Whole-response caching of a batch is nearly useless, because the same item recurs across many different batches.
Choosing between them
Work down the list and stop at the first match:
- Retrieving, input fits in a URL, not sensitive →
GET - Retrieving, but the input is large / sensitive / structured →
POST, named like a read (:batchGet,/search) - Writing, and the caller knows the identifier →
PUT(idempotent — prefer this whenever a natural key exists) - Creating, and the server assigns the identifier →
POST+ idempotency key - An action with no resource to mutate →
POST
Resources and naming
URLs name things. Verbs describe what you’re doing to them.
GET /v1/books/{isbn} ✓ noun, plural collection
POST /v1/books/{isbn}/refresh ✓ action, when there's genuinely no noun
GET /v1/getBook?id=... ✗ verb in the path — that's what GET is for
POST /v1/bookUpdate ✗ ditto
Plural collections, identifiers in the path, filters in the query string:
/v1/books the collection
/v1/books/{isbn} one member
/v1/books/{isbn}/reviews a sub-collection that only exists in context
Two modelling rules that matter more than naming:
Name the owner of every field. If your service holds both your own price and a
price mirrored from a third party, those are separate blocks with separate freshness
guarantees — not one price field. Collapsing them is how you end up unable to say
which number is authoritative. This is
single source of truth
applied to a response body.
Stop nesting at one level. /authors/{id}/books/{isbn}/reviews/{rid} forces
callers to know a hierarchy that will change. If a resource has its own identifier,
give it a top-level route.
Status codes and errors
Enough codes to be precise, not the whole registry:
| Code | Meaning |
|---|---|
200 OK |
Success with a body |
201 Created |
New resource; include Location |
204 No Content |
Success, nothing to return |
400 Bad Request |
Malformed — retrying won’t help |
401 / 403 |
Not authenticated / not permitted |
404 Not Found |
No such resource |
409 Conflict |
Violates current state (duplicate, version clash) |
422 Unprocessable |
Well-formed but semantically invalid |
429 Too Many Requests |
Throttled — send Retry-After |
500 / 503 |
Server fault / temporarily unavailable |
The split that actually matters to callers is 4xx means don’t retry, 5xx and
429 mean retry with backoff. Return 400 for a transient fault and clients give
up on work that would have succeeded; return 500 for malformed input and they retry
forever. (See
retries: when and when not.)
Errors need one consistent shape, with a machine-readable code alongside the human message — clients must not have to string-match your prose:
{
"error": {
"code": "isbn_not_found",
"message": "No book with ISBN 9780000000000.",
"requestId": "req_8f3c1a"
}
}
Pagination and filtering
Every collection response must be bounded. An unbounded list endpoint is an unbounded-work endpoint, and it will be discovered eventually.
Cursor-based, not offset-based:
GET /v1/books?limit=20&cursor=eyJpZCI6MTIzfQ
{ "items": [ ... ], "nextCursor": "eyJpZCI6MTQzfQ" }
?offset=200 re-runs the query and skips 200 rows — so if anything was inserted or
deleted meanwhile, callers silently skip and duplicate items across pages, and
the cost grows with the offset. A cursor encodes where you were, which is both
stable under concurrent writes and cheap.
Filters and sorts belong in the query string, with a documented default and a hard
maximum on limit:
/v1/books?author=ishiguro&sort=-published&limit=20
Versioning and evolution
Assume you cannot see every client, and design so you never have to.
/v1/books version in the path — visible, greppable, trivially routable
The version number is the cheap part. The discipline is the rule you commit to:
- Additive only. New optional fields and new endpoints are safe.
- Never repurpose a field, never narrow a type, never make an optional field required.
- Never change the meaning of an existing value. Silent semantic changes are worse than breaking changes, because nothing fails loudly.
Anything you can’t do additively is /v2, running alongside /v1 until callers move.
Two habits that buy real freedom later: clients must ignore unknown fields (so
you can add them), and where a response mirrors data from elsewhere, timestamp it
(asOf) so staleness is part of the contract instead of an assumption.