API design — the decisions that age well
Resource shape, error envelopes, idempotency, pagination, and versioning. What I pick, why, and the failure mode each choice prevents.
Most API design debates rerun the same arguments because the decisions that compound are the boring ones. Here are the five I always lock in early, what I pick, and the failure mode each choice prevents.
Resource shape: nouns, plural, predictable#
Resources are plural nouns. Sub-resources nest one level deep, no further. Filters live in query params, not path segments.
GET /api/v1/projects # listGET /api/v1/projects/{id} # oneGET /api/v1/projects/{id}/deployments # nested listGET /api/v1/projects?owner=alice&state=activeWhat this prevents: the third nesting level (/orgs/{o}/projects/{p}/deployments/{d}/logs/{l}) where every URL becomes a riddle and you can’t deep-link without knowing the full ancestry. The {id} is the contract — anything that has an id can be addressed directly.
Error envelope: structured, stable, code-driven#
Errors return a stable JSON shape regardless of which endpoint produced them. Status code conveys category; code conveys the specific reason; message is human-readable; details is structured.
{ "error": { "code": "validation.field_required", "message": "Field 'email' is required.", "details": { "field": "email" }, "request_id": "req_01J9X8Q…" }}What this prevents: clients parsing English. code is your forever-stable contract; message can change with no breaking-change discipline. request_id is what makes support tickets answerable.
Idempotency: every write that matters takes a key#
Any non-idempotent write (POST that creates, POST that charges, anything that costs money or sends mail) accepts an Idempotency-Key header. Same key inside a 24h window returns the original response — bit-for-bit.
POST /api/v1/chargesIdempotency-Key: cli_2026-04-12T10:11:00Z_a3f...
{ "amount": 4200, "currency": "usd", "customer": "cus_..." }What this prevents: the most expensive bug class in distributed systems — retried writes that succeeded the first time. Network blips, mobile sleeps, lambda retries, CI restarts. Without idempotency keys, every retry is a coin flip on whether you double-charged a customer.
Pagination: cursor, not offset#
Offset/limit
GET /items?offset=400&limit=50- Simple to implement
- Breaks under inserts/deletes (skipped or duplicated rows)
- Linear cost in
offset(DB scans rows it discards) - Total count is “free” but expensive
Cursor
GET /items?cursor=eyJpZCI6...&limit=50- Stable under inserts/deletes
- Constant cost regardless of depth
- No total count by default
- Cursors are opaque tokens (base64-encoded
(sort_key, id))
I default to cursor. If a UI genuinely needs “page 47 of 200,” it’s almost always a sign the user actually wants search/filter, not pagination.
What this prevents: the deep-paginate-to-tar-pit problem at scale, and silent data corruption in clients that paginate while the dataset shifts under them.
Versioning: in the URL, monthly date strings#
/api/v1 for major; Api-Version: 2026-04-01 header for minor. Breaking changes pin to a date; clients opt in by sending the header, otherwise they get the most recent stable.
What this prevents: the three bad alternatives. Header-only major versions hide the API generation in HTTP debugging. URL-only versioning forces a coordinated cutover for trivial breaks. Semantic version numbers (v1.4.2) imply guarantees you can’t keep across HTTP boundaries.
What I’d push back on#
- “Let’s use GraphQL” — different toolset, different tradeoffs; not a substitute for the five decisions above. Solves a real problem (over-fetch, under-fetch in mobile clients) at the cost of caching, monitoring, and rate-limit complexity.
- “Let’s return
2xxwith an error body” — no. HTTP status codes are part of the contract that load balancers, CDNs, and oncall dashboards already understand. - “Let’s auto-generate clients from OpenAPI” — yes, but only after the five above are stable. Code-gen amplifies whatever shape you give it.
The compounding insight: each of these decisions is small in isolation and load-bearing in aggregate. Get them right early, and the next three years of API work is mostly adding endpoints. Get them wrong, and every endpoint inherits the debt.