A REST API

Design an API a stranger can guess: resources, the right verbs, honest status codes and one error shape.

09of 18
≈ 13Min read
7Parts

Resources, not actions

  • Plural nouns, lower case, hyphens if you must: /door-finishes.
  • Nest one level to express ownership; deeper than that becomes unreadable. /orders/88 beats /doors/12/orders/88.
  • The path identifies what, the method says what to do with it.
nouns in the path, verbs in the method
GET    /doors            list
POST   /doors            create
GET    /doors/12         read one
PUT    /doors/12         replace it
PATCH  /doors/12         change part of it
DELETE /doors/12         remove it

GET    /doors/12/orders  the orders of that door

// not this
POST /getDoors
POST /createDoor
POST /deleteDoorById?id=12

What each verb promises

Idempotent means doing it twice has the same effect as doing it once. That is what lets a client retry safely after a timeout — and it is why a retried POST can create two doors while a retried PUT cannot.

VerbSafeIdempotentTypical answer
GETyesyes200, or 404
POSTnono201 with Location
PUTnoyes200 with the object, or 204
PATCHnonot necessarily200 with the object
DELETEnoyes204, and 404 if it was never there

Validation, and the status it deserves

  • Report every problem at once. A form that reveals its rules one field per request is hostile.
  • Validate at the edge, before anything else touches the data. A schema library (zod, valibot) turns this into three lines and gives you the types.
  • Never echo back the invalid value in the message — it ends up rendered into someone’s page.
one shape for every failure
// 400 — the request is malformed (broken JSON, missing path parameter)
// 422 — understood perfectly, but the values are wrong
{
  "error": "Validation failed",
  "details": [
    { "field": "name", "message": "must be at least 2 characters" },
    { "field": "days", "message": "must be a whole number of days" }
  ]
}

Lists need limits

Cap the limit server-side — a client asking for limit=1000000 should get 100. Cursor pagination (?after=<id>) is better for large or changing collections, because offsets skip rows when something is inserted while you page.

never return everything
GET /doors?limit=20&offset=40&sort=days&q=oak

{
  "items": [ … ],
  "total": 137,
  "limit": 20,
  "offset": 40
}

Consistency beats cleverness

  • One date format everywhere: ISO 8601, in UTC — 2026-09-21T10:00:00Z.
  • One naming convention for fields: pick snake_case or camelCase and never mix.
  • Ids as strings. A number that is never arithmetic is a string that will survive becoming a UUID.
  • Money in the smallest unit, as an integer, with the currency next to it. Never a float.

Versioning

Adding a field is not a breaking change; removing one, renaming one or changing its type is. Version when you break, and keep the old version alive long enough for clients to move.

decide before anyone depends on you
/api/v1/doors                       // in the path — obvious, easy to route
Accept: application/vnd.doors.v1+json   // in a header — purer, less visible

The details that make it usable

  • Location on a 201, so the client knows where the new thing lives.
  • Cache-Control and ETag on reads that repeat.
  • Retry-After with a 429 or a 503.
  • CORS configured deliberately, not * because it made the error go away.
  • A written description — OpenAPI, or a page of examples that are actually correct.

Practice