POST is non-idempotent by specification and will remain so. The practical question is not how to change that but how to give a POST endpoint retry safety without inventing semantics that confuse every client author who reads your documentation. This page sits under HTTP method semantics & safety and compares the four patterns that actually get used.
Prerequisites. You should know why GET and HEAD are inherently idempotent and have read the idempotent endpoint contract, since three of the four patterns build on it.
Step 1 — Decide where resource identity comes from
Every pattern below is downstream of one question: can the client choose the resource’s identifier?
Step 2 — Pattern A: POST with an Idempotency-Key header
Keeps the method, keeps the collection URL, adds a header. This is what Stripe, Adyen and PayPal do, and it is the right default for a public API where clients cannot be trusted to invent globally unique resource identifiers.
POST /v1/charges HTTP/1.1
Idempotency-Key: 01HXYZ3ABCDEFGHJKMNPQRSTVW
Content-Type: application/json
{"amount": 4500, "currency": "gbp", "source": "card_1a2b3c"}
The REST objection — that POST is now “idempotent” in a way the RFC does not sanction — dissolves once the documentation is written correctly. The method is still non-idempotent; the endpoint offers a retry contract. Write it that way:
POST /v1/chargesis not idempotent in the HTTP sense. Supplying anIdempotency-Keymakes retries with the same key and payload safe for 24 hours: at most one charge is created, and repeat calls return the original response.
Nothing in that paragraph misstates HTTP, and every client author understands it immediately.
Step 3 — Pattern B: PUT with a client-chosen identifier
When the client can name the resource, PUT gives you idempotency for free, with no dedup store and no header.
PUT /v1/subscriptions/cust_9f8e7d:plan_pro_monthly HTTP/1.1
Content-Type: application/json
{"quantity": 3, "trial_days": 14}
-- The server-side write is a plain upsert; retries converge on the same row.
INSERT INTO subscriptions (id, customer_id, plan_id, quantity, trial_days)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE
SET quantity = EXCLUDED.quantity,
trial_days = EXCLUDED.trial_days
RETURNING *;
This is the cleanest option available and it is under-used, because teams reach for POST reflexively. It works whenever the resource has a natural composite identity — a customer plus a plan, an account plus a period, an external system’s reference. It does not work when identity must be server-assigned, and forcing it there produces client-generated primary keys that collide across tenants.
Add If-None-Match: * when the intent is strictly create-once rather than upsert:
PUT /v1/subscriptions/cust_9f8e7d:plan_pro_monthly HTTP/1.1
If-None-Match: *
A second request returns 412 Precondition Failed instead of overwriting, which is the correct answer when a re-PUT would clobber concurrent edits.
Step 4 — Pattern C: allocate then consume
Split one unsafe call into two safe ones. The allocation is a repeatable POST with no side effect; the consumption is guarded by a state transition.
# 1. Allocate — safe to repeat, no money moves.
INTENT=$(curl -s -XPOST https://api.example.com/v1/charges/intents \
-H "Authorization: Bearer $KEY" -d '{"amount":4500,"currency":"gbp"}' | jq -r .id)
# 2. Consume — single-shot by construction, guarded by the intent's state.
curl -s -XPOST "https://api.example.com/v1/charges/intents/$INTENT/confirm" \
-H "Authorization: Bearer $KEY" -d '{"source":"card_1a2b3c"}'
-- The guard. Zero rows updated means it was already consumed: replay, do not execute.
UPDATE charge_intents
SET state = 'consumed', consumed_at = now()
WHERE id = $1 AND tenant_id = $2 AND state = 'allocated'
RETURNING id;
The extra round trip costs latency and buys two things: a durable, auditable record that the intent existed before any money moved, and immunity to a client that generates keys badly. In regulated ledger work that trade is usually worth making.
Step 5 — Compare and document
| Pattern | Method | Needs a dedup store | Extra round trip | Identity owner | Best fit |
|---|---|---|---|---|---|
| A — header key | POST |
Yes | No | Server | Public payment APIs, partner integrations |
| B — client-chosen id | PUT |
No | No | Client | Subscriptions, settings, per-period aggregates |
B′ — PUT + If-None-Match: * |
PUT |
No | No | Client | Strict create-once with no overwrite |
| C — allocate/consume | POST ×2 |
No (state column) | Yes | Server | Ledger writes, high-value transfers, regulated flows |
Document the choice per route in the API reference, not once in a preamble. A client author reading POST /v1/refunds needs to know whether that specific route honours a key without cross-referencing a general page.
Verification and testing
Confirm PUT really is idempotent end to end, including any triggers or side effects the handler fires.
URL=http://localhost:8080/v1/subscriptions/cust_9f8e7d:plan_pro_monthly
for i in 1 2 3; do
curl -s -XPUT "$URL" -H 'Content-Type: application/json' \
-d '{"quantity":3,"trial_days":14}' -o /dev/null -w '%{http_code} '
done; echo
psql -c "SELECT count(*) FROM subscriptions WHERE id='cust_9f8e7d:plan_pro_monthly'"
# expect: 201 200 200, and count = 1
Confirm If-None-Match: * blocks the overwrite.
curl -s -XPUT "$URL" -H 'If-None-Match: *' -H 'Content-Type: application/json' \
-d '{"quantity":9}' -o /dev/null -w '%{http_code}\n'
# expect: 412
Confirm the intent cannot be consumed twice.
for i in 1 2; do
curl -s -XPOST "http://localhost:8080/v1/charges/intents/$INTENT/confirm" \
-d '{"source":"card_1a2b3c"}' | jq -r '.status // .code'
done
# expect: succeeded then idempotent_replay (never two charges)
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
PUT with a client-chosen id is adopted, and two tenants pick the same identifier |
Namespace the identifier by tenant in the URL or the primary key, exactly as the scoping tuple does for header keys. Never trust a bare client id as globally unique. | resource_id_collision_total from a unique-violation counter on the tenant-scoped key; alert on any value |
Documentation claims “POST is idempotent on this API”, and a client stops sending keys because it assumes the method guarantees it |
Rewrite the reference to describe the endpoint’s retry contract rather than redefining the method, and make the key required so the assumption cannot cause harm. | idempotency_key_present_ratio per credential; a client dropping to zero after a docs change is the signature |
| Allocate/consume adds a round trip that pushes the checkout flow past its latency budget | Allocate eagerly at the start of the user session rather than at submit time, so the extra trip overlaps with the user filling in a form. | checkout_latency_seconds split by phase; intent_allocated_unused_total to watch allocation waste |
PUT handler was implemented as an insert-only path, so the second call returns 409 instead of converging |
Implement the write as an upsert with ON CONFLICT DO UPDATE, and add the three-call test above to CI. A PUT that fails on repeat is not idempotent. |
http_responses_total{method="PUT",status="409"} — should be zero on upsert routes |
SRE / observability checklist
idempotency_key_present_ratio— Gauge per credential on pattern-A routes. Page below0.98; it means a client or an edge hop stopped sending the header.http_responses_total{method="PUT",status="409"}— Should be flat at zero on upsert routes. Any value means the handler is insert-only and the idempotency claim is false.intent_allocated_unused_total— Counter of intents allocated but never confirmed within an hour. A rising value means clients are abandoning after allocation, which is a funnel signal as much as a technical one.resource_id_collision_total— Counter of unique violations on client-chosen identifiers. Any non-zero value on a tenant-scoped key is a namespacing bug.retry_contractroute label — Attachheader_key,put_upsertorintent_tokento every route’s metrics so a dashboard shows which guarantee each endpoint is actually running.
Related
- HTTP Method Semantics & Safety — the parent page on which methods are safe, idempotent, or neither, and why the distinction matters.
- Why GET and HEAD Are Inherently Idempotent — the specification-level reasoning this page builds on.
- API Contract Design for Idempotent Endpoints — the full wire contract behind pattern A.
- PostgreSQL Unique Constraints vs. Application-Level Checks — the storage guarantee under the
PUTupsert in pattern B.