Idempotency Key
An idempotency key is a client-supplied identifier that lets a server treat repeated requests as a single operation. In fintech APIs, it is what keeps retried payment requests from creating duplicate charges or duplicate ledger entries.
What an idempotency key is
An idempotency key is a unique value supplied by the API client on a write request, attached to the request body or a header (commonly Idempotency-Key). The server stores the key alongside the result of the operation. If the same key arrives again, the server returns the original result instead of re-executing the work. The result is exactly-once semantics on top of an at-least-once network.
Stripe popularized the pattern. Most modern payment platforms — Adyen, Square, Modern Treasury, Marqeta — accept idempotency keys on the same endpoints for the same reason: clients retry on timeouts, and the server cannot afford to charge twice when the customer was already charged once.
Why every fintech API needs one
A payment request that times out is ambiguous. Did the request reach the server? Did the server process it? Did the response just fail to return? Without an idempotency key, the safe-looking choice — retry — risks a duplicate charge. With an idempotency key, the retry is safe by construction: the server matches the key, sees the original transaction, and returns the same response.
Idempotency keys also protect append-only ledgers from double-writes. When a payment service receives the same request twice, it would otherwise insert two pairs of entries for one logical transaction. An idempotency check at the ledger insert path turns the second insert into a no-op that returns the original transaction id.
Implementation pattern
The standard pattern has three parts:
- Client supplies the key. Generate it once per logical operation, typically a UUIDv4 or a deterministic hash of the request payload. Reuse the key on every retry of that operation.
- Server hashes the request and stores the key. Save the idempotency key, the request fingerprint (a hash of the relevant payload fields), and the result, with a TTL of 24–72 hours.
- Server matches on subsequent requests. If the key exists and the fingerprint matches, return the stored result. If the key exists but the fingerprint differs, return a 422 — the client is misusing the key.
A small but important detail: the idempotency check must run before any side effects, including ledger writes, processor calls, or webhook emissions. Putting it after side effects defeats the purpose.
Code-shape example
A minimal Postgres-backed idempotency middleware:
// Idempotency table.
// CREATE TABLE idempotency_keys (
// key TEXT PRIMARY KEY,
// fingerprint TEXT NOT NULL,
// response_status INT NOT NULL,
// response_body JSONB NOT NULL,
// created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
// expires_at TIMESTAMPTZ NOT NULL
// );
export async function withIdempotency(
req: Request,
fingerprintFields: string[],
handler: () => Promise<Response>,
): Promise<Response> {
const key = req.headers.get("Idempotency-Key");
if (!key) return handler(); // Optional on read paths only.
const fp = fingerprintOf(await req.clone().json(), fingerprintFields);
const existing = await db.idempotencyKeys.findOne({ key });
if (existing) {
if (existing.fingerprint !== fp) {
return new Response(
JSON.stringify({ error: "idempotency_key_in_use" }),
{ status: 422 },
);
}
return new Response(JSON.stringify(existing.response_body), {
status: existing.response_status,
});
}
const res = await handler();
await db.idempotencyKeys.insert({
key,
fingerprint: fp,
response_status: res.status,
response_body: await res.clone().json(),
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
return res;
}
The handler runs only once per (key, fingerprint) pair within the TTL window. Every retry returns the cached response in O(1).
How we implement it at Dashhold
In production, a few decisions consistently pay off:
- Idempotency-as-middleware, not as scattered checks. A single middleware wraps every write route. Engineers cannot accidentally skip it on a new endpoint.
- Fingerprint the canonical fields, not the full body. Headers, request ids, and unrelated metadata change between retries; the canonical fields (
amount,currency,to_account,from_account,description) do not. Hash only those. - Store the response body and status, not just a “succeeded” flag. The retried client expects the same response. Returning
204 No Contentto the second request when the first returned201 Createdbreaks clients that parse the body. - TTL of 48 hours. Long enough for mobile retries with exponential backoff, short enough to keep the index small. After expiry, the request is genuinely treated as new — usually intentional.
- Backed by Postgres, not Redis. Idempotency is a correctness property. We pick durability over latency. Redis-backed implementations are tempting but you will regret it the first time the cache evicts a key mid-flight.
Common pitfalls
- Generating the key server-side. Defeats the entire purpose. The client must control the key for retries to work.
- Idempotency check after the database write. First retry double-writes. Always check first.
- Storing only the key, not the fingerprint. Same key with different body should reject. Without a fingerprint, you cannot tell.
- Treating webhooks as idempotent because the source emits a unique id. Webhook deliveries are at-least-once even from Stripe and Adyen. Use the source’s event id as the idempotency key when ingesting.
- Skipping idempotency on “read” endpoints that have side effects. A GET that opens a session, mints a token, or emits an event needs idempotency. The HTTP method does not determine whether you need it; side effects do.
- Letting the idempotency table grow forever. The TTL is enforced by a scheduled cleanup, not by the application. Without it, the table outgrows the database within a year.
Where it lives in a fintech platform
In a well-engineered platform, idempotency is enforced at the API gateway and again at the ledger boundary. Gateway-level checks short-circuit requests fast. Ledger-level checks are the structural backstop, ensuring no path — including admin scripts, replay tools, or fan-out workers — can write the same logical transaction twice. The studio’s fintech development team treats idempotency as a Day 1 architectural constraint on payment APIs, alongside append-only ledgers and double-entry semantics.
See also
- Append-only ledger — why ledger writes must be guarded by idempotency
- Payment platform — where idempotency lives in the platform stack
- Double-entry ledger — the balanced-pair structure being protected