What consistent REST API design is and who it is for
REST API design, in the sense this guide uses it, is less about any single endpoint and more about a small set of conventions applied the same way everywhere. Resources are modelled as nouns (/invoices, /invoices/{id}), not verbs. Methods and status codes follow the semantics defined in RFC 9110, so GET never changes state and 404 always means the target resource doesn't exist. Every failure returns one error body shape. Every list endpoint uses one pagination, filtering and sorting style.
The payoff is predictability. Suppose a client has integrated with three of your endpoints. It should be able to guess how the fourth behaves: what a 409 means, where the error message lives, how to fetch the next page. It shouldn't need to open per-endpoint docs to find out. That cuts integration time, and it lets teams write shared client code once: retry logic, error handling and pagination helpers. Without it, every endpoint needs special cases.
This matters most for backend and platform engineers whose APIs are consumed by several teams or by external clients. Those consumers can't ask you what an endpoint meant, and inconsistencies get copied into their code and become hard to remove later.
It matters less for a single internal endpoint with one consumer that you control. There, the two sides can agree directly, and strict conformance buys little. You can still follow the conventions cheaply, but you shouldn't delay shipping to debate them.
Resource modelling: nouns, shallow nesting and actions that don't fit CRUD
Start with the nouns your clients care about and give each one a plural collection path: /orders for the collection, /orders/{id} for a single order. The collection accepts POST to create and GET to list. The item path is the resource's identity, and every client, log line and cache key should use that same string.
Keep nesting to one level
Nest only when the child cannot exist without the parent and is almost always fetched in its context, as in /orders/{id}/items. Stop there. A path like /customers/{cid}/orders/{oid}/items/{iid} encodes your ownership hierarchy into every client. If orders later move between customers, or items get their own lifecycle, every caller breaks. Clients also have to know three IDs to reach one item, even when the item ID alone is unique. Deep paths also tend to create a second route to the same thing, such as /orders/{oid} alongside the nested form. Two paths to one resource lead to duplicated authorization checks, split caches and arguments about which URL is canonical. Pick one canonical path and use query filters like /orders?customer_id=42 for the other access patterns.
Actions that don't fit CRUD
Many operations are state changes. You can model them that way: PATCH /orders/{id} with status: cancelled, or create a sub-resource such as POST /orders/{id}/refunds, which also leaves a record behind. Some operations are commands with side effects and no natural resource, such as sending an invoice or recalculating totals. Forcing those into CRUD produces fake resources nobody understands. An explicitly named action like POST /invoices/{id}/send is honest and easy to search for. Reserve this pattern for real commands so the URL structure stays mostly made of nouns.
# Collections and items: plural nouns, one canonical path per resource
GET /orders # list orders
POST /orders # create an order
GET /orders/{id} # the one identity used by clients, logs, cache keys
PATCH /orders/{id} # partial update
# One level of nesting: items only exist inside an order
GET /orders/{id}/items
POST /orders/{id}/items
# Other access patterns use filters, not a second route
GET /orders?customer_id=42 # instead of /customers/42/orders
# Avoid: deep nesting ties every client to the ownership hierarchy
# GET /customers/{cid}/orders/{oid}/items/{iid}
# State change modelled as an update to the resource
PATCH /orders/{id} # body: {"status": "cancelled"}
# Operation that leaves a record: create a sub-resource
POST /orders/{id}/refunds # body: {"amount": 1500, "reason": "damaged"}
GET /orders/{id}/refunds # refunds stay queryable afterwards
# Real commands with no natural resource: explicit, searchable action names
POST /invoices/{id}/send # side effect: email the invoice
POST /orders/{id}/recalculate # side effect: recompute totals
A route map with plural collections, one level of nesting, query filters in place of duplicate paths, and the three ways to handle non-CRUD operations: state change, sub-resource, or named action.
How HTTP methods work: safety, idempotency, PUT vs PATCH
RFC 9110 (HTTP Semantics) defines two properties that matter more than the method names. A safe method asks for no state change on the server. An idempotent method has the same intended effect whether it is sent once or several times. The response may differ between attempts; only the effect on server state must not.
| Method | Safe | Idempotent | Typical use |
|---|---|---|---|
| GET, HEAD, OPTIONS | Yes | Yes | Read, inspect |
| PUT | No | Yes | Replace a whole resource |
| DELETE | No | Yes | Remove a resource |
| POST | No | No | Create, run a command |
| PATCH | No | Not guaranteed | Partial update |
These properties determine retry behaviour. When a connection drops before the response arrives, the client does not know whether the server processed the request. HTTP clients, proxies and retry middleware may resend an idempotent request automatically. Most will not resend POST. If your PUT handler appends a row instead of replacing state, an automatic retry will produce duplicates, and the bug will look random.
PUT replaces the full representation. Any field the client omits is cleared or reset to its default. PATCH changes only the fields that are named. Pick one patch format and document it. JSON Merge Patch (RFC 7396) is simple but cannot set a field to null as a value. JSON Patch (RFC 6902) is an explicit list of operations and can express array edits. Never accept "whatever JSON looks right."
Some intermediaries and older client libraries handle PATCH poorly. They may strip it, reject it, or lack a convenient helper for it. Test against the proxies, SDKs and runtimes your clients actually use before you rely on it.
# Current state of /users/42:
# {"name": "Ada", "email": "ada@example.com", "phone": "555-0100"}
# PUT replaces the whole representation. phone is omitted, so it is cleared.
PUT /users/42 HTTP/1.1
Content-Type: application/json
{"name": "Ada", "email": "ada@new.example.com"}
# JSON Merge Patch (RFC 7396): change only the named fields.
# null means "remove this member", so you cannot store a literal null.
PATCH /users/42 HTTP/1.1
Content-Type: application/merge-patch+json
{"email": "ada@new.example.com", "phone": null}
# JSON Patch (RFC 6902): an ordered list of explicit operations.
# "test" makes the patch fail if the current value is not what the client expects.
PATCH /users/42 HTTP/1.1
Content-Type: application/json-patch+json
[
{"op": "test", "path": "/email", "value": "ada@example.com"},
{"op": "replace", "path": "/email", "value": "ada@new.example.com"},
{"op": "remove", "path": "/phone"}
]
# Sending the same PUT twice leaves the same state, so a retry is harmless.
# A PATCH like {"op": "add", "path": "/tags/-", "value": "vip"} appends
# on every attempt, so it is not idempotent and should not be retried blindly.
The same change to a user resource expressed as a full PUT replacement, a JSON Merge Patch and a JSON Patch, with notes on which requests are safe to retry.
Status codes by intent and one error body shape
Pick the success code from what the client needs to do next. 200 OK returns a representation the client can use immediately. 201 Created signals that a new resource exists, and the Location header gives its URI so the client doesn't have to construct it. 204 No Content confirms the action and tells the client not to expect a body to parse, which suits DELETE and updates that return nothing.
For failures, each 4xx code should point the client to a different fix:
| Code | Meaning | Client's next step |
|---|---|---|
| 400 | Request is malformed (unparseable body, wrong types) | Fix the request |
| 401 | No credentials, or invalid ones | Authenticate, then retry |
| 403 | Authenticated but not allowed | Request access; retrying won't help |
| 404 | Resource doesn't exist, or is hidden | Check the identifier |
| 409 | Conflicts with current state | Re-read state, then reconcile |
| 422 | Well-formed but fails business rules | Correct the field values |
| 429 | Rate limit exceeded | Back off, honouring Retry-After if sent |
For resources the caller can't access, returning 404 instead of 403 avoids confirming that the resource exists. The cost is harder debugging for legitimate users with a permissions gap. Choosing between 422 and 400 for validation failures has no clear right answer, so pick one and apply it everywhere.
Every error should share one body shape:
code: a stable, machine-readable string such asinsufficient_balance. Clients branch on this, not on message text.message: human-readable text you can reword freely.details: a list of field-level errors.
RFC 9457 (Problem Details for HTTP APIs) standardises this shape and allows extension members, so you can add your code without designing a format from scratch. HTTP status semantics are defined in RFC 9110.
// One error shape for every 4xx/5xx, based on RFC 9457 problem details.
// `code` and `errors` are extension members; RFC 9457 allows them.
interface FieldError {
field: string; // e.g. "amount"
code: string; // stable, machine-readable
message: string; // human-readable, free to reword
}
interface Problem {
type: string; // URI identifying the problem type
title: string; // short summary of the problem type
status: number; // mirrors the HTTP status code
detail?: string; // explanation for this occurrence
code: string; // clients branch on this, never on detail text
errors?: FieldError[];
}
function problemResponse(p: Problem): Response {
return new Response(JSON.stringify(p), {
status: p.status,
// RFC 9457 media type, so clients can recognise the format
headers: { "Content-Type": "application/problem+json" },
});
}
// Well-formed request that fails a business rule: 422 (or 400, if that
// is the convention you chose; use the same code everywhere).
const res = problemResponse({
type: "",
title: "Insufficient balance",
status: 422,
detail: "Transfer of 150.00 exceeds available balance of 120.00.",
code: "insufficient_balance",
errors: [
{ field: "amount", code: "exceeds_balance", message: "Must be at most 120.00." },
],
});
A helper that builds a single RFC 9457 problem-details error response, adding a stable code and field-level errors as extension members, shown for a 422 business-rule failure.
Collections, concurrency and safe retries
Pick one pagination style and use the same limit, cursor, filter and sort parameters on every list endpoint, so clients can share one paging helper.
| Cursor | Offset | |
|---|---|---|
| Stable when rows change | Yes | No: skips or duplicates items |
| Deep pages | Cheap with an indexed seek | Gets slower as the offset grows |
| Jump to page N | No | Yes |
| Total count | Costly, often omitted | Commonly provided |
Cursor pagination suits feeds and syncs. Offset pagination suits small admin tables where users jump between pages.
Concurrency and caching
Return an ETag on reads. Writers send If-Match with that value. If the resource changed in the meantime, respond 412 Precondition Failed rather than silently overwriting another client's edit. For caching, clients send If-None-Match, and the server returns 304 Not Modified without a body when the ETag still matches. RFC 9110 defines both preconditions.
Idempotency keys for POST
POST is not idempotent. Have clients generate a unique key per logical operation and send it in a header. The server handles it in four steps:
- Look up the key. If it is missing, store it atomically as in progress, scoped to the caller.
- Process the request. Save the status code and body against the key, with an expiry such as 24 hours.
- Replay on repeat. If the key already holds a completed response, return the stored response.
- Reject conflicts. If the key is still in progress, or arrives with a different payload, return
409.
A partial version is worse than having none. For example, if the lookup and insert are not atomic, two concurrent requests can both miss the key and both charge the card. Clients will still believe retries are safe.
-- PostgreSQL. One row per (caller, key); the primary key makes the claim atomic.
CREATE TABLE idempotency_keys (
caller_id text NOT NULL,
idem_key text NOT NULL,
request_hash text NOT NULL, -- hash of method, path and body
state text NOT NULL CHECK (state IN ('in_progress', 'completed')),
response_code int,
response_body jsonb,
expires_at timestamptz NOT NULL,
PRIMARY KEY (caller_id, idem_key)
);
-- Step 1: claim the key in one statement. Concurrent duplicates block on the
-- same primary key, and only one of them gets a row back. An expired row is reclaimed.
INSERT INTO idempotency_keys (caller_id, idem_key, request_hash, state, expires_at)
VALUES ('acct_123', '7c1e9a52-4b0d-4f8e-9a63-2d5f0b8e1c47', 'sha256:3fa9...', 'in_progress',
now() + interval '24 hours')
ON CONFLICT (caller_id, idem_key) DO UPDATE
SET request_hash = EXCLUDED.request_hash, state = 'in_progress',
response_code = NULL, response_body = NULL, expires_at = EXCLUDED.expires_at
WHERE idempotency_keys.expires_at <= now()
RETURNING idem_key;
-- A row came back: this request owns the key, so run the side effect.
-- No row came back: read the existing row instead.
-- Steps 3 and 4: completed with the same hash means replay the stored response.
-- in_progress, or a different request_hash, means respond 409.
SELECT state, request_hash, response_code, response_body
FROM idempotency_keys
WHERE caller_id = 'acct_123' AND idem_key = '7c1e9a52-4b0d-4f8e-9a63-2d5f0b8e1c47';
-- Step 2: once processing finishes, store the response for later replays.
UPDATE idempotency_keys
SET state = 'completed', response_code = 201,
response_body = '{"id": "pay_9f3k2", "status": "succeeded"}'
WHERE caller_id = 'acct_123' AND idem_key = '7c1e9a52-4b0d-4f8e-9a63-2d5f0b8e1c47';
-- Periodic cleanup of expired keys.
DELETE FROM idempotency_keys WHERE expires_at <= now();
A PostgreSQL idempotency key store that claims each key with one atomic INSERT ... ON CONFLICT, so concurrent retries cannot both run the side effect, and that replays completed responses, rejects in-progress or mismatched keys with 409, and reclaims keys after 24 hours.
Enforcing consistency mechanically, and when to avoid strictness
Conventions that live only in reviewers' heads drift as teams and reviewers change. Consistency at scale needs three layers, each catching what the previous one misses.
Write it down, then make it checkable
Start with a short style guide covering naming, pagination parameters, the error body shape, and which status code applies to which situation. Keep it short enough that people actually read it.
Next, keep an API description file, such as an OpenAPI document, in the same repository as the handlers. Review changes to it in the same pull request as the code. A reviewer then sees the contract change in the diff, rather than inferring it from implementation details.
Finally, lint that file in CI. Rules like "every 4xx response references the shared error schema" or "collection endpoints accept the standard cursor parameter" are mechanical checks. A failing build is more reliable than a reviewer remembering the rule on a busy afternoon.
When strictness costs more than it saves
On a live API, a "wrong" status code or error shape is still part of the contract. Clients branch on 404 versus 410, or parse an error_code field. Correcting it silently breaks them. Ship the fix behind a new version, or announce a deprecation period during which both behaviours are available.
Some exceptions are legitimate. These include command-style endpoints that don't map to resources, and legacy clients you can't upgrade. Record each one in the style guide with its reason, and add a matching lint suppression. A documented exception is a decision. An undocumented one becomes a precedent that others will copy.
Alternatives and impact on cost and engineering time
Strict REST conventions are not the only option. They suit APIs where clients are many and unknown, so behaviour has to be readable from the HTTP verb and status code alone. When you control every client, a different style may cost less.
| Style | Fits best | Errors | Retries | Caching |
|---|---|---|---|---|
| Strict REST | Public or multi-team APIs | HTTP status code plus one error body | Method semantics tell you what is safe; POST needs an idempotency key | HTTP caching works on GET |
| RPC-style HTTP | Internal command-heavy services | Mostly in the body, often with a 200 | Each operation needs its own rule | Rarely usable |
| GraphQL | Clients that pick fields from a graph | Usually an errors array returned with a 200 |
Queries are safe to retry; mutations need keys | Weak for POST queries; mostly done on the client |
| gRPC | Service-to-service traffic with generated stubs | Its own status codes | Retry policy set per method | None at the HTTP level |
The cost of strict REST comes up front. You write the style guide, set up linting and build idempotency-key storage. You get that time back on every endpoint you add afterwards: less custom documentation per endpoint, shared client code for pagination and retries, shorter reviews because reviewers only check exceptions, and fewer support questions about what a given status code means.
Illustrative estimate (assumed numbers, not a benchmark)
Break-even endpoints = upfront hours ÷ hours saved per endpoint
- Upfront: style guide 40 h + linting 16 h + idempotency storage 24 h = 80 h
- Saved per endpoint: docs 1 h + review 0.5 h + support 0.5 h = 2 h
- Break-even: 80 ÷ 2 = 40 endpoints
If your API will stay below that number and you own every client, a looser style is probably the cheaper choice.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to rest api design, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.