Making POST Endpoints Idempotent Without Breaking REST

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?

Choosing a retry-safe write pattern A decision tree. The first question asks whether the client can choose the resource identifier. If yes, the answer is PUT to a client-chosen URL, which is idempotent by specification. If no, the next question asks whether an extra round trip is acceptable. If yes, use an allocate-then-consume intent token. If no, use POST with an Idempotency-Key header and server-side deduplication. Can the client choose the id? e.g. customer + plan, external ref yes PUT /subs/{client-id} idempotent by specification no dedup store required no Extra round trip acceptable? latency budget vs auditability no POST + Idempotency-Key server-side dedup store the payment-API default yes allocate → consume server-issued intent token auditable allocation record

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/charges is not idempotent in the HTTP sense. Supplying an Idempotency-Key makes 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
Header-key POST versus allocate-then-consume Two stacked sequences. The upper sequence shows a single POST carrying an idempotency key, where the deduplication store provides the single-shot guarantee. The lower sequence shows two calls: an allocate call that creates an intent record with no side effect, and a confirm call whose state transition provides the guarantee without any deduplication store. A — POST + Idempotency-Key (1 round trip) client POST /charges + key API dedup store guarantee lives here C — allocate then consume (2 round trips) client POST /intents intent_id API POST /intents/{id}/confirm state transition guarantee lives here

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
How to write it down without misstating HTTP Two documentation snippets. The first claims POST is idempotent on this API, which misstates the specification and invites clients to drop the key. The second states that the method is not idempotent but that supplying a key makes retries safe for twenty-four hours, which is accurate and immediately actionable. misleading "POST is idempotent on this API." misstates RFC 9110 · invites clients to stop sending the key accurate "POST is not idempotent. Supplying an Idempotency-Key makes retries with the same key and payload safe for 24 hours." nothing misstated, immediately actionable

SRE / observability checklist

  1. idempotency_key_present_ratio — Gauge per credential on pattern-A routes. Page below 0.98; it means a client or an edge hop stopped sending the header.
  2. 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.
  3. 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.
  4. 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.
  5. retry_contract route label — Attach header_key, put_upsert or intent_token to every route’s metrics so a dashboard shows which guarantee each endpoint is actually running.