There is no shortage of REST style guides, and most of them agree on the easy parts. What they rarely do is explain why a given choice is the pragmatic one, or show what it costs when you get it wrong. This post goes through the six decisions that come up in every HTTP API I design, picks a default for each, and tries to justify it with something stronger than taste: an RFC, a measurement, or a failure mode.
Resources and naming
A resource is a noun the client can point at. Verbs belong in the HTTP method, not in the path.
| Instead of | Use | Why |
|---|---|---|
POST /createOrder | POST /orders | the method already says "create" |
GET /orders/getById?id=42 | GET /orders/42 | identity in the path is cacheable and linkable |
GET /customers/7/orders/42/items/3/product | GET /products/19 | nest at most one level; deep paths repeat what the client knows |
GET /order | GET /orders | collections are plural, consistently |
POST /orders/42/cancel | POST /orders/42/cancellations or PATCH /orders/42 | a state change is either a sub-resource or an update of the parent |
Plural nouns, lower-case, hyphens if you must. The one exception I allow is an action that is not a state of anything, like POST /orders/42/receipt-email, and even then I try to model it as creating a resource (an "email request") that has its own status.
Versioning
Put the major version in the path: /v1/orders. It is visible in logs, trivial to route, and impossible to forget in a curl command. Header-based versioning is more "pure" and almost nobody uses it correctly from a shell.
More important than where the version lives is how rarely it changes. Treat these as non-breaking and ship them inside v1:
- adding a field to a response,
- adding an optional request parameter,
- adding a new endpoint or a new enum value when the documentation told clients to tolerate unknown values.
Everything else (renaming, removing, changing a type, changing the meaning of a status code) is a v2, and v2 should be rare enough that you can afford to run v1 alongside it for a year with a Deprecation header on every response.
Pagination: cursor, not offset
?offset=10000&limit=50 is the pagination everyone knows and the one I avoid for anything that grows. Two reasons, one about correctness and one about cost.
Correctness: if a row is inserted (or deleted) while the client is paging, every later page shifts by one. The client sees a duplicate or misses a row and has no way to detect it.
Cost: the database has to produce and discard 10,000 rows to return page 201. I measured this on a 2,000,000-row SQLite table with an index on (created_at, id), fetching 50 rows at increasing depths, best of 20 runs each:
Keyset (cursor) pagination replaces "skip n rows" with "seek to the last row I saw":
-- offset: walks and discards 10,000 index entries
SELECT id, created_at, customer, amount
FROM orders
ORDER BY created_at, id
LIMIT 50 OFFSET 10000;
-- keyset: one B-tree seek, then 50 reads
SELECT id, created_at, customer, amount
FROM orders
WHERE (created_at, id) > ($1, $2) -- values from the last row of the previous page
ORDER BY created_at, id
LIMIT 50;
The API encodes (created_at, id) of the last returned row as an opaque string, so the client never sees or constructs it:
{
"data": [{ "id": 420, "created_at": "2025-05-27T03:00:00Z", "amount": "12.50" }],
"next_cursor": "eyJjIjoiMjAyNS0wNS0yN1QwMzowMDowMFoiLCJpZCI6NDIwfQ",
"has_more": true
}
The sort key must be unique and stable, which is why id is included as a tiebreaker. You lose "jump to page 37"; in my experience nobody who paged past page 3 wanted page 37 anyway.
Idempotency keys
Networks fail after the server commits and before the client reads the response. Without help, the client's retry creates a second order. The fix is a client-generated Idempotency-Key header on every unsafe request, and a server that stores the first outcome keyed by it.
| Server sees | Response |
|---|---|
| new key | process normally, store (key, request fingerprint, response) |
| same key, same fingerprint, finished | replay the stored response, same status and body |
| same key, same fingerprint, still in flight | 409 Conflict with Retry-After |
| same key, different fingerprint | 422 Unprocessable Content: the client reused a key by mistake |
The storage is a small table with a TTL of about 24 hours; the fingerprint is a hash of method, path and body so that a reused key with a different payload is caught instead of silently replayed. The IETF HTTP API working group has a draft that standardises exactly this header and these semantics, linked below.
TIP
Generate the key on the client at the moment the intent is created (the user pressed "Pay"), not at the moment the request is sent. Retries then share the key automatically, and a second click produces a second key, which is what you want.
Errors: problem details, not ad-hoc JSON
Every API eventually grows three incompatible error shapes. RFC 9457 ends that with one media type, application/problem+json, and five well-defined members, plus room for your own:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "The account balance is 12.50 but the order total is 40.00.",
"instance": "/v1/orders/req_01HZX3",
"balance": "12.50",
"required": "40.00"
}
type is a stable identifier clients can switch on; detail is for humans and may change wording freely. The status code still carries the coarse meaning: use 400 for malformed syntax, 422 for well-formed but unprocessable input, 404 for a missing resource, 409 for a state conflict, and keep 500 for things that are genuinely your fault.
Rate limiting
Return 429 Too Many Requests with a Retry-After header, and tell clients where they stand before they hit the limit. The IETF draft for standard RateLimit headers (also linked below) is the direction things are going; the X-RateLimit-* family is what most clients already parse, so I send both while the draft settles:
HTTP/1.1 200 OK
RateLimit-Policy: 100;w=60
RateLimit: 100;r=37;t=22
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 37
X-RateLimit-Reset: 22
Rate limits belong on the gateway or in middleware, keyed by API key (not IP), and every limited response should still be a problem-details document so clients only need one error parser.
Putting it in OpenAPI
Everything above is expressible in OpenAPI 3.1, which means it can be linted in CI and turned into typed clients. The excerpt below covers the create-order operation with the idempotency header, the problem response, and the cursor-paginated list:
openapi: 3.1.0
info: { title: Orders API, version: 1.0.0 }
paths:
/v1/orders:
get:
summary: List orders, newest last
parameters:
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
- { name: cursor, in: query, schema: { type: string }, description: Opaque; take it from next_cursor }
responses:
"200":
content:
application/json:
schema:
type: object
required: [data, has_more]
properties:
data: { type: array, items: { $ref: "#/components/schemas/Order" } }
next_cursor: { type: [string, "null"] }
has_more: { type: boolean }
post:
summary: Create an order
parameters:
- name: Idempotency-Key
in: header
required: true
schema: { type: string, maxLength: 255 }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NewOrder" }
responses:
"201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Order" } } } }
"409": { $ref: "#/components/responses/Problem" }
"422": { $ref: "#/components/responses/Problem" }
"429": { $ref: "#/components/responses/Problem" }
components:
responses:
Problem:
description: RFC 9457 problem details
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
schemas:
Problem:
type: object
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
The same request, end to end
The tabs below show one create-order call under the three outcomes the idempotency table describes.
-
POST /v1/orders HTTP/1.1 Host: api.example.com Content-Type: application/json Idempotency-Key: 6f1c2a1e-6e04-4a6a-9a1c-3e2b1d6c9f10 { "customer_id": 7, "items": [{ "sku": "A-100", "qty": 2 }] }HTTP/1.1 201 Created Content-Type: application/json Location: /v1/orders/420 RateLimit: 100;r=99;t=60 { "id": 420, "status": "pending", "total": "40.00", "created_at": "2025-05-27T03:00:00Z" } -
The client never saw the
201, so it sends the identical request with the same key. The server finds the stored outcome and replays it byte for byte; no second order exists.HTTP/1.1 201 Created Content-Type: application/json Location: /v1/orders/420 Idempotent-Replayed: true { "id": 420, "status": "pending", "total": "40.00", "created_at": "2025-05-27T03:00:00Z" } -
HTTP/1.1 422 Unprocessable Content Content-Type: application/problem+json { "type": "https://api.example.com/problems/idempotency-key-reused", "title": "Idempotency key reused with a different request", "status": 422, "detail": "Key 6f1c2a1e-... was first used with a different request body. Use a new key for a new request.", "instance": "/v1/orders" }
Why 422 and not 400 for a reused key
400 Bad Request means the server could not understand the request: malformed JSON, an invalid header, a body that does not parse. A reused idempotency key with a different body is perfectly well-formed; the server understood it completely and refuses it on semantic grounds. That is what 422 Unprocessable Content is for. Keeping this distinction lets clients treat 400 as "fix the code" and 422 as "fix the data", which is a useful split in practice.
Further reading
- RFC 9457, Problem Details for HTTP APIs: the error format above, including how to register your own
typeURIs. - RFC 9110, HTTP Semantics: the definitive word on what each method and status code means.
- The Idempotency-Key HTTP Header Field, IETF HTTP API working group draft.
- RateLimit header fields for HTTP, the companion draft for the
RateLimitandRateLimit-Policyheaders. - OpenAPI Specification 3.1.0, which the YAML excerpt follows.
- MDN's HTTP response status codes reference, for the quick lookups.