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/88beats/doors/12/orders/88. - The path identifies what, the method says what to do with it.
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.
| Verb | Safe | Idempotent | Typical answer |
|---|---|---|---|
| GET | yes | yes | 200, or 404 |
| POST | no | no | 201 with Location |
| PUT | no | yes | 200 with the object, or 204 |
| PATCH | no | not necessarily | 200 with the object |
| DELETE | no | yes | 204, 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.
// 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.
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_caseorcamelCaseand 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.
/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
Locationon a 201, so the client knows where the new thing lives.Cache-ControlandETagon reads that repeat.Retry-Afterwith 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.