REST API Design: Decisions You Cannot Change Later
Resource naming, status codes, pagination, versioning and error shapes — with attention to the choices that become breaking changes if you get them wrong.
Table of contents
- Resource naming
- Status codes that carry information
- Error shape: pick one and never change it
- Pagination: cursors, not offsets
- Versioning: choose before you launch
- Make writes idempotent
- Frequently asked questions
- PATCH or PUT for updates?
- Should I use REST or GraphQL?
- How do I handle bulk operations?
- Where should filtering go?
- Related reading
- References
Most API design advice is cosmetic. These are the decisions that are expensive to reverse.
Resource naming#
Nouns, plural, lowercase, hyphenated:
GET /users
GET /users/123
POST /users
PATCH /users/123
DELETE /users/123
GET /users/123/ordersNot /getUser, /user, or /users/123/getOrders. The HTTP verb is the action; putting it in the path duplicates it.
Nest at most one level. /users/123/orders/456/items/789 is unusable — once you have the order id, /orders/456/items is enough.
Status codes that carry information#
200 OK successful GET, PATCH, or DELETE with a body
201 Created successful POST — include a Location header
204 No Content success with nothing to return
400 Bad Request malformed syntax or failed validation
401 Unauthorized not authenticated (badly named; means "unauthenticated")
403 Forbidden authenticated but not permitted
404 Not Found no such resource
409 Conflict violates current state (duplicate email, version mismatch)
422 Unprocessable Entity syntactically valid, semantically wrong
429 Too Many Requests rate limited — include Retry-After
500 Internal Server Error your fault
503 Service Unavailable temporarily down; include Retry-AfterThe 401/403 distinction genuinely matters to clients: 401 means "log in", 403 means "logging in again will not help".
Returning 200 with {"error": "..."} in the body is the pattern to avoid. It breaks every HTTP-aware layer — caches, retry logic, monitoring — because they all read the status.
Error shape: pick one and never change it#
{
"error": {
"code": "validation_failed",
"message": "The request could not be processed.",
"details": [
{
"field": "email",
"code": "invalid_format",
"message": "Enter a valid email address."
}
],
"requestId": "req_01H..."
}
}Three properties that make this useful:
- A stable machine-readable
code. Clients branch on codes, not on message text — messages get reworded. - Per-field
details. Lets a form show errors inline instead of one banner. - A
requestIdthe user can quote and you can grep in logs. This single field saves more support time than anything else in an API.
Pagination: cursors, not offsets#
GET /posts?limit=20&cursor=eyJpZCI6MTAwfQ{
"data": [/* ... */],
"pagination": { "nextCursor": "eyJpZCI6ODB9", "hasMore": true }
}Offset pagination has two flaws that only appear in production: it gets slower as the offset grows (the database produces and discards every skipped row), and it skips or duplicates items when the underlying data changes between pages — which for an activity feed is constant.
Cursor pagination fixes both. Do not return a total count by default; COUNT(*) over a large filtered set is often the slowest part of the request.
Versioning: choose before you launch#
/v1/users in the path is the pragmatic default: visible in logs, easy to route, obvious in a URL someone pastes into a bug report. Header-based versioning is theoretically cleaner and harder to debug.
Whatever you choose, version from day one. Adding /v1 after clients exist is itself a breaking change.
What does not require a new version: adding a field, adding an endpoint, adding an optional parameter. Clients must tolerate unknown fields — say so in your documentation.
Make writes idempotent#
POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000A network timeout leaves the client not knowing whether the request succeeded. Without an idempotency key, retrying may charge twice. Store the key with its response and return the stored result on a repeat. This is not optional for anything involving money.
PUT and DELETE are idempotent by definition; POST is not, which is why the header exists.
Frequently asked questions#
PATCH or PUT for updates?#
PATCH for partial updates, which is what clients almost always want. PUT replaces the entire resource, so omitting a field means "clear it" — rarely the intent.
Should I use REST or GraphQL?#
REST for a public API with cacheable resources and many unknown consumers. GraphQL when clients need to compose wildly different shapes from the same graph and you control them. Neither is a default.
How do I handle bulk operations?#
A dedicated endpoint (POST /users/bulk) returning per-item results with 207 Multi-Status, so a partial failure is reportable. Do not overload the single-resource endpoint.
Where should filtering go?#
Query parameters: GET /posts?status=published&author=123&sort=-createdAt. Keep the syntax consistent across every collection endpoint.
Related reading#
References#
- RFC 9110: HTTP Semantics
- Stripe API reference — the most-copied design for good reason