TL;DR
- Idempotency means repeating the same operation produces the same server state as running it once.
POSTis non-idempotent by spec, and network retries onPOST /chargescreate duplicate charges without extra protection.- Idempotency keys fix this at the application layer: the client sends a UUID header, the server caches the response against it.
- Implement deduplication with a dedicated database table and a unique constraint. The constraint handles the race condition atomically.
Your payment form has a spinner. The user clicks "Pay." The network times out after three seconds. Your frontend retries. Stripe creates two charges.
This isn't a hypothetical. Before Stripe formalized idempotency keys, this was a documented, recurring production failure, described explicitly in their 2017 engineering blog post because it was happening to real customers. The failure mode was always the same: a POST /charges that timed out, a retry that fired cleanly, and two line items on the card.
The fix isn't "don't retry." Retries are correct. Networks are unreliable. The fix is designing your API so that retrying the same logical operation never produces a different result than running it once. That property is idempotency, and it's the one most developers skip.
The fix is a server-side pattern called idempotency keys. This article explains the concept, builds the implementation in Node.js and PostgreSQL, and shows tests that prove the behavior against Stripe.
Note
What you'll need: Node.js 20+, PostgreSQL 14+, Express 4.x or 5.x (middleware examples follow 4.x style), and a Stripe test account if you run the validation suite against test mode.
Why POST retries create duplicate charges
A POST can be executed more than once without the client doing anything wrong. Common cases:
| What went wrong on the wire | What the client does next |
|---|---|
| Timeout before any response is read | Retries |
| Success response lost in transit | Retries |
| App crashed after send, before it saved "success" locally | Retries on restart |
Same outcome: the server may see two logical attempts at one user action. None of these are client bugs.
Naive retry (no idempotency key on the request):
// Node.js 20: client-side retry without idempotency protection
async function chargeCustomer(amount, customerId) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await fetch("/api/charges", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount, customer_id: customerId }),
});
return await response.json();
} catch (err) {
if (attempt === 3) throw err;
await new Promise((r) => setTimeout(r, attempt * 1000));
}
}
}The loop is fine. The server is the problem: every POST /charges creates a new charge. Three retries can mean three charges.
What idempotent means in REST API design
RFC 9110 (Section 9.2.2):
"A request method is considered 'idempotent' if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request."
What that implies in practice:
- Server state, not identical response bodies. Example: first
DELETE /users/42→204; repeat →404. User is still gone; state matches "deleted once." - Semantics over mechanics: you design for what the operation means, not only what the handler does line-by-line.
Idempotent by spec: GET, HEAD, PUT, DELETE, OPTIONS, TRACE. POST is not. That is why idempotency keys exist as an application-layer pattern on top of POST.
The idempotency key flow: the second request returns the cached response without touching Stripe.
On the first request the server processes the operation and stores the response against the key. Every subsequent request with that key gets the cached response back without re-running the processor.
Stripe (reference behavior):
- Reused
Idempotency-Key→ same cached response, same HTTP status (e.g. cached402stays402). - Mappings kept ≥ 24 hours; after that, the same string is a new logical request.
- Keys scoped per account;
(key, endpoint, params)fingerprint → wrong params →400. - Two identical in-flight calls can surface as
409. - Key format: UUID v4 recommended (
crypto.randomUUID()in Node); max length per Stripe docs.
Implementing idempotency keys on the server
Stack: Node.js (Express + PostgreSQL).
Step 1: Create the idempotency keys table
Dedicated table; pattern aligns with production billing key stores (see e.g. Brandur Leach on idempotency at Stripe).
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
idempotency_key TEXT NOT NULL,
last_run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
recovery_point TEXT NOT NULL DEFAULT 'started',
request_method TEXT NOT NULL,
request_path TEXT NOT NULL,
request_params JSONB NOT NULL,
response_code INT,
response_body JSONB,
user_id BIGINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '24 hours'),
UNIQUE(user_id, idempotency_key)
);
CREATE INDEX ON idempotency_keys (expires_at);| Mechanism | Role |
|---|---|
UNIQUE(user_id, idempotency_key) | Atomic dedupe; races resolved in the DB |
recovery_point | Tracks how far a multi-step operation got |
locked_at | Available for external lock tracking; not set by this implementation |
Index on expires_at | Cheap cleanup of expired rows |
Step 2: Acquire and complete keys in PostgreSQL
Database-backed, concurrency-safe acquisition:
// Node.js 20: PostgreSQL idempotency key store (pg@8)
import pg from "pg";
import { isDeepStrictEqual } from "node:util";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export async function acquireIdempotencyKey(userId, key, method, path, params) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const requestParams = params ?? {};
const insert = await client.query(
`INSERT INTO idempotency_keys
(user_id, idempotency_key, request_method, request_path, request_params)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, idempotency_key) DO NOTHING
RETURNING *`,
[userId, key, method, path, JSON.stringify(requestParams)],
);
if (insert.rows.length > 0) {
// We inserted; we own this request
await client.query("COMMIT");
return { status: "new", record: insert.rows[0] };
}
// Key already exists; fetch it with a lock
const existing = await client.query(
`SELECT * FROM idempotency_keys
WHERE user_id = $1 AND idempotency_key = $2
FOR UPDATE NOWAIT`,
[userId, key],
);
await client.query("COMMIT");
const record = existing.rows[0];
// Params mismatch; reject
if (!isDeepStrictEqual(record.request_params, requestParams)) {
return { status: "conflict" };
}
// Already completed; return cached response
if (record.response_code !== null) {
return { status: "cached", record };
}
// In-flight; signal the client to retry
return { status: "in_progress" };
} catch (err) {
await client.query("ROLLBACK");
// FOR UPDATE NOWAIT throws 55P03 if the row is already locked
if (err.code === "55P03") {
return { status: "in_progress" };
}
throw err;
} finally {
client.release();
}
}
export async function completeIdempotencyKey(
userId,
key,
responseCode,
responseBody,
) {
await pool.query(
`UPDATE idempotency_keys
SET response_code = $3,
response_body = $4,
recovery_point = 'completed',
last_run_at = now()
WHERE user_id = $1 AND idempotency_key = $2`,
[userId, key, responseCode, JSON.stringify(responseBody)],
);
}| Behavior | Why |
|---|---|
COMMIT before returning from acquisition | Row lock does not wrap your handler or Stripe |
response_code === null → in_progress (409) | Dedupes slow work while another request is in flight |
FOR UPDATE NOWAIT → 55P03 → in_progress | Fail-fast when two transactions race at acquisition time |
Warning
Do not read FOR UPDATE NOWAIT as “the row stays locked through payment.” After COMMIT, the row is unlocked. Concurrent requests during Stripe still see response_code === null and receive 409 in_progress; NOWAIT matters when two transactions contend during acquisition, not while the processor call is in flight.
Step 3: Express middleware
- Validate UUID v4
Idempotency-Key; require authenticateduserId. cached→ replay stored status + body;conflict→ 400;in_progress→ 409;new→ wrapres.jsonand fire-and-forgetcompleteIdempotencyKeyso the client is not blocked.
// Node.js 20: Express idempotency middleware
import {
acquireIdempotencyKey,
completeIdempotencyKey,
} from "../db/idempotency.js";
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function idempotencyMiddleware() {
return async (req, res, next) => {
const key = req.headers["idempotency-key"];
// Idempotency is opt-in; skip if no key
if (!key) return next();
if (!UUID_V4.test(key)) {
return res.status(400).json({
error: "invalid_idempotency_key",
message: "Idempotency-Key must be a valid UUID v4.",
});
}
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({
error: "unauthorized",
message: "Authentication is required before idempotency checks.",
});
}
const result = await acquireIdempotencyKey(
userId,
key,
req.method,
req.path,
req.body,
);
switch (result.status) {
case "cached":
return res
.status(result.record.response_code)
.json(result.record.response_body);
case "conflict":
return res.status(400).json({
error: "idempotency_key_reuse",
message: "This key was used with different request parameters.",
});
case "in_progress":
return res.status(409).json({
error: "request_in_progress",
message:
"A request with this key is already being processed. Retry shortly.",
});
case "new": {
// Intercept res.json to capture the response for storage (sync override; persistence is fire-and-forget)
const originalJson = res.json.bind(res);
res.json = (body) => {
completeIdempotencyKey(userId, key, res.statusCode, body).catch(
(err) => {
console.error("Idempotency completion failed:", err);
},
);
return originalJson(body);
};
return next();
}
}
};
}Step 4: Charge route and Stripe
Pass the same key to Stripe so you have app-level dedupe plus processor-level dedupe.
// Node.js 20: Express charge route with layered idempotency
import express from "express";
import Stripe from "stripe";
import { idempotencyMiddleware } from "../middleware/idempotency.js";
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
router.post("/charges", idempotencyMiddleware(), async (req, res) => {
const { amount, currency, payment_method_id } = req.body;
const paymentIntent = await stripe.paymentIntents.create(
{ amount, currency, payment_method: payment_method_id, confirm: true },
// Pass your key to Stripe as a second layer of deduplication
{ idempotencyKey: req.headers["idempotency-key"] },
);
res.status(201).json({
id: paymentIntent.id,
status: paymentIntent.status,
amount: paymentIntent.amount,
});
});
export default router;Verifying idempotency keys in Stripe test mode
- Confirm TTL, replay, and mismatch behavior against current Stripe docs for your API version.
- Run the API on
http://localhost:3000withTEST_TOKENset for the test below. - From project root:
node --test test/idempotency.test.js
// Node.js 20 built-in test runner
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
const BASE_URL = "http://localhost:3000";
async function charge(idempotencyKey, amount = 4999) {
const res = await fetch(`${BASE_URL}/charges`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.TEST_TOKEN}`,
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
amount,
currency: "usd",
payment_method_id: "pm_card_visa", // Stripe test payment method
}),
});
return { status: res.status, body: await res.json() };
}
describe("Idempotency middleware", () => {
it("returns the same charge ID for duplicate keys", async () => {
const key = crypto.randomUUID();
const first = await charge(key);
const second = await charge(key);
assert.equal(first.status, 201);
assert.equal(second.status, 201);
assert.equal(first.body.id, second.body.id); // same charge, not a new one
});
it("returns 409 for concurrent duplicate requests, exactly one succeeds", async () => {
const key = crypto.randomUUID();
const results = await Promise.all([charge(key), charge(key), charge(key)]);
const successes = results.filter((r) => r.status === 201);
const conflicts = results.filter((r) => r.status === 409);
assert.equal(successes.length, 1);
assert.ok(conflicts.length >= 1);
// All successful responses reference the same charge
assert.equal(new Set(successes.map((r) => r.body.id)).size, 1);
});
it("returns 400 for parameter mismatch on same key", async () => {
const key = crypto.randomUUID();
await charge(key, 4999); // establish the key
const mismatch = await charge(key, 9999); // different amount
assert.equal(mismatch.status, 400);
assert.equal(mismatch.body.error, "idempotency_key_reuse");
});
});Example output (timings vary):
$ node --test test/idempotency.test.js ▶ Idempotency middleware ✔ returns the same charge ID for duplicate keys (612.884104ms) ✔ returns 409 for concurrent duplicate requests, exactly one succeeds (734.291205ms) ✔ returns 400 for parameter mismatch on same key (298.156402ms) ✔ Idempotency middleware (1648.201901ms) ℹ tests 3 ℹ suites 1 ℹ pass 3 ℹ fail 0 ℹ duration_ms 1689.442105
| Test | What it proves |
|---|---|
| Same key, sequential | Cached response path |
Same key, Promise.all | UNIQUE + acquisition race + in_progress while work runs |
| Same key, different amount | Parameter fingerprint → 400 |
When not to use idempotency keys
| Situation | Reason |
|---|---|
GET / PUT / DELETE etc. are already spec-idempotent | Keys add cost with no gain |
POST only mutates your DB, no payments/email/webhooks | A business unique key (order_number, …) is often simpler |
Analytics / audit / high-volume append-only POST /events | You usually want every event, not dedupe |
| Retry window longer than your key TTL (often 24h-style) | Same key string becomes a new operation after expiry: extend TTL or dedupe by business id |
How other APIs handle idempotency keys
| Provider | Typical header | Check in docs |
|---|---|---|
| Stripe | Idempotency-Key | Status replay, TTL, mismatch vs in-flight |
| PayPal | PayPal-Request-Id | Omitting header may still process duplicates |
| Adyen | Idempotency-Key | Length limits, replay rules vs Stripe |
- Conditional requests (
ETag/If-Match) on some APIs (e.g. GitHub REST) solve concurrency, not the same problem as paymentPOSTkeys. Same family of “safe retries,” different tool. - IETF
Idempotency-Keyheader draft. Until it is widely adopted, assert your provider’s contract in tests, not a generic table row.

