Idempotency keys in Node.js: safe retries for POST endpoints without double charges
A Postgres-backed Express middleware, the 409 and 422 rules, key expiry, and the 5xx decision that decides correctness.

A customer on a flaky mobile connection taps “Pay”, the request times out on their side, the app retries, and your API charges them twice. Your code did nothing wrong on either request. The problem is that POST is not idempotent, and the client had no way to say “this is the same attempt as before”.
An idempotency key fixes that. The client generates a unique key (a UUID) once per logical operation and sends it in an Idempotency-Key header on every attempt. The server stores the key with the result of the first attempt; any retry with the same key gets the stored response instead of running the operation again. Below is a working Express implementation on Postgres, the edge cases that bite (concurrent retries, changed payloads, crashes mid-request), and how long to keep keys.
Why POST needs an idempotency key and PUT usually does not
HTTP defines GET, HEAD, PUT and DELETE as idempotent: sending the same request twice has the same effect as sending it once (RFC 9110, section 9.2.2). POST and PATCH are not. “Create a payment” twice creates two payments.
Clients retry for good reasons: timeouts, dropped connections, a 502 from a load balancer during your deploy. If you have set sensible client timeouts (see timeouts everywhere in Node.js), retries will happen, and the server must be the one that makes them safe.
How the Idempotency-Key header works
The IETF httpapi working group has a draft standard for this header (revision 07, October 2025). It is not an RFC yet, but its rules match what payment APIs such as Stripe have done for years:
| Situation | Server response |
|---|---|
| First request with a new key | Run the operation, store status and body, return it |
| Retry with the same key and same payload, finished | Return the stored response, do not run again |
| Same key, request still running | 409 Conflict, client may retry later |
| Same key, different payload | 422 Unprocessable Content, client bug |
| Key missing on an endpoint that requires it | 400 Bad Request |
The draft recommends a UUID or similar random value as the key, and says servers should publish how long keys are kept. Stripe’s documentation says keys can be pruned once they are at least 24 hours old and that the stored response is returned even when it was a 500.
Implementing idempotency keys in Express with Postgres
Step 1: a table with a unique key per caller
CREATE TABLE idempotency_keys (
account_id bigint NOT NULL,
key text NOT NULL,
fingerprint text NOT NULL,
status text NOT NULL DEFAULT 'running', -- running | done
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, key)
);
CREATE INDEX ON idempotency_keys (created_at);
Scope keys to the caller. Without account_id in the primary key, two customers who happen to send the same key collide, and one could read the other’s stored response.
Step 2: the middleware
// idempotency.js
import { createHash } from 'node:crypto';
import { pool } from './db.js';
const KEY_RE = /^[A-Za-z0-9_-]{8,255}$/;
const STALE_MS = 60_000; // a "running" row older than this is treated as abandoned
export function idempotent({ required = true } = {}) {
return async (req, res, next) => {
const key = req.get('Idempotency-Key')?.replace(/^"|"$/g, '');
if (!key) {
return required ? res.status(400).json({ error: 'Idempotency-Key header required' }) : next();
}
if (!KEY_RE.test(key)) return res.status(400).json({ error: 'invalid Idempotency-Key' });
const accountId = req.user.accountId;
const fingerprint = createHash('sha256')
.update(`${req.method} ${req.baseUrl}${req.path}\n${JSON.stringify(req.body ?? null)}`)
.digest('hex');
const inserted = await pool.query(
`INSERT INTO idempotency_keys (account_id, key, fingerprint)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING key`,
[accountId, key, fingerprint],
);
if (inserted.rowCount === 0) {
const { rows: [row] } = await pool.query(
'SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2',
[accountId, key],
);
if (row.fingerprint !== fingerprint) {
return res.status(422).json({ error: 'Idempotency-Key reused with a different request' });
}
if (row.status === 'done') {
res.set('Idempotent-Replayed', 'true');
return res.status(row.response_code).json(row.response_body);
}
if (Date.now() - row.created_at.getTime() < STALE_MS) {
return res.status(409).json({ error: 'request with this Idempotency-Key is in progress' });
}
// Abandoned by a crashed process: take it over.
await pool.query(
'UPDATE idempotency_keys SET created_at = now() WHERE account_id = $1 AND key = $2',
[accountId, key],
);
}
const json = res.json.bind(res);
res.json = (body) => {
const done = res.statusCode < 500
? pool.query(
`UPDATE idempotency_keys SET status = 'done', response_code = $3, response_body = $4
WHERE account_id = $1 AND key = $2`,
[accountId, key, res.statusCode, body],
)
: pool.query('DELETE FROM idempotency_keys WHERE account_id = $1 AND key = $2', [accountId, key]);
done.catch((err) => req.log?.error({ err }, 'idempotency write failed'));
return json(body);
};
next();
};
}
Step 3: use it on the routes that move money or create things
app.post('/v1/payments', authenticate, idempotent(), async (req, res) => {
const payment = await createPayment(req.user.accountId, req.body);
res.status(201).json(payment);
});
Express 5 forwards rejected promises from async handlers to your error middleware, so a thrown error here reaches it, and the error handler’s res.json call deletes the key so the client can retry.
The decisions you have to make
Store 5xx responses or let them retry?
Stripe stores the first response, including a 500. That is the safest choice when a failure might have happened after the side effect (the charge went through, then the database write failed). The middleware above takes the other path: it deletes the key on a 5xx so the client can retry. That is only safe if your handler is transactional, meaning a 5xx guarantees nothing was committed. If the handler calls an external payment provider, pass the same key through to the provider so their side is idempotent too.
How long to keep keys
Keep them at least as long as clients retry, plus a margin. 24 hours is a common choice. Delete old rows on a schedule:
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
Document the window in your API docs, as the draft asks.
Redis instead of Postgres
If you already run Redis, SET key value NX EX 86400 gives you the same “first writer wins” step. Keep Postgres if the operation itself writes to Postgres: storing the key in the same transaction as the payment row removes a whole class of crash-in-between bugs.
The client side
The key must be created once per user intent, not once per HTTP attempt:
const key = crypto.randomUUID(); // when the user clicks Pay
for (let attempt = 1; attempt <= 3; attempt++) {
const res = await fetch('https://api.example.com/v1/payments', {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': key },
body: JSON.stringify({ amount: 4200, currency: 'usd' }),
signal: AbortSignal.timeout(10_000),
}).catch(() => null);
if (res && res.status !== 409 && res.status < 500) break;
await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
}
Never use an email address or other personal data as the key.
If you only do one thing
- Add the
idempotency_keystable and middleware to every endpoint that charges, sends or creates something a user would notice twice. - Scope keys per account, fingerprint the payload, and return 409 and 422 as above.
- Purge keys older than 24 hours.
A one-person team can ship this in an afternoon for the one payment route that matters. A ten-person team should make it a shared middleware and add “is this POST idempotent?” to the review checklist, next to rate limiting.
FAQ
Is an idempotency key the same as a request ID?
No. A request ID identifies one HTTP attempt and changes on every retry. An idempotency key identifies the user’s intent and stays the same across retries.
Should the idempotency key go in a header or the body?
A header. It keeps the business payload clean, works the same on every endpoint, and matches the IETF draft and Stripe’s API.
How do I test idempotency?
Send the same request twice in parallel and check that only one row was created and the second call got a 409 or the replayed response. Then send the same key with a changed body and expect 422.
Doing this with ApiTraffic
If you would rather not hunt for duplicate charges in raw logs, ApiTraffic records every request and response, so you can tag each one with its key and see every attempt side by side:
apitraffic.tag('idempotencyKey', key);
Then search for conflicts, url_path == "/v1/payments" AND status_code == 409, or for one customer’s attempts with tag:userId == "1042" AND status_code >= 400. Outbound calls are logged too, so you can confirm your payment provider received the request once.
Start free and see every request your API handles: apitraffic.io
Sources: IETF, “The Idempotency-Key HTTP Header Field”, draft-ietf-httpapi-idempotency-key-header-07 (Oct 15, 2025); RFC 9110, HTTP Semantics, section 9.2.2 (June 2022); Stripe API reference, “Idempotent requests”
Keep reading
Node.js fetch timeout: deadlines with AbortSignal and undici
One slow vendor and every handler hangs, because Node fetch waits up to five minutes by default. Here is how to set deadlines with AbortSignal and undici in Express and Fastify.
Node.js rate limiting: fixed window vs sliding window vs token bucket
One client's runaway retry loop should not take your API down at 3am. Compare fixed window, sliding window and token bucket rate limiting in Node, with working code, a Redis version and what to ship this afternoon.
Express 5 migration: what breaks in your API and how to fix it
You bump Express to 5 and the app dies with "Missing parameter name", or worse, starts fine and quietly breaks pagination. Here is what the Express 5 migration actually breaks in an API and how to fix each one.