HTTP Verbs, Status Codes, and Idempotency
What This Concept Is
HTTP defines verbs (methods) with standardized semantics. Your API inherits those semantics whether you want to or not - caches, proxies, libraries, and consumers will assume them. The big five plus PATCH:
| Verb | Safe? | Idempotent? | Typical use |
|---|---|---|---|
GET | yes | yes | read a resource or list |
HEAD | yes | yes | like GET but headers only |
POST | no | no | create in a collection; non-idempotent ops |
PUT | no | yes | replace a resource at a known URL |
PATCH | no | no (RFC) | partial update; can be made idempotent |
DELETE | no | yes | remove a resource |
Safe means no server-side side effect the caller is responsible for (GET reading is fine; incrementing a view counter is not a user-visible side effect and is allowed).
Idempotent means calling the same request twice has the same server state as calling it once. Idempotency is what lets clients retry on network failure without double-charging anyone.
Status codes are grouped:
2xxsuccess -200 OK,201 Created,202 Accepted,204 No Content3xxredirect -301,304 Not Modified4xxclient error -400,401,403,404,409 Conflict,410 Gone,422,429 Too Many Requests5xxserver error -500,502,503,504
Do not invent status codes outside the standard. Do not reuse 500 for validation errors.
Why It Matters Here
Verbs and status codes are the only part of your API that HTTP infrastructure (CDNs, proxies, load balancers, client retry libraries) actually understands. If you use them correctly, you get caching, retries, and error classification for free. If you misuse them, every layer misbehaves.
Idempotency in particular is not a nice-to-have. Any distributed system has retries; any API without idempotency semantics invites duplicate side effects.
Concrete Example
A correct CRUD pattern for an Order resource:
POST /orders -> 201 Created, body=Order, Location: /orders/ord_1
GET /orders/ord_1 -> 200 OK, body=Order
GET /orders -> 200 OK, body={ items: [...], next_page_token }
PUT /orders/ord_1 -> 200 OK, body=Order (full replace)
PATCH /orders/ord_1 -> 200 OK, body=Order (partial update)
DELETE /orders/ord_1 -> 204 No Content
Error cases and their status codes:
POST /orders malformed JSON -> 400 Bad Request
POST /orders missing required field -> 400 Bad Request (or 422 Unprocessable)
GET /orders/xyz not found -> 404 Not Found
PUT /orders/ord_1 stale If-Match -> 412 Precondition Failed
POST /orders duplicate idempotency key but different body -> 409 Conflict
POST /orders rate-limited -> 429 Too Many Requests, Retry-After: 30
GET /orders/ord_1 deleted -> 410 Gone (not 404 if we want to signal it existed)
Making POST idempotent via a header (standard pattern):
POST /orders
Idempotency-Key: 4b2e2c01-7c2f-4a2a-8f4f-92bfb0d0e1b1
Content-Type: application/json
{ "customer_id": "c_9", "items": [ ... ] }
Server behavior:
- first call: creates the order, stores
(key, request_hash, response)for 24 hours, returns201 - retry with same key and same body: returns the cached
201with the sameOrder- no duplicate - retry with same key but different body: returns
409 Conflict - after TTL: key is reusable
For updates, prefer PUT (naturally idempotent: send the full state). For partial updates, PATCH can be made idempotent by including preconditions such as If-Match: "etag-value":
PATCH /orders/ord_1
If-Match: "W/\"v12\""
Content-Type: application/json-patch+json
[{ "op": "replace", "path": "/status", "value": "confirmed" }]
Retries with the same If-Match either succeed once or fail with 412; retries never double-apply.
Common Confusion / Misconception
"POST is for create." That is the common case, not the rule. POST is "non-idempotent operation on a collection or non-resource endpoint." Creating is one example; running a search with a large filter (POST /orders:search) is another.
"PUT and PATCH do the same thing." PUT replaces the entire resource; omitting a field means "set it to default / null." PATCH updates a subset; omitted fields are unchanged. Use PUT when the client has the full state; PATCH when it has only the diff.
"DELETE is idempotent, so I can return 204 always." You should still distinguish: first DELETE returns 204 (deleted); second returns 204 or 404 (already gone). Either is valid per spec; pick one and document it.
"200 is the safe choice for everything." 200 with an error body is a common anti-pattern. HTTP-aware infrastructure treats 2xx as success; your client's retry logic will not retry a 200 { ok: false }. Return the correct status.
"GET with a request body is fine for search." Per HTTP spec, servers MAY ignore GET bodies, and many proxies and CDNs strip them. Use query parameters or a POST /things:search custom method (Cluster 3).
How To Use It
For each endpoint:
- Pick the verb by answering: does it read or write? Is it idempotent? Does it target a collection or an item?
- Pick the success status by answering: is there a body (
200)? Was something created (201)? Was it accepted for async processing (202)? No body (204)? - Map every failure to a specific
4xxor5xx- never200 { ok: false }. - Decide idempotency for every write. Document the mechanism (
Idempotency-Key,If-Match, or "naturally idempotent becausePUT").
Keep a one-page verb-and-status table per API. Review it in contract review.
Check Yourself
- Why is
POSTfor creation not idempotent by default, and what are the two common ways to make it idempotent? - A consumer sees
504 Gateway TimeoutonPOST /payments. What should they do? What would your answer be if it werePOST /search? - Is returning
200 OKwith an error body in the response wrong, or just unconventional? Under what conditions does it actively break things?
Mini Drill or Application
For a banking API with accounts, transfers, statements:
- Produce a table with verb, URL, success status, idempotency mechanism for each endpoint.
- Produce a second table with one expected
4xxand one expected5xxper endpoint, naming the code and meaning. - Write a paragraph defending the idempotency choice for
POST /transfers.