REST (Representational State Transfer) is an architectural style for web APIs that uses
HTTP's own semantics instead of inventing parallel ones. The payoff is mechanical: clients,
caches, proxies, and monitors already know what GET, 304, and 429 mean, so an API that
uses them correctly gets caching, retries, and observability largely for free. An API that
fights them gets a decade of special cases.
Resource-oriented design
Every entity is a resource identified by a URL; operations are expressed by the HTTP method applied to that URL, not by verbs in the path:
GET /api/users list users (paginated)
GET /api/users/42 fetch one user
POST /api/users create a user
PUT /api/users/42 replace user 42 entirely
PATCH /api/users/42 modify a subset of user 42's fields
DELETE /api/users/42 delete user 42Nested resources express containment, and one level of nesting is usually the limit before URLs become unreadable and authorization rules ambiguous:
GET /api/users/42/orders orders belonging to user 42
POST /api/users/42/orders create an order for user 42
GET /api/orders/9001/items items inside an orderActions that do not map to CRUD belong on subresources or as well-named nested resources
(POST /api/invoices/1042/void) rather than as verbs in the path
(POST /api/voidInvoice?id=1042). The latter works, but it forfeits the method semantics
your clients and proxies already implement.
Two design questions decide most disputes: does the URL identify what (never how), and can a client that has never seen this endpoint predict the shape of the request and response from the resource model alone?
Methods: safety, idempotence, and PATCH
| Method | Idempotent? | Safe? | Purpose |
|---|---|---|---|
| GET | yes | yes | Retrieve a resource |
| HEAD | yes | yes | Same as GET, headers only |
| OPTIONS | yes | yes | Describe supported methods |
| POST | no | no | Create a resource or trigger a process |
| PUT | yes | no | Replace a resource entirely |
| PATCH | no | no | Update part of a resource |
| DELETE | yes | no | Remove a resource |
Idempotent means applying the request twice has the same effect as applying it once — which is what makes a retry after a lost response safe. Safe means no server-side state change, which is what lets caches and prefetchers replay the request without thinking.
PUT versus PATCH is a contract decision: PUT takes the full representation and must
produce the same final state on every replay (fields you omit revert to defaults). PATCH
is partial — but which partial? JSON Merge Patch ({"status":"shipped"}) treats omitted
fields as "leave alone" and cannot express "set this field to null"; JSON Patch
([{"op":"replace","path":"/status","value":"shipped"}]) can, at the cost of verbosity.
Pick one, document it, and do not mix them on the same resource.
Status codes and error bodies
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful GET, PATCH, or DELETE with a body |
| 201 | Created | Successful POST; include a Location header |
| 204 | No Content | Successful request with no body to return |
| 400 | Bad Request | Malformed syntax the client must fix |
| 401 | Unauthorized | Not authenticated — send credentials or refresh |
| 403 | Forbidden | Authenticated, not allowed |
| 404 | Not Found | Resource does not exist (or is hidden from this caller) |
| 409 | Conflict | State conflict: duplicate, version mismatch, concurrent edit |
| 422 | Unprocessable Content | Well-formed but semantically invalid (validation errors) |
| 429 | Too Many Requests | Rate limited — always send Retry-After |
| 500 | Internal Server Error | Unhandled fault; not a validation result |
The body should be as structured as the status line. RFC 9457 ("problem details") gives you a shared vocabulary that HTTP clients already parse:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation",
"title": "Validation failed",
"status": 422,
"detail": "email must be a valid address",
"instance": "/v1/invoices",
"errors": [{ "pointer": "/body/email", "code": "format" }]
}Put a machine-readable code, a human message, and a correlation ID in every error; never
return a stack trace or a SQL fragment to the client.
Pagination: cursor beats offset
Offset pagination looks simple and degrades badly: LIMIT 50 OFFSET 50000 makes the
database find and discard 50,000 rows before returning 50, so page 1,000 costs orders of
magnitude more than page 1 — and any insert above the cursor silently shifts rows between
pages, causing duplicates and skips.
Cursor pagination keys off a stable sort column (usually a descending ID or timestamp):
$ curl 'https://api.example.com/v1/orders?limit=50&after=ord_9f3c1a'{
"data": [{ "id": "ord_9f3c19" }, { "id": "ord_9f3c18" }],
"page": { "next_cursor": "ord_9f3c18", "has_more": true }
}A cursor is an opaque token — the client must not parse it — and it stays O(index lookup) at any depth. Use offsets only for admin tables where users page shallowly and accept resets.
Versioning, idempotency, and rate limits
Three cross-cutting controls finish the contract.
Versioning. Make additive changes (new optional fields) without a new version, and
version only when something must break. URL versioning (/v2) is cacheable and easy to spot in logs;
header negotiation (Accept: application/vnd.api.v2+json) needs Vary: Accept in every
cache in front of you or a CDN will serve the wrong version's body. Full trade-offs are in
designing APIs around explicit semantics.
Idempotency keys. POST is not idempotent, so let clients make it so — send a key the
server stores alongside the response for about 24 hours, then replay that response on retry
instead of creating a second payment:
POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 6b1f2a90-4c3d-4a6e-8f27-1d5b0c9e7a44
Content-Type: application/json
{"amount_cents": 4999, "currency": "USD"}Rate limits. Return 429 with Retry-After, and expose the budget in headers —
RateLimit-Limit: 100, RateLimit-Remaining: 12, RateLimit-Reset: 42 — so clients can
throttle before they are rejected rather than discovering the limit through failures.
Where this bites in production
- Status codes drive client behavior. Return
200with an error in the body and every retry, metric, and alert downstream misclassifies the failure. 401versus403versus404: collapsing them turns correct client logic into infinite retries or silent data loss.- Unbounded endpoints are an outage: a list endpoint without a default
limit, or aDELETEwithout idempotency, is a production incident waiting for one scripted client. - Cache correctness lives in
cache-control,etag, andVary; if you are not specifying them, you are leaving the decision to a proxy you have never met. - Review the API contract in code review against three questions: is it idempotent where retries happen, is every error code unambiguous, and can a new client predict the shapes from the resource model?