# 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.

Source: https://howhttpworks.com/guides/rest-api-design-http-semantics
Last reviewed: 2026-10-04

> **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 to | Method | Safe | Idempotent | Typical success |
| --- | --- | --- | --- | --- |
| Read a resource or collection | [GET](https://howhttpworks.com/methods/get) | Yes | Yes | 200 |
| Create, server picks the ID | [POST](https://howhttpworks.com/methods/post) to the collection | No | No | 201 |
| Create or replace at a client-chosen URI | [PUT](https://howhttpworks.com/methods/put) | No | Yes | 201 or 200/204 |
| Change some fields | [PATCH](https://howhttpworks.com/methods/patch) | No | No (not guaranteed) | 200 or 204 |
| Remove | [DELETE](https://howhttpworks.com/methods/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

```http
POST /v1/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"sku":"A-100","quantity":2}
```

```http
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](https://howhttpworks.com/status-codes/201).

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

```http
POST /v1/exports HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"format":"csv","range":"2026-Q3"}
```

```http
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:

```http
GET /v1/exports/exp_91 HTTP/1.1
Host: api.example.com
```

```http
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](https://howhttpworks.com/status-codes/202).

## 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 `{}`.

```http
DELETE /v1/orders/ord_8f3k HTTP/1.1
Host: api.example.com
If-Match: "3"
```

```http
HTTP/1.1 204 No Content
```

See [204 No Content](https://howhttpworks.com/status-codes/204).

## 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`](https://howhttpworks.com/status-codes/422)**: 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`](https://howhttpworks.com/status-codes/409)**: 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`](https://howhttpworks.com/status-codes/412)**: 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.

```http
GET /v1/orders/ord_8f3k HTTP/1.1
Host: api.example.com
```

```http
HTTP/1.1 200 OK
ETag: "3"
Content-Type: application/json

{"id":"ord_8f3k","quantity":2,"status":"pending"}
```

```http
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
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
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](https://howhttpworks.com/headers/etag) and [If-Match](https://howhttpworks.com/headers/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](https://howhttpworks.com/guides/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:

```http
GET /v1/orders?limit=2 HTTP/1.1
Host: api.example.com
```

```http
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](https://howhttpworks.com/headers/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
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:

```http
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
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](https://howhttpworks.com/guides/api-rate-limiting).
