Guide
REST API Design with HTTP Semantics
Choose HTTP methods and status codes for a REST API: 201, 202, 204, 400 vs 422, 409, ETag with If-Match, pagination, problem+json and idempotency keys.
On this page
TL;DR: Let the method carry the intent (
GETreads,POSTcreates,PUTreplaces,PATCHedits,DELETEremoves), let the status code carry the outcome (201+Location,202+ status URL,204,409,412), put the machine-readable detail in an RFC 9457application/problem+jsonbody, and useETag+If-Matchto stop lost updates. Each section below shows the actual exchange.
Choosing the method
The decision depends on two properties from RFC 9110 section 9.2: whether the method is safe (read-only) and whether it is idempotent (repeating it has the same effect as sending it once). Retries, proxies and caches rely on both.
| You want to | Method | Safe | Idempotent | Typical success |
|---|---|---|---|---|
| Read a resource or collection | GET | Yes | Yes | 200 |
| Create, server picks the ID | POST to the collection | No | No | 201 |
| Create or replace at a client-chosen URI | PUT | No | Yes | 201 or 200/204 |
| Change some fields | PATCH | No | No (not guaranteed) | 200 or 204 |
| Remove | DELETE | No | Yes | 204 |
| Trigger a non-CRUD action | POST to an action sub-resource | No | No | 200, 202 or 201 |
Two traps. First, PATCH is not defined as idempotent: {"op":"add","path":"/tags/-","value":"x"} appends a tag every time it runs, while a JSON Merge Patch (RFC 7396) of {"name":"x"} happens to be repeatable. Document which patch format you accept, and send Content-Type: application/merge-patch+json or application/json-patch+json accordingly. Second, DELETE is idempotent in effect, not in response: the first call returns 204 and the second may return 404, and that is fine.
Creating: 201 and Location
POST /v1/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"sku":"A-100","quantity":2}
HTTP/1.1 201 Created
Location: https://api.example.com/v1/orders/ord_8f3k
Content-Type: application/json
ETag: "1"
{"id":"ord_8f3k","sku":"A-100","quantity":2,"status":"pending"}
RFC 9110 section 15.3.2: a 201 means the request created one or more resources, the primary one is identified by Location, and if there is no Location it is the request URI. Return the representation so clients do not need a follow-up GET. See 201 Created.
With PUT to a client-chosen URI, return 201 when it was created and 200 or 204 when you replaced an existing resource; the same request can legitimately produce either.
Async work: 202 and a status resource
POST /v1/exports HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"format":"csv","range":"2026-Q3"}
HTTP/1.1 202 Accepted
Location: https://api.example.com/v1/exports/exp_91
Retry-After: 5
Content-Type: application/json
{"id":"exp_91","status":"queued"}
Polling the status URL:
GET /v1/exports/exp_91 HTTP/1.1
Host: api.example.com
HTTP/1.1 303 See Other
Location: https://api.example.com/v1/exports/exp_91/result.csv
While the job runs, the status resource answers 200 with {"status":"running"}. When it finishes you can either keep answering 200 with a link to the result, or redirect with 303 See Other as above, which tells the client to GET the result. A failed job is still a successful poll (200 with "status":"failed" and a problem object), not a 500. 202 is deliberately non-committal (RFC 9110 section 15.3.3): the request was accepted, and processing may still fail. See 202 Accepted.
No body: 204
Use 204 No Content when the action succeeded and there is nothing to send, typically DELETE and some PUT/PATCH calls. It must not contain a body, so do not send 204 with {}.
DELETE /v1/orders/ord_8f3k HTTP/1.1
Host: api.example.com
If-Match: "3"
HTTP/1.1 204 No Content
See 204 No Content.
Errors: 400, 422, 409, 412, 428
Pick the status by who can fix the problem and how:
400 Bad Request: the server could not understand the request. Invalid JSON, a missing required header, a query parameter of the wrong type.422 Unprocessable Content: syntactically valid, semantically rejected. Field-level validation lives here in many APIs. The older reason phrase was “Unprocessable Entity”; RFC 9110 renamed it.409 Conflict: the request is fine but conflicts with the current state of the resource: a duplicate unique key, transitioning an order that is already shipped, editing something another user just deleted. The client can fix it after re-reading state.412 Precondition Failed: a conditional header (If-Match,If-Unmodified-Since) evaluated false. Use it for stale-version writes instead of409, because the client asked for that check explicitly.428 Precondition Required(RFC 6585 section 3): the server requires the request to be conditional and it was not.
The split between 400 and 422 is a convention call, not a rule of nature. RFC 9110 defines both, and what matters is that your clients can predict which one a validation failure produces. Pick one rule, put it in your API docs, and keep it.
Optimistic concurrency with ETag and If-Match
Two clients read the same order, both edit it, both PUT it. Without a precondition the second write silently discards the first. An ETag identifies a version of the representation (RFC 9110 section 8.8.3), and If-Match (section 13.1.1) makes the write conditional on that version still being current.
GET /v1/orders/ord_8f3k HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
ETag: "3"
Content-Type: application/json
{"id":"ord_8f3k","quantity":2,"status":"pending"}
PUT /v1/orders/ord_8f3k HTTP/1.1
Host: api.example.com
If-Match: "3"
Content-Type: application/json
{"id":"ord_8f3k","quantity":5,"status":"pending"}
If another writer already moved the order to version 4:
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{"type":"https://api.example.com/problems/stale-version","title":"Resource has changed","status":412,"detail":"Re-fetch the order and reapply your change."}
To make the check mandatory, reject a write that has no If-Match:
HTTP/1.1 428 Precondition Required
Content-Type: application/problem+json
{"type":"about:blank","title":"Precondition Required","status":428,"detail":"Send If-Match with the ETag from your last GET."}
If-Match uses strong comparison, so use strong ETags (no W/ prefix) for it. A version counter or a content hash both work; a row’s updated_at timestamp at one-second resolution does not, because two edits within the same second get the same tag. See ETag and If-Match. The read side of the same header, If-None-Match on a GET returning 304 Not Modified, is covered in HTTP headers and caching.
Pagination: Link headers and cursors
Offset pagination (?page=3&per_page=50) is simple and breaks when rows are inserted or deleted between requests: items shift, so a client sees duplicates or skips. Cursor pagination (?after=<opaque>) anchors on a position in a stable sort order and does not drift. Use a cursor for any collection that changes while people page through it, and treat the cursor as an opaque string that clients must not construct.
Put navigation in a Link header (RFC 8288), so the client follows relations instead of building URLs:
GET /v1/orders?limit=2 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.example.com/v1/orders?limit=2&after=Y3Vyc29yOjI>; rel="next"
[{"id":"ord_1"},{"id":"ord_2"}]
The last page simply omits rel="next". Many APIs also return the links in the JSON body because browser-side code finds that easier; if you must pick one, the header keeps the body a plain array. See Link.
Errors as problem details (RFC 9457)
Error bodies need to be machine-readable. RFC 9457 (July 2023, obsoletes RFC 7807) defines the application/problem+json media type with five standard members:
type: a URI identifying the problem type. Defaults toabout:blank, meaning “no meaning beyond the HTTP status”.title: a short human-readable summary that stays the same for every occurrence of the type.status: the HTTP status code. Advisory: the real response status wins if they disagree.detail: an explanation specific to this occurrence, aimed at helping the client fix it.instance: a URI identifying this particular occurrence, for example a request or log ID.
Extension members are allowed, and clients must ignore ones they do not recognise. That is where field errors go:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://api.example.com/problems/validation-error",
"title": "Your request is not valid.",
"status": 422,
"detail": "2 fields failed validation.",
"instance": "/v1/orders/requests/7b1c",
"errors": [
{ "pointer": "/quantity", "detail": "must be at least 1" },
{ "pointer": "/sku", "detail": "unknown SKU" }
]
}
errors and pointer are this example’s own extensions, not part of the RFC. Keep detail free of stack traces and internal identifiers; the RFC explicitly warns about leaking implementation details through problem types.
Idempotency for POST
A client sends POST /v1/payments, the connection drops, and it cannot tell whether the payment happened. Retrying risks a double charge. The fix is a client-generated key that lets the server recognise a repeat:
POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 3c1f7e2a-5b64-4a5e-9d0e-2f2b7d9b1a10
Content-Type: application/json
{"amount":4200,"currency":"EUR"}
The server stores the key with the outcome of the first request. A retry with the same key and same body replays the stored response; a retry while the first is still in flight, or with a different body, is an error.
Idempotency-Key is specified in draft-ietf-httpapi-idempotency-key-header, an IETF Internet-Draft. Its latest revision (07, October 2025) has expired, so this is a convention, not a standard. The draft proposes 400 for a missing key, 409 for a concurrent request with the same key and 422 for a reused key with a different payload:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{"type":"about:blank","title":"Idempotency key reused","status":422,"detail":"This key was first used with a different request body."}
Providers differ here, so document your own behaviour and key retention window. Do this for POST creates and payment-like actions; PUT and DELETE are already idempotent by definition.
Versioning
- Path (
/v2/orders). Obvious in logs, easy to route and cache, easy to try in a browser. Resource URIs change, which breaks stored links. - Header or media type (
Accept: application/vnd.example.v2+json). URIs stay stable. Caches needVary: Acceptor the version header or they serve one version to everyone, and it is harder to test with a plain link. - Query parameter (
?version=2). Easy, but it fragments cache keys and is easy to forget.
Whichever you choose, the cheapest strategy is not needing a new version: add optional fields, never repurpose or remove them, and have clients ignore unknown fields. Reserve a new version for breaking changes, and announce retirement with a Deprecation or Sunset header plus documentation.
Quick decision list
- Creating something:
POSTto the collection,201,Location, body. - Replacing at a known URI:
PUT,200or204, addIf-Match. - Long-running:
202,Locationto a status resource, optionalRetry-After. - Nothing to return:
204, no body. - Bad syntax:
400. Bad meaning:422. Bad state:409. Stale version:412. Missing precondition:428. - Over quota:
429withRetry-After; see API rate limiting.
Frequently asked questions
Should validation errors return 400 or 422?
Both are defensible; pick one and document it. 400 Bad Request fits a request the server cannot parse (invalid JSON, wrong types for the format). 422 Unprocessable Content (RFC 9110 section 15.5.21) fits a request that parsed fine but violates business or schema rules, such as an email field that is not an email. Many APIs use 422 for field validation and reserve 400 for malformed syntax.
What status code should a successful POST that creates a resource return?
201 Created, with a Location header holding the URI of the new resource and, usually, a representation of it in the body. RFC 9110 section 15.3.2 says the origin server should send Location, and if it does not, the effective request URI is the new resource.
When should an API return 202 Accepted?
When the work will not finish within the request, such as a video transcode or bulk import. Return 202 with a Location pointing at a status resource the client can poll. The status resource then reports pending, running, succeeded or failed, and links to the result. 202 promises nothing about the final outcome.
How do I prevent lost updates in a REST API?
Return an ETag on GET, require the client to send it back as If-Match on PUT, PATCH or DELETE, and answer 412 Precondition Failed when it does not match the current version. To force clients to do this, answer 428 Precondition Required when the header is missing.
Is Idempotency-Key an official standard?
No. It is described in draft-ietf-httpapi-idempotency-key-header, an IETF Internet-Draft whose latest revision (07, October 2025) has expired, so treat it as a convention. Stripe and others implemented the same idea earlier, and the header name is widely recognised, but exact error behaviour for reused keys differs between providers.
Should I use URL versioning or header versioning?
URL versioning (/v2/orders) is the easiest to route, cache, log and debug, at the cost of changing resource URIs. Header or media-type versioning keeps URIs stable but requires Vary on the header for caches and is harder to test from a browser. Whichever you choose, prefer additive, backwards-compatible changes so you version rarely.
Sources
- RFC 9110: HTTP Semanticsrfc-editor.org
- RFC 9457: Problem Details for HTTP APIsrfc-editor.org
- RFC 8288: Web Linkingrfc-editor.org
- RFC 6585: Additional HTTP Status Codes (428)rfc-editor.org
- IETF draft: The Idempotency-Key HTTP Header Fielddatatracker.ietf.org
- MDN: HTTP request methodsdeveloper.mozilla.org
- MDN: HTTP response status codesdeveloper.mozilla.org
Related
ETag Header: Strong vs Weak, If-None-Match and 304 Debugging
ETag identifies a resource version for cache validation and optimistic locking. Strong vs weak ETags, If-None-Match, If-Match, and why nginx gzip weakens them.
HTTP POST Method: Complete Guide with Examples
Learn how the HTTP POST method works. Understand when to use POST requests, request bodies, form submissions, and API calls with practical examples.
If-Match Header
Learn how the If-Match header makes requests conditional based on ETag matching. Prevent conflicts and lost updates in concurrent editing scenarios.
HTTP PUT Method: Update Resources
Learn how the HTTP PUT method works, when to use PUT vs POST vs PATCH, and best practices for updating resources in REST APIs.