API Design
Date: 2026-08-17
Designing an interface other people build against and you can’t change afterwards. Most of the difficulty isn’t the happy path — it’s errors, pagination and versioning, which are decided late, badly, and then permanently.
API design is deciding the contract of an interface — resources, operations, errors, pagination, versioning — that clients will build against.
Resources and naming
Nouns for things, HTTP verbs for actions. The verb is already in the request; repeating it in the path is the commonest smell.
✗ POST /getOrder ✓ GET /orders/1234
✗ POST /createOrder ✓ POST /orders
✗ POST /orders/1234/cancel-order ✓ POST /orders/1234/cancellation
✗ GET /order_list ✓ GET /orders
Conventions worth fixing on day one, because they’re unchangeable by day two: plural collection names, lowercase-hyphenated paths, one casing for fields throughout (snake_case or camelCase, pick either, never both), ISO 8601 timestamps with a timezone (2026-08-17T09:30:00Z), ISO 4217 currency codes, and money as integer minor units — 1499 and "GBP", never 14.99 as a float — Revenue Metrics.
Actions that aren’t really state on a resource are the awkward case. POST /orders/1234/refunds works because a refund is a thing; POST /search works because the alternative is a URL too long to log. Don’t contort the model to avoid one honest exception.
Errors
The single most under-designed part of most APIs, and the part clients handle most often.
{
"error": {
"type": "validation_failed", ← stable, machine-readable, documented
"message": "Postcode is not a valid UK format", ← for a human log
"field": "shipping_address.postcode",
"request_id": "req_01HXYZ..." ← the thing support will ask for
}
}- A stable string code, not a number and not the message. Clients will branch on whatever you give them; the message is the one thing you want to stay free to reword
- HTTP status carries the category, the body carries the specifics — 4xx the caller’s problem, 5xx yours. Never
200 OKwith{"success": false}, which defeats every retry, cache and monitoring layer in the path — HTTP Semantics - Return all validation failures at once. One-at-a-time forces a request per mistake
- A
request_idon every response, success included. It’s the join key between a customer complaint and a log line — Observability - Say whether a retry is sensible.
429and503should carryRetry-After;400never should
Pagination
Offset pagination is the obvious one and it breaks under exactly the conditions production has.
OFFSET CURSOR
GET /orders?limit=20&offset=40 GET /orders?limit=20&after=eyJpZCI6MTIzfQ
page 2 requested cursor encodes "after order 1234, by created_at"
↓ new order arrives, top of list
page 3 requested new arrivals don't shift the window
→ one row silently skipped → nothing skipped, nothing duplicated
also: OFFSET 200000 makes the stable, and the query stays fast at any depth
database count 200,000 rows first
Use cursors for anything ordered by time or that changes while being read. Offset is acceptable for small, static, user-facing lists where jumping to page 7 matters. Always return the cursor in the response rather than making clients construct it, and always send a limit default and a hard maximum.
Versioning
| Approach | Where | Reality |
|---|---|---|
| URL path | /v1/orders | Ugly, obvious, easy to route and cache. The pragmatic default |
| Header | Accept: application/vnd.x.v2+json | Purer, harder to test by hand, easy to get wrong in a cache key |
| Date-based | X-Api-Version: 2026-08-17 | Pins a client to the behaviour on a date. Excellent, and the most work |
The version you don’t cut is the cheapest one. Most changes can be additive — new optional fields, new endpoints — and clients must be told in writing that unknown fields are to be ignored, not rejected, or your additive changes become breaking ones on their side. Everything else is Backwards Compatibility and Deprecation.
The rest of the contract
- Idempotency on anything that charges money. A client-supplied
Idempotency-Keyon POST, so a retried payment isn’t a second payment — Idempotency - Filtering and sorting as query parameters, with a documented allowlist.
?sort=-created_at&status=pending - Sparse fieldsets (
?fields=id,total) if payloads are large — cheaper than a new endpoint per screen - Rate limits in headers, so clients can back off before they’re rejected — Rate Limiting
- Timestamps on everything.
created_atandupdated_atcost nothing and answer most support questions - The spec is the source of truth. OpenAPI or equivalent, generated from or validated against the implementation. Hand-written documentation drifts within a quarter
Where it interacts
- REST GraphQL and RPC — this note assumes REST-shaped HTTP; the same concerns exist in the others, differently distributed
- Webhooks — the outbound half of the contract, and it needs the same rigour applied to a delivery model that has none of the guarantees
- Backend for Frontend — where per-client shaping goes, so the shared API doesn’t grow a parameter per screen