You POST to the payment API and the response never arrives. 504 timeout. Retry? Don't retry and you might miss the charge; retry and you might double-charge. The client can't tell whether the request reached the server. This is the fundamental problem of POST, and idempotency keys are the answer. This guide explains how Stripe and PayPal implement them, the server-side state machine, and key-generation strategy.
Why GET is safe and POST is dangerous
HTTP's notion of idempotency: sending the same request N times has the same effect as sending it once.
- GET, PUT, DELETE — idempotent by definition. The same PUT 100 times = the same end state as one PUT.
- POST — not idempotent. The same POST 100 times creates 100 resources.
- PATCH — case by case.
{"$inc": 1}isn't idempotent;{"name": "X"}is.
The most important operations — payments, transfers, order creation — are all POST. Without retries you lose reliability; with retries you risk duplicates. You need a mechanism that accepts retries safely.
How idempotency keys work
The client sends a unique key in a header on each request. The server stores (key → response).
POST /v1/charges
Idempotency-Key: 5d8a3f2e-1c4b-4a9d-b8e7-3f2a1c4b8e7d
Content-Type: application/json
{ "amount": 5000, "currency": "usd" }- First call — server processes the charge, stores (key → response), and replies.
- Retry — same key arrives. Server returns the stored response without re-charging.
- Different key — treated as a new charge.
Result: the client retries freely; the server prevents duplicates because the key matches.
The Stripe rules, specifically
- Header:
Idempotency-Key. Up to 255 characters. - Retention: 24 hours. After 24 hours the same key is treated as new.
- Body must match: same key + different body = 409 Conflict. The server stores a hash of the body alongside the key.
- Response headers: echoes
Idempotency-Keyand addsStripe-Should-Retry(true/false) as a retry hint. - Scope: POST only. GET is naturally idempotent.
Any format for the key works. The standard choices are UUID v4 or v7. Generate with UUID / ULID Generator, and build a curl with the header in cURL Builder.
Server-side state machine
Track this state per key:
| state | Meaning | Behavior on repeated key |
|---|---|---|
| NEW | First time seen (not in DB) | Acquire lock, start processing |
| IN_PROGRESS | Currently executing (lock held) | 409 Conflict or wait-and-poll |
| COMPLETED | Response stored | Return the stored response |
| FAILED | Transient error (5xx) | Allow retry — don't return the stored response |
Schema sketch:
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
state VARCHAR(20) NOT NULL,
http_status INT,
response_body JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON idempotency_keys (expires_at);TTL: expires_at = created_at + 24h (Stripe's choice). Delete expired rows in a periodic job.
Locking — concurrent requests
After a client timeout it may retry while the original is still in flight. Both arrive at the server, both see NEW, both execute = duplicate. You need a lock.
Postgres:
BEGIN;
INSERT INTO idempotency_keys (key, request_hash, state)
VALUES ($1, $2, 'IN_PROGRESS')
ON CONFLICT (key) DO NOTHING
RETURNING *;
COMMIT;If RETURNING emits a row, you're first; otherwise someone else is processing or it's already done. In the otherwise branch, read the row and dispatch on state.
Redis works too: SET key value NX EX 86400. NX fails if the key exists.
Key generation — the client's job
Since the server only consumes keys, the client must generate them well.
- New key per logical request — keep the same key across retries for the same intent; mint a new one for a new intent.
- UUID v4 — the typical choice. Collisions effectively zero.
- UUID v7 or ULID — time-sortable. Better DB locality.
- Don't hash the body as the key — two different but identical-looking requests would collapse into one.
Pattern on mobile/SPA — generate a UUID the moment the user taps Pay, store it locally, reuse across retries, delete after success.
Use response headers to tell the client
HTTP/1.1 200 OK
Idempotency-Key: 5d8a3f2e-1c4b-4a9d-b8e7-3f2a1c4b8e7d
Idempotent-Replayed: true
Content-Type: application/json
{ "id": "ch_abc", "status": "succeeded" }Seeing Idempotent-Replayed: true lets the client understand that the original "failure" was a phantom — useful for UI flow.
Common pitfalls
1. Idempotency key on GET
GET is already idempotent. Adding a key confuses caches and CDNs for no benefit.
2. Same key across endpoints
Using one key for both /charges and /refunds. Without scoping by endpoint, the second call replays the first's response. Scope keys by path, or have the client always mint a fresh one.
3. Wrong TTL
24 hours (Stripe's default) is reasonable. Too short (1 minute) and a delayed retry duplicates; too long (1 year) wastes storage. Match it to the business.
4. No body hashing
Same key + different body returning the cached response is a security issue (one user's data goes to another). Always store and check the body hash.
5. Unbounded wait on IN_PROGRESS
Polling forever while another request holds the lock cascades into client timeouts. Cap at ~30 s, then return 409 or 503.
6. Storing a 5xx as completed
If the server replied 5xx, the client will retry. If the row is in IN_PROGRESS or has the bad response stored, the retry is rejected or replays the failure. Delete the row (or mark FAILED) on 5xx so retries proceed.
Client implementation pattern
async function chargeWithRetry(amount, currency, maxRetries = 3) {
const key = crypto.randomUUID(); // create once, keep across retries
for (let i = 0; i <= maxRetries; i++) {
try {
const res = await fetch("/v1/charges", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({amount, currency}),
});
if (res.ok) return res.json();
if (res.status >= 400 && res.status < 500) {
throw new Error(`Client error: ${res.status}`);
}
// 5xx → backoff then retry
} catch (e) {
if (i === maxRetries) throw e;
}
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}Exponential backoff plus a stable key. 4xx is a client error — don't retry.
Summary
- POST is not idempotent by nature. You need a key to make retries safe.
Idempotency-Keyheader; the server stores (key → response).- State machine: NEW / IN_PROGRESS / COMPLETED / FAILED. Locking required.
- Validate key + body hash together. Different body → 409.
- 24-hour TTL (Stripe's standard). Sweep expired rows.
- Clients keep a key across retries of the same intent; new intent gets a new key.