May 21, 2026
Idempotency in payments
Why every money-moving endpoint needs an idempotency key, and how to implement one correctly
The retry that charges twice
A client submits a charge request. The request reaches the server, the charge succeeds, the response starts back across the network — and the connection drops before the client sees it. From the client’s point of view, this looks identical to the request never arriving in the first place, and the correct-looking thing to do is retry. From the server’s point of view, a second, identical-looking charge request has just arrived for something that already succeeded. Without a mechanism to recognize “I’ve seen this exact request before,” the server processes it again, and a customer gets charged twice for one purchase.
This isn’t a rare edge case. Any client that retries on timeout — and any client that doesn’t is fragile in worse ways — will eventually produce this exact sequence. A payments API has to be built assuming it will happen routinely, not occasionally.
The idempotency key
The fix is a key the client generates once per logical operation and sends with every attempt to perform that operation, retries included:
POST /v1/charges
Idempotency-Key: 8f14e45f-ceea-467e-9926-example-uuid
{
"amount": 4200,
"currency": "usd",
"paymentMethodToken": "tok_abc123"
}
The server’s contract: the first request with a given key executes and its result is stored against that key; every subsequent request with the same key returns the stored result without executing anything again, regardless of how many times it arrives or how long ago the first attempt happened.
Implementing it correctly
The naive implementation — check if the key exists, if not process the request and save the result — has a race condition: two requests with the same key arriving close together can both pass the “does it exist” check before either has written a result. The fix is to make the check-and-claim atomic, using a unique constraint the database enforces for you:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress',
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handleCharge(req: ChargeRequest, idempotencyKey: string) {
const requestHash = hashRequest(req);
const claimed = await db.query(
`INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ($1, $2, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING key`,
[idempotencyKey, requestHash],
);
if (claimed.rowCount === 0) {
// key already exists — someone got here first
const existing = await db.query(
`SELECT status, response_body, request_hash FROM idempotency_keys WHERE key = $1`,
[idempotencyKey],
);
const row = existing.rows[0];
if (row.request_hash !== requestHash) {
throw new IdempotencyKeyReusedError(idempotencyKey);
}
if (row.status === "in_progress") {
throw new RequestInFlightError(idempotencyKey); // client should retry later
}
return row.response_body; // return the original result, unchanged
}
const result = await processCharge(req);
await db.query(
`UPDATE idempotency_keys SET status = 'completed', response_body = $2 WHERE key = $1`,
[idempotencyKey, result],
);
return result;
}
The ON CONFLICT DO NOTHING ... RETURNING pattern is what makes the
claim atomic: exactly one concurrent request wins the insert and proceeds to
actually process the charge; every other request with the same key sees
zero rows returned and knows to wait for or fetch the original result
instead.
The detail that’s easy to skip: hash the request body too
A key alone isn’t enough. If a client reuses the same idempotency key for a genuinely different request — a bug, or a key generated once and accidentally reused across two different purchases — returning the first request’s cached result silently would be worse than an error: it would report success for a charge that was never actually made for the second purchase’s amount. Storing a hash of the original request body and comparing it on every subsequent use of the same key turns that scenario into a loud, explicit error instead of a silent mismatch.
Where the key belongs, and where it doesn’t
Every endpoint that moves money or has an external side effect — charges, refunds, payouts, transfers — needs this. A read endpoint, like fetching a charge’s status, doesn’t: it’s naturally idempotent already, since calling it twice has no side effect to duplicate. Applying idempotency-key handling indiscriminately to every endpoint adds a database write and a lookup to paths that don’t need the protection; scope it to the operations where a duplicate execution is actually a problem.
Expiry, not indefinite retention
Idempotency keys don’t need to live forever — a client retrying a request does so within seconds or minutes of the original attempt, not weeks later. A retention window (24 to 48 hours is typical) bounded by a scheduled cleanup keeps the table from growing unbounded, while still covering every realistic retry scenario a client will actually produce.
What this means for you
Any endpoint where “the client retried and it happened twice” is an unacceptable outcome needs an idempotency key, a database-enforced atomic claim on that key, and a stored hash of the original request to catch key reuse across different requests. This is a small, well-understood pattern — a handful of columns and a conditional insert — and it’s the difference between a network hiccup being invisible to the customer and it being a duplicate charge someone has to notice, complain about, and get refunded.
30 minutes with a senior engineer.
Tell us what you're building. You'll leave with an honest opinion, even if it's "you don't need us."
Reference calls with past clients are available under NDA during evaluation.