How HTTP Works

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.

Reviewed 8 min readintermediate7 sourcesTry itMarkdown
On this page

TL;DR: Let the method carry the intent (GET reads, POST creates, PUT replaces, PATCH edits, DELETE removes), let the status code carry the outcome (201 + Location, 202 + status URL, 204, 409, 412), put the machine-readable detail in an RFC 9457 application/problem+json body, and use ETag + If-Match to 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 toMethodSafeIdempotentTypical success
Read a resource or collectionGETYesYes200
Create, server picks the IDPOST to the collectionNoNo201
Create or replace at a client-chosen URIPUTNoYes201 or 200/204
Change some fieldsPATCHNoNo (not guaranteed)200 or 204
RemoveDELETENoYes204
Trigger a non-CRUD actionPOST to an action sub-resourceNoNo200, 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 of 409, 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.

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 to about: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 need Vary: Accept or 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: POST to the collection, 201, Location, body.
  • Replacing at a known URI: PUT, 200 or 204, add If-Match.
  • Long-running: 202, Location to a status resource, optional Retry-After.
  • Nothing to return: 204, no body.
  • Bad syntax: 400. Bad meaning: 422. Bad state: 409. Stale version: 412. Missing precondition: 428.
  • Over quota: 429 with Retry-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

  1. RFC 9110: HTTP Semanticsrfc-editor.org
  2. RFC 9457: Problem Details for HTTP APIsrfc-editor.org
  3. RFC 8288: Web Linkingrfc-editor.org
  4. RFC 6585: Additional HTTP Status Codes (428)rfc-editor.org
  5. IETF draft: The Idempotency-Key HTTP Header Fielddatatracker.ietf.org
  6. MDN: HTTP request methodsdeveloper.mozilla.org
  7. MDN: HTTP response status codesdeveloper.mozilla.org

Keep going

Browse /search