Tags: web-dev concept

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 OK with {"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_id on every response, success included. It’s the join key between a customer complaint and a log line — Observability
  • Say whether a retry is sensible. 429 and 503 should carry Retry-After; 400 never 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

ApproachWhereReality
URL path/v1/ordersUgly, obvious, easy to route and cache. The pragmatic default
HeaderAccept: application/vnd.x.v2+jsonPurer, harder to test by hand, easy to get wrong in a cache key
Date-basedX-Api-Version: 2026-08-17Pins 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-Key on 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_at and updated_at cost 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