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.

3 min read 697 words

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 # list
GET /api/v1/projects/{id} # one
GET /api/v1/projects/{id}/deployments # nested list
GET /api/v1/projects?owner=alice&state=active

What 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/charges
Idempotency-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 2xx with 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.

Related

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.