Networks fail in the worst possible way. The server does the work, and then the response gets lost before the client sees it. The client cannot tell whether the payment went through, so it retries, and now the customer is charged twice. Idempotency keys are the standard fix. Below is how to implement them properly, including the edge cases most tutorials skip.
What an idempotency key does
The client attaches a unique key to any request that has side effects.
POST /v1/payments
Idempotency-Key: 9f1c2b7e-4a0d-4c1e-8a57-3d2f6c0e9b11
Content-Type: application/json
{ "amount": 4900, "currency": "usd", "customer": "cus_123" }In return, the server makes a promise. The first request with that key gets executed. Any retry with the same key gets back the same stored response, status code and body included, and nothing runs a second time. And if the same key arrives with a different payload, the server rejects it, because that can only mean a bug in the client.
Once that holds, the operation is safe to retry. Your clients and queues can retry aggressively on timeouts and 5xx errors, and so can your SDKs.
Generating keys on the client
Use a random UUID. Version 4 is fine, or v7 if you want keys that sort by time. There is no need to hash the payload.
The key belongs to the logical operation, so create it once, for example when the user submits the checkout form. A fresh key for every HTTP attempt defeats the point. Persist it next to the pending operation (local state, a job row or an outbox table) so that a retry after a crash or restart reuses it. On the server side, scope keys per user or API account so two tenants can never collide.
Implementing it on the server
A solid implementation needs a table, a fingerprint and a lock. Here is the table in Postgres.
CREATE TABLE idempotency_keys (
account_id bigint NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
status text NOT NULL DEFAULT 'in_progress', -- in_progress | completed
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, key)
);And the request flow that uses it:
async function handleWithIdempotency(req, handler) {
const key = req.header("Idempotency-Key");
if (!key) return handler(req); // or require it for this endpoint
const hash = sha256(req.method + req.path + canonicalJson(req.body));
// 1. Claim the key atomically. Only one request can insert it.
const claimed = await db.query(
`INSERT INTO idempotency_keys (account_id, key, request_hash)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING key`,
[req.accountId, key, hash],
);
if (claimed.rowCount === 0) {
const existing = await db.one(
`SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2`,
[req.accountId, key],
);
if (existing.request_hash !== hash) return res(422, "Idempotency-Key reused with a different request");
if (existing.status === "in_progress") return res(409, "Request with this key is still in progress");
return res(existing.response_code, existing.response_body); // replay
}
// 2. Execute and store the result.
const result = await handler(req);
await db.query(
`UPDATE idempotency_keys SET status = 'completed', response_code = $3, response_body = $4
WHERE account_id = $1 AND key = $2`,
[req.accountId, key, result.status, result.body],
);
return result;
}A few details in that code carry most of the weight.
The claim has to be atomic. Because INSERT ... ON CONFLICT DO NOTHING runs against a primary key, two concurrent retries cannot both execute. Read first and insert afterward, and you have a race.
The fingerprint covers the case where someone reuses a key for a different request. Replaying the stored response there would be silently wrong, so return a 4xx instead. A retry that shows up while the first request is still running gets a 409, and the client tries again later.
Store the response even when it is an error. If the first attempt failed with a validation error, the retry should see that same error. Server errors are worth treating differently. Consider not storing 5xx responses, so a transient failure can be retried with the same key.
Which status codes? The IETF Idempotency-Key header draft suggests 400 for a missing key and 422 for a key reused with a different payload. For a request still in flight it suggests 409. The draft expired without becoming a standard, yet following it keeps your API predictable.
Then there is the crash in the middle. If the process dies after claiming the key, that row stays in_progress, so expire those rows after a timeout. Where you can, also make the side effect itself safe to re-run by doing the work and the key update in one database transaction.
Last comes retention. Delete keys once they are past your retry window, with a scheduled job. 24 hours is a common default.
Idempotency for payments
When you call a payment provider, pass your own idempotency key through. Stripe accepts one. So do Adyen and PayPal, along with most other payment APIs. Derive the key from your own operation ID, for example order_8812_charge, so that a retry of your job reuses the same key at the provider too. A retry at any layer then results in one charge, whether it came from the browser, from your API or from your background job.
Check your provider's exact semantics, because they differ. Stripe alone shows how much. As of September 2026, its two API versions behave like this.
| Stripe API v1 | Stripe API v2 | |
|---|---|---|
| Key retention | Pruned after at least 24 hours | 30 days |
| Retry after the first attempt failed | Replays the saved result, even a 500 | Re-executes the request |
| Key reused with different parameters | Error | Error |
Two rules follow. Treat a 500 or a timeout from the provider as indeterminate, since the charge may or may not have happened. Retry with the same key and reconcile via webhooks instead of assuming failure. After a clear 4xx validation error it goes the other way, and you fix the request and use a new key, because the old one may be bound to the failed attempt.
The other direction: webhooks
Providers deliver webhooks at least once. You will receive duplicates, sometimes out of order, and the receiving side needs its own deduplication.
CREATE TABLE processed_events (
provider text NOT NULL,
event_id text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);- Verify the signature.
INSERTthe event ID. If it conflicts, return200and stop, because you already handled it.- Process in the same transaction as the insert, or enqueue a job keyed by the event ID.
- Return
2xxfast; do slow work asynchronously so the provider does not time out and redeliver.
Ordering needs a different fix. Do not trust arrival order at all. Re-fetch the current state of the object from the provider's API, or compare the event's timestamp or version with what you have stored.
Checklist
- Require
Idempotency-Keyon every POST that creates something or has side effects. - Clients generate one UUID per operation and persist it across retries.
- Claim keys atomically with a unique constraint.
- Fingerprint requests; reject key reuse with a different payload.
- Return
409for in-flight duplicates; replay stored responses for completed ones. - Expire keys after your retry window.
- Pass keys through to payment providers.
- Deduplicate incoming webhooks by event ID.
Most integration bugs we fix come down to retries without idempotency. They show up as double charges, duplicate orders and missing syncs. For how retries interact with load, see why APIs slow down under load. If you are connecting AI agents to these same APIs, MCP servers vs APIs covers how to do it safely.