POST: More Than Create
POST is HTTP's "here, process this" — creation, commands, complex reads, batch submissions. Its defining property is what it refuses to promise: idempotency. Every POST that matters needs an answer to "what if this arrives twice?", because it will.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The honest catch-all
POST's definition is broader than the CRUD folklore: it asks the target resource to *process* the enclosed representation according to its own semantics. Creation under a collection (POST /orders) is the famous case, but commands (POST /orders/42/cancel — see Resource or Action?), processing submissions (POST /images/classify), batch envelopes (POST /events:batch — see Batch APIs and Partial Failure) and body-carried reads (POST /search — see GET: The Promise of Safety) are all legitimate, spec-honest POSTs.
What unifies them is the *absence* of promises: POST claims neither safety nor idempotency, which is precisely why it is the right method for operations that genuinely lack them. Choosing POST is not laziness when the operation truly may have a distinct effect per call. It is laziness when the operation *is* idempotent (a full-state replace, a deletion) and POST's vagueness just spared someone a design conversation — that operation forfeited free retries for nothing (see HTTP Methods Are Promises).
For creation specifically, the response carries contract weight: 201 Created with a Location header pointing at the new resource, and ideally the representation itself, so the client immediately holds the id it will need for everything afterwards. A 200 with an ambiguous body works until the client needs to know *whether* creation happened — which is the next section's problem.
- Create:
POST /orders→201+Location: /orders/ord_42+ the representation. - Command:
POST /orders/42/cancel— server-owned transition (see Designing State Transitions). - Process:
POST /documents:analyze— computation on submitted data, possibly async (see Long-Running Operations: 202 and the Job Resource). - Batch:
POST /events:batch— an envelope whose per-item outcomes need their own reporting (see Partial Failure: When 3 of 5 Succeed). - Body-carried read:
POST /search— safe in effect, POST in shape; document the exception.
The duplicate problem is the design problem
Walk the failure: the client POSTs a payment; the server charges the card; the response is lost to a network blip. The client sees a timeout — which is *not* "it failed", it is "unknown" (see Retries and Timeouts as Contract Guidance). Its options are both terrible: give up (maybe the customer paid and gets nothing) or retry (maybe the customer pays twice). This is not an edge case; at 1M requests/day and a 0.1% response-loss rate, it is a thousand daily coin flips over money.
The method cannot save you — POST's non-idempotency is the truth about the operation. The answer must be application-level: give the client a way to say "this is the *same* submission", and make the server treat a replay as a replay. The standard mechanism is an idempotency key — client-generated, stored with the outcome server-side, replayed outcome on key match (the full protocol, including in-flight concurrency and parameter-mismatch rules, lives in Idempotency Keys: The Mechanism). Natural business keys (order number, event id) can serve the same role when the domain has them.
The contract clause that matters is the *replay response*: the retry must receive the original outcome (same order id, same status) — not a 409 duplicate error that forces the client to guess what happened, and not a second effect. With that clause, POST becomes as retry-safe as PUT, at the cost of one header and a server-side store.
POST /payments HTTP/1.1
Idempotency-Key: 4c1e9a… # same key as the timed-out attempt
Content-Type: application/json
{
"amount": 4999,
"currency": "EUR",
"source": "card_abc"
}HTTP/1.1 201 Created
Location: /payments/pay_7fk2
Idempotency-Replayed: true
{
"id": "pay_7fk2",
"status": "succeeded",
"amount": 4999,
"created_at": "2026-08-25T09:41:07Z"
}Designing each POST deliberately
Because POST carries no method-level promises, every promise must be written locally, per endpoint. The review checklist: What does this POST do — create, command, or process? What is returned, and does a created resource get a Location? What identifies a duplicate, and what does the duplicate receive? Is the work synchronous, or does it return 202 and a job to poll (see The Async Job Pattern)? Which failures are the caller's (4xx) versus the processor's (5xx), and which are retryable (see Retryability: Telling Clients What To Do Next)?
The comparison below shows the same creation endpoint before and after the checklist. Nothing about the "after" is exotic — it is the accumulation of small clauses: status code with meaning, location, idempotency, explicit duplicate behavior. APIs that skip the checklist do not avoid these questions; they delegate them to each client's incident retro.
1POST /orders2{ "items": [ … ] }3→ 200 OK4{ "success": true }5 6# Where is the order? (fetch the list and guess)7# Timed out — retry? (coin flip: zero or two orders)8# Sent twice by a double-click? (two orders, silently)1POST /orders2Idempotency-Key: 9d47b2…3{ "items": [ … ] }4→ 201 Created5Location: /orders/ord_8126{ "id": "ord_812", "status": "created", … }7 8# retry with same key → 201 replayed, same ord_8129# same key, changed body → 422 idempotency_key_reuse10# validation failure → 422 with field errors, never retriedEach added line eliminates a class of client-side guesswork: Location kills the fetch-and-guess, the key kills the retry coin flip, the reuse rule kills silent divergence. The "simple" version was only simple for the two weeks before real traffic.
Key points
- POST legitimately covers creation, commands, processing, batches and body-carried reads — "POST = create" is folklore, not the contract.
- POST's value is refusing to promise idempotency for operations that genuinely lack it; using it for idempotent operations forfeits free retries for nothing.
- A timeout is "unknown", not "failed" — without a duplicate-submission answer, every retry of a consequential POST is a coin flip.
- The replay response is the crucial clause: retries receive the original outcome, never a second effect and never a bare duplicate error.
- Creation should return 201 + Location + representation, so clients hold the id without a follow-up query.
- Every POST endpoint documents locally what the method refuses to promise globally.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships
POST /paymentsreturning200 {"success": true}— no id, no idempotency, no duplicate policy. - 2Network → client: a response is lost; the SDK surfaces a timeout to checkout code.
- 3Client → API: retries, as its authors chose the lesser evil; the card charges again.
- 4Customer → support: two charges appear; reconciliation finds hundreds of monthly cases nobody had noticed.
- 5Team → clients: adds idempotency keys as an optional header; optional means unused, and the incident recurs until keys are required for money paths.
- Duplicate effects on retries: double charges, double orders, double emails — concentrated on the operations where duplication hurts most.
- Clients that fear retrying instead leak failures to users: transient blips become abandoned checkouts.
- Without Location/id in responses, clients resort to list-and-guess reconciliation, which itself races with concurrent creations.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Require idempotency keys on every POST with money or irreversible effects; make the replay return the original outcome (see [[idempotency-keys]]).
- • Return 201 + Location + representation for creations; 202 + job resource for async processing (see [[async-job-pattern]]).
- • Define duplicate detection even without keys where natural business ids exist, and document the duplicate response explicitly.
- • Separate caller errors (4xx, do not retry) from processing errors (5xx, retry with backoff) so client recovery is deterministic (see [[retryability]]).
- • Reconciliation jobs detecting duplicate business effects (same cart, same minute, two orders) measure the missing-idempotency tax directly.
- • Track idempotency-key adoption and replay rates per endpoint; replays correlate with client-side timeout rates and validate the mechanism.
- • Alert on `POST` latency approaching client timeout budgets — that gap is where duplicate submissions breed.
- • Idempotency keys can be introduced additively (optional → warn → required for new API versions), with telemetry showing who still submits bare POSTs.
- • A synchronous POST that outgrows its latency budget evolves to `202` + job resource — a breaking change in response shape, so version it or add a new endpoint (see [[long-running-operations]]).
- • Response enrichment (adding Location, echoing the representation) is additive and safe at any time.
- • Idempotency requires server-side state: a keyed outcome store with expiry, capacity and consistency questions of its own.
- • Requiring keys raises the integration bar — every client must generate and persist keys across its own retries to benefit.
- • Strict duplicate rules (422 on key reuse with changed body) surface client bugs loudly; teams will feel the strictness before they thank it.