Idempotency Keys: The Simple Trick That Prevents Duplicate Payments and Ghost Orders
You've seen it happen. A user clicks "Place Order," the network hiccups, the request times out, and now they've been charged twice. Or the charge went through but the order never appeared. Neither outcome is acceptable, and both are preventable — with idempotency keys.
What Is an Idempotency Key?
An idempotency key is a unique identifier you attach to a request so that the server can safely process it exactly once, no matter how many times the client retries. If the server receives the same key again, it returns the original response instead of executing the operation a second time.
The concept is borrowed from mathematics: an operation is idempotent if applying it multiple times produces the same result as applying it once. DELETE /users/42 is naturally idempotent — deleting an already-deleted user changes nothing. POST /orders is not — sending it twice creates two orders.
Idempotency keys make non-idempotent operations safe to retry.
How It Works in Practice
The client generates a unique key (typically a UUID v4) before sending the request. It attaches the key as a header, usually Idempotency-Key. The server stores the key and the response in a short-lived cache (24 hours is common). On retry, the server finds the key, skips execution, and returns the stored response.
Here's a payment request using this pattern in Python:
import uuid
import httpx
def charge_customer(customer_id: str, amount_cents: int) -> dict:
idempotency_key = str(uuid.uuid4()) # generate once, store for retries
headers = {
"Authorization": "Bearer sk_live_...",
"Idempotency-Key": idempotency_key,
"Content-Type": "application/json",
}
payload = {
"customer": customer_id,
"amount": amount_cents,
"currency": "usd",
}
for attempt in range(3):
try:
response = httpx.post(
"https://api.stripe.com/v1/charges",
headers=headers, # same key on every retry
json=payload,
timeout=10,
)
response.raise_for_status()
return response.json()
except (httpx.TimeoutException, httpx.ConnectError):
if attempt == 2:
raise
continue
The critical detail: the idempotency_key is generated once before the loop, not inside it. Every retry sends the identical key, so the server knows they're all the same request.
Implementing the Server Side
If you're building an API that accepts payments, bookings, or any state-changing operation, you need to store and check idempotency keys yourself.
import { randomUUID } from "crypto";
import { redis } from "./redis";
async function handleCharge(req: Request): Promise<Response> {
const key = req.headers.get("Idempotency-Key");
if (!key) {
return Response.json({ error: "Idempotency-Key header required" }, { status: 400 });
}
// Check for a cached response
const cached = await redis.get(`idem:${key}`);
if (cached) {
return Response.json(JSON.parse(cached), { status: 200 });
}
// Execute the operation
const result = await processCharge(req);
// Store result for 24 hours
await redis.set(`idem:${key}`, JSON.stringify(result), "EX", 86400);
return Response.json(result, { status: 201 });
}
A few implementation notes worth keeping in mind:
Scope keys to the user. Store the key as user:{userId}:idem:{key} to prevent one user from accidentally (or maliciously) colliding with another's key.
Don't cache errors. If the operation fails with a 500, don't store the result — let the client retry with the same key and give the operation another chance. Do cache 4xx errors, since a bad request won't succeed on retry.
Return the same status code. If the original response was 201 Created, the cached response should also be 201, not 200. Clients sometimes branch on status codes.
When to Require Idempotency Keys
Not every endpoint needs them. GET requests are already safe to repeat. Natural idempotent operations (DELETE, PUT with full resource replacement) don't need extra help. But you should require idempotency keys for:
- Payment and refund creation
- Order placement
- Email or SMS sends
- Any operation that creates a resource or triggers a side effect
Stripe, Braintree, and Adyen all require idempotency keys on charge endpoints for exactly this reason. If you're building a public API, following their lead protects your users and your data integrity.
Testing Idempotency
Before shipping, verify the behaviour explicitly in your test suite:
def test_duplicate_charge_is_ignored():
key = str(uuid.uuid4())
first = client.post("/charges", headers={"Idempotency-Key": key}, json=payload)
second = client.post("/charges", headers={"Idempotency-Key": key}, json=payload)
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["id"] == second.json()["id"] # same charge, not a new one
assert db.count_charges() == 1 # only one record created
Idempotency keys are one of those small API design choices with an outsized reliability impact. They're cheap to implement and expensive to skip — your support team (and your users) will thank you.
If you want to test idempotency key behaviour against your API without writing scaffolding from scratch, APIKumo lets you set custom headers like Idempotency-Key per-request or as collection-level defaults, so you can replay the same request with identical headers and verify your server handles duplicates correctly. It's a quick sanity check before you ship.
Top comments (0)