Overview
A user clicks "Pay now." The request reaches your server, charges the card, and then the connection drops before the response gets back. The user clicks again. They get charged twice. You spend the next week refunding and apologizing.
This is not a rare edge case. It's a phone dropping Wi-Fi, a load balancer timing out, a client library retrying on its own. Every payment API in the world solves it the same way, and every API that handles state-changing operations should too.
The pattern in one sentence
The client sends a unique key with the request. If the server sees the same key again, it returns the original response instead of performing the operation a second time.
POST /api/payments
Idempotency-Key: 4c8a3e2b-1f9d-4e5a-9b6c-8d7e6f5a4b3c
Content-Type: application/json
{
"amount": 2500,
"currency": "usd",
"customer_id": "cus_abc123"
}
The client generates a UUID, sends it with the request, and if anything goes wrong — timeout, connection drop, retry logic — it sends the same key again. The server sees the key, recognizes it, and returns the stored response from the first execution.
What the server needs to store
| Field | Purpose |
|---|---|
| Idempotency key | The unique identifier from the client |
| Request fingerprint | Hash of the request body — detects key reuse with different data |
| Response status | To return the same status code |
| Response body | To return the same response |
| State | in_progress or completed — handles concurrent requests |
| Created at | For cleanup of old keys |
The request fingerprint is what catches the case where a client reuses a key with different data. That's a bug on the client side, and returning a 422 is better than silently doing the wrong thing.
Implementation in Node.js
import crypto from "crypto";
import express from "express";
const app = express();
app.use(express.json());
// In production this is Redis or a Database table
const idempotencyStore = new Map();
function fingerprint(body) {
return crypto
.createHash("sha256")
.update(JSON.stringify(body))
.digest("hex");
}
async function idempotency(req, res, next) {
const key = req.headers["idempotency-key"];
if (!key) {
// Idempotency is optional for GET, required for mutations
if (req.method !== "GET") {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
return next();
}
const existing = await idempotencyStore.get(key);
if (existing) {
// Same key, same body — replay the response
if (existing.fingerprint === fingerprint(req.body)) {
if (existing.state === "in_progress") {
return res.status(409).json({ error: "Request in progress" });
}
return res
.status(existing.status)
.set("Idempotency-Replayed", "true")
.json(existing.body);
}
// Same key, different body — client bug
return res.status(422).json({
error: "Idempotency key reused with different request body",
});
}
// Mark in progress before processing
await idempotencyStore.set(key, {
state: "in_progress",
fingerprint: fingerprint(req.body),
createdAt: Date.now(),
});
// Intercept the response
const originalJson = res.json.bind(res);
res.json = (body) => {
idempotencyStore.set(key, {
state: "completed",
fingerprint: fingerprint(req.body),
status: res.statusCode,
body,
createdAt: Date.now(),
});
return originalJson(body);
};
next();
}
app.post("/api/payments", idempotency, async (req, res) => {
const charge = await stripe.charges.create({
amount: req.body.amount,
currency: req.body.currency,
customer: req.body.customer_id,
});
res.status(201).json({ charge_id: charge.id });
});
Three details in the middleware that matter:
The in_progress state. If two requests with the same key arrive simultaneously, the second one sees in_progress and returns 409. Without this, both could charge.
The response interception. Overriding res.json is a bit hacky, but it's the cleanest way to capture the actual response without changing every handler. In production you'd want to handle res.send and res.end too.
The fingerprint check. Catching key reuse with different bodies prevents a class of subtle bugs where the client generates a key once and uses it for multiple operations.
Expiring keys
Idempotency keys don't need to live forever. Stripe expires them after 24 hours, which is a reasonable default — it's long enough to cover any realistic retry window, and short enough that the storage doesn't grow forever.
-- Postgres cleanup job
DELETE FROM idempotency_keys
WHERE created_at < NOW() - INTERVAL '24 hours';
Or with Redis, just set a TTL when you write the key:
await redis.set(key, JSON.stringify(record), "EX", 86400);
Database schema for a real implementation
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
fingerprint text NOT NULL,
state text NOT NULL CHECK (state IN ('in_progress', 'completed')),
status_code integer,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_idempotency_created_at ON idempotency_keys (created_at);
For high-traffic systems, put this in Redis instead of the primary database. The keys are short-lived, the reads are hot, and there's no reason to burden your main database with them.
If you use Postgres, do the insert in the same transaction as the actual operation when possible:
BEGIN;
INSERT INTO idempotency_keys (key, fingerprint, state)
VALUES ($1, $2, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- If no row returned, the key exists — read and return the stored response
INSERT INTO payments (customer_id, amount) VALUES ($3, $4);
UPDATE idempotency_keys SET state = 'completed', status_code = 201, response_body = $5
WHERE key = $1;
COMMIT;
The single transaction means either both the payment and the idempotency record are committed, or neither is. If the process crashes mid-transaction, nothing happened and the client can retry safely.
What not to do
| Anti-pattern | Why it fails |
|---|---|
| Store only the key, not the response | Can't replay the response on retry |
| Store the response but not the fingerprint | Can't detect key reuse with different data |
No in_progress state | Concurrent requests both execute |
| Client-generated keys based on timestamp | Two clients in the same millisecond collide |
| Keys based on a hash of the request | Legitimate repeat requests (buying the same item twice) collapse to one |
The last two are subtler. If the client generates a key by hashing the request, two genuine purchases of the same item at the same price look like retries of one purchase. The key has to be generated per attempt, not derived from the content.
The client side
import { v4 as uuid } from "uuid";
async function createPayment(data, attempt = 1) {
const idempotencyKey = uuid();
try {
const response = await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(data),
});
if (response.status === 409) {
// Another request with the same key is in progress; wait and retry
if (attempt < 5) {
await new Promise(r => setTimeout(r, 1000));
return createPayment(data, attempt + 1);
}
throw new Error("Payment already in progress");
}
if (!response.ok) {
throw new Error(`Payment failed: ${response.status}`);
}
return response.json();
} catch (err) {
// Network error — retry with the SAME key
if (attempt < 3) {
return createPayment(data, attempt + 1);
}
throw err;
}
}
The bug I've written in the past: generating a new UUID on each retry. That defeats the whole point, because the server sees a different key each time and treats it as a new request. The key has to be captured before the retry loop, not inside it.
// Correct
async function createPayment(data) {
const idempotencyKey = uuid(); // generated once, outside the loop
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch("/api/payments", {
method: "POST",
headers: { "Idempotency-Key": idempotencyKey },
body: JSON.stringify(data),
});
if (response.ok) return response.json();
} catch (err) {
if (attempt === 2) throw err;
}
}
}
When you need this
Any endpoint that:
- Charges money or moves funds
- Creates resources where duplicates are bad (orders, subscriptions)
- Sends messages or emails
- Triggers external side effects (webhooks, third-party API calls)
For read endpoints, it's unnecessary. For internal APIs where you control both sides, you can skip it and rely on the caller not retrying. For anything public-facing that changes state, it's the difference between a good API and a support queue.
Stripe, Square, PayPal, Adyen, and every other payment API document this feature. If you're building something that handles money or important state, you should implement it too.
