TRT
Sign in
Networking

REST API Design

Resource-oriented design, HTTP methods and status codes, idempotency, versioning, pagination, and error response patterns.

The Runtime Theory Team4 min read

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:

text
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 42

Nested resources express containment, and one level of nesting is usually the limit before URLs become unreadable and authorization rules ambiguous:

text
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 order

Actions 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

MethodIdempotent?Safe?Purpose
GETyesyesRetrieve a resource
HEADyesyesSame as GET, headers only
OPTIONSyesyesDescribe supported methods
POSTnonoCreate a resource or trigger a process
PUTyesnoReplace a resource entirely
PATCHnonoUpdate part of a resource
DELETEyesnoRemove 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

CodeMeaningWhen to use
200OKSuccessful GET, PATCH, or DELETE with a body
201CreatedSuccessful POST; include a Location header
204No ContentSuccessful request with no body to return
400Bad RequestMalformed syntax the client must fix
401UnauthorizedNot authenticated — send credentials or refresh
403ForbiddenAuthenticated, not allowed
404Not FoundResource does not exist (or is hidden from this caller)
409ConflictState conflict: duplicate, version mismatch, concurrent edit
422Unprocessable ContentWell-formed but semantically invalid (validation errors)
429Too Many RequestsRate limited — always send Retry-After
500Internal Server ErrorUnhandled 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
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):

bash
$ curl 'https://api.example.com/v1/orders?limit=50&after=ord_9f3c1a'
json
{
  "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:

http
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 200 with an error in the body and every retry, metric, and alert downstream misclassifies the failure.
  • 401 versus 403 versus 404: 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 a DELETE without idempotency, is a production incident waiting for one scripted client.
  • Cache correctness lives in cache-control, etag, and Vary; 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?

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save