How to design an API people love to use
An API is a menu that one program offers to another. A good menu is easy to read, says clearly what went wrong, handles long lists well and doesn't suddenly change under you. In this lesson you fix badly designed requests and see why the good versions work better.
API design is about the contract you'll have to live with for years: resource modelling, error semantics, pagination that holds up under concurrent writes, and evolving the contract without breaking deployed clients. Each unit below is hands-on.
Name things well: the API doctor
Each "patient" below is a request from a badly designed API. The red squiggles mark the problems. Pick the healthier version and watch it get fixed.
Each case is a real-world design smell. Choose the better contract. Advanced mode adds two harder cases where "pure REST" is not the right answer.
- The URL is a noun: the address of a thing (
/users/42). The method is the verb (GET, POST, DELETE…). - Use plural names for lists:
/orders,/orders/17. - GET only ever reads. It must never change anything.
- Use the path for "belongs to" (
/users/42/orders), and query parameters for filters (?sort=price). - Pick one naming style (lowercase) and stick to it everywhere.
- Model resources and collections; let HTTP methods carry the semantics (safe, idempotent, cacheable).
- Give every resource with a unique ID a canonical top-level URL. Nest only for listing (
/teams/12/members). - State transitions with side effects can be explicit actions:
POST /orders/42/cancel, returning 409 on an illegal transition. - Composable query parameters:
filter,sort=-created_at,limit,cursor. Reject unknown parameters with 400. - Write the contract first (OpenAPI) and lint it in CI (e.g. Spectral) so the conventions are enforced, not just remembered.
Speak clearly: status codes and errors
Every response starts with a three-digit status code. Programs read it before anything else, so it has to be right. The first digit already tells you a lot:
Clients, retries, caches, monitoring and alerting all branch on the status code. Choose the most specific correct code, and return a machine-readable error body (RFC 9457 problem details).
(pick a status code)
Bad error vs good error
HTTP/1.1 200 OK
{ "success": false, "msg": "Error" }
- Says 200 OK, so programs think it worked.
- "Error" doesn't say what went wrong or how to fix it.
- 2xx on failure defeats retries, caches, metrics and alerting.
- No stable machine-readable type, so clients end up parsing strings.
- No field-level detail and no correlation ID for support.
HTTP/1.1 400 Bad Request
{
"error": "email is required",
"field": "email"
}
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation",
"title": "Validation failed",
"status": 422,
"detail": "1 field is invalid",
"errors": [{ "pointer": "/email", "detail": "is required" }],
"request_id": "req_7Hk2"
}
- The status code alone tells the program it failed and why.
- The message says exactly which field to fix.
- A stable
typeURI lets clients branch on the error kind. - JSON pointers map errors to form fields;
request_idlinks to your logs. - Never leak stack traces or SQL in
detail.
Long lists: pagination
A feed can have millions of posts, so an API sends them a few at a time ("pages"). There are two common ways to ask for the next page. Watch what happens when a new post arrives while you're scrolling.
Offset pagination versus cursor (keyset) pagination on a feed sorted newest-first, with writes happening between page requests. Press Play the classic bug, or drive it yourself.
LIMIT 3 OFFSET 6. The database scans and discards 6 rows (O(offset)), and the window shifts whenever rows are inserted or deleted above it.Cursor / keyset:
WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 3. This uses the index and stays stable under writes. Return the cursor opaquely, e.g. base64.
Page numbers (offset)Offset pagination
Bookmark (cursor)Cursor pagination
Change safely: breaking or not?
Once apps use your API, you can't just change it, because their code expects the old shape. Decide whether each change is safe to ship or would break apps that already exist.
Assume clients are already deployed and you can't force them to upgrade. Classify each change against that contract. Advanced mode adds the subtle cases that cause real incidents.
✓ Safe to ship
⚠ Breaking
- Expand, then contract: add the new field, migrate clients, then remove the old one after a
Deprecationheader and aSunsetdate (RFC 8594). - Versioning: a URL prefix (
/v2) is simple and visible; header or date-based versions (API-Version: 2026-10-01) allow finer-grained changes per endpoint. - Tolerant reader: tell clients to ignore unknown fields and handle unknown enum values. Then "add" really is safe.
- Run contract tests (consumer-driven, e.g. Pact) or an OpenAPI diff in CI to catch breaking changes before release.