Idempotency Keys in Distributed APIs: Preventing Double Charges and Phantom Orders
Practical architecture for implementing idempotency keys in high-scale APIs, including schema design, race-free request handling, TTL strategy, and failure-mode recovery patterns with TypeScript examples.
If your API handles money, inventory, provisioning, or anything else that cannot happen twice, retries are dangerous without idempotency.
Clients retry because networks fail. Mobile apps retry when users tap twice. Proxies retry on timeout. Background jobs retry after crashes. If your POST endpoint is not idempotent, one user action can create two charges, two orders, or two resources.
Idempotency keys are the production-grade fix: the client sends a unique key, and your API guarantees that repeated requests with the same key produce one logical operation.
What Idempotency Actually Guarantees
For a given (tenant, endpoint, idempotency_key) tuple:
- The first valid request executes the business operation.
- Retries with the same key return the exact same logical result.
- Concurrent duplicates do not execute the operation twice.
It does not mean different keys with the same payload are deduplicated. It also does not replace database constraints or transaction safety.
Data Model That Holds Up Under Load
A durable idempotency record needs enough state to survive crashes and race conditions.
interface IdempotencyRecord {
tenantId: string;
endpoint: string; // e.g. POST /v1/payments
key: string; // client-provided UUID
requestHash: string; // hash(method + path + canonical body)
status: "in_progress" | "succeeded" | "failed";
responseCode: number | null;
responseBody: string | null; // stored canonical response
resourceType: string | null; // e.g. "payment"
resourceId: string | null; // e.g. pay_123
lockedUntil: Date | null; // safety lease for crashed workers
createdAt: Date;
expiresAt: Date;
}
Use a unique index on (tenant_id, endpoint, key). That is the hard stop preventing duplicate inserts during concurrent retries.
Also store requestHash. If a client accidentally reuses a key with a different payload, return 409 Conflict. Silent acceptance here creates impossible debugging.
Race-Free Request Flow
Most failures come from getting this order wrong.
- Begin transaction.
- Attempt insert idempotency record as
in_progress. - If insert succeeds, current request owns execution.
- If duplicate key exists:
- hash mismatch ->
409 Conflict - existing
succeeded-> replay saved response - existing
in_progress-> return409or202with retry hint
- hash mismatch ->
- Owner executes business logic.
- Persist canonical response into idempotency record.
- Commit.
Postgres pattern
CREATE UNIQUE INDEX ux_idempotency
ON idempotency_records (tenant_id, endpoint, key);
async function handleCreatePayment(req: Request): Promise<Response> {
const key = req.headers.get("Idempotency-Key");
if (!key) return json({ error: "Missing Idempotency-Key" }, 400);
const tenantId = req.auth.tenantId;
const endpoint = "POST /v1/payments";
const hash = canonicalRequestHash(req.method, req.url, await req.text());
return db.tx(async (tx) => {
const inserted = await tx.idempotency.tryInsert({
tenantId,
endpoint,
key,
requestHash: hash,
status: "in_progress",
lockedUntil: new Date(Date.now() + 30_000),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
if (!inserted) {
const existing = await tx.idempotency.get(tenantId, endpoint, key);
if (!existing) return json({ error: "Retry" }, 409);
if (existing.requestHash !== hash) {
return json({ error: "Key reused with different payload" }, 409);
}
if (existing.status === "succeeded") {
return new Response(existing.responseBody!, {
status: existing.responseCode!,
headers: { "Content-Type": "application/json", "Idempotent-Replay": "true" },
});
}
return json({ error: "Request already in progress" }, 409);
}
const payment = await tx.payments.create({
tenantId,
amount: 4999,
currency: "USD",
});
const body = JSON.stringify({ id: payment.id, status: "confirmed" });
await tx.idempotency.markSucceeded({
tenantId,
endpoint,
key,
responseCode: 201,
responseBody: body,
resourceType: "payment",
resourceId: payment.id,
});
return new Response(body, { status: 201, headers: { "Content-Type": "application/json" } });
});
}
The Two Common Bugs
1) Writing idempotency record after business logic
If payment creation happens before the key is stored, a crash between those steps allows duplicate charges on retry.
2) Not storing the original response
If replayed requests recompute state, clients can observe inconsistent responses. Save and replay the original response payload and code.
Handling Crashes and Stuck in_progress Records
A request can die mid-flight after acquiring ownership. Without recovery, key stays blocked forever.
Use a lease (lockedUntil) and takeover rule:
- If
in_progressandlockedUntilis in the past, a new retry may reclaim ownership. - Reclaim only with compare-and-swap in SQL (
WHERE locked_until < now()).
This prevents zombie locks while avoiding two workers owning the same key.
TTL Strategy: How Long Should Keys Live?
For payment/order APIs: 24-72 hours is practical.
- Too short: late retries create duplicates.
- Too long: storage grows and key collisions become support issues.
Use partitioned cleanup jobs and metrics on expired key volume.
Multi-Region Considerations
If writes happen in multiple regions with eventual consistency, duplicate execution can still occur before replica convergence.
Mitigations:
- Route same tenant+key to a single home region.
- Or use globally consistent datastore for idempotency table.
- Keep hard uniqueness at payment provider side too (defense in depth).
Never rely on cache-only idempotency for money movement.
Observability You Need From Day 1
Track these metrics:
idempotency.duplicate_hit_rateidempotency.hash_mismatch_countidempotency.in_progress_timeout_countidempotency.replay_latency_ms
Log fields:
tenant_id,endpoint,idempotency_key,request_hash,status_transition
Without this, duplicate incidents turn into guesswork.
Contract for API Consumers
Document this clearly:
- Send a UUIDv4 in
Idempotency-Keyfor every non-idempotent POST. - Reuse the same key only when retrying the exact same request.
- Treat
Idempotent-Replay: trueas normal success. - Rotate to a new key for new operations.
Good client guidance reduces bad retries more than backend heroics.
Closing
Idempotency keys are not optional polish. They are core reliability infrastructure for distributed APIs. The winning pattern is simple but strict: unique key constraint, request hash validation, in-progress ownership, canonical response replay, and lease-based recovery.
Get those right and retries become safe instead of expensive incidents.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.