Designing idempotent money-adjacent APIs
Retries are not optional on flaky networks. In money-adjacent systems, naive retries are how you double-charge someone.
This post is a practical walkthrough of the pattern we reach for when an operation must happen exactly once from the business’s point of view — even if the client submits it three times.
The failure mode
A client times out waiting for POST /transfers. It retries. Without protection, the server happily creates a second transfer. Support gets a ticket. Finance gets a reconciliation nightmare.
The fix is not “tell clients not to retry.” The fix is idempotency as a protocol between client and server.
A minimal contract
The client sends an Idempotency-Key header. The server stores the key with the request fingerprint and the response. Replays of the same key return the same outcome.
type IdempotentRecord = {
key: string;
requestHash: string;
status: 'in_progress' | 'completed' | 'failed';
responseStatus: number;
responseBody: unknown;
createdAt: string;
};
async function handleTransfer(
key: string,
body: TransferRequest,
store: IdempotencyStore,
): Promise<Response> {
const requestHash = hashRequest(body);
const existing = await store.get(key);
if (existing) {
if (existing.requestHash !== requestHash) {
return Response.json(
{ error: 'idempotency_key_reuse_mismatch' },
{ status: 409 },
);
}
if (existing.status === 'in_progress') {
return Response.json({ error: 'request_in_progress' }, { status: 409 });
}
return Response.json(existing.responseBody, {
status: existing.responseStatus,
});
}
await store.put({
key,
requestHash,
status: 'in_progress',
responseStatus: 0,
responseBody: null,
createdAt: new Date().toISOString(),
});
try {
const result = await createTransfer(body);
await store.complete(key, 201, result);
return Response.json(result, { status: 201 });
} catch (err) {
await store.fail(key, 500, { error: 'transfer_failed' });
throw err;
}
}A few details worth getting right:
- Hash the request body — reusing a key with different payloads should 409, not silently apply the first request’s logic to new data.
- Reserve the key before side effects — otherwise two concurrent retries can both pass the “no record” check.
- TTL the store — keys should expire on a business-relevant horizon (days, not forever).
Where this sits in the stack
Idempotency is usually implemented at the API edge, but the guarantee has to reach the ledger or payment rail. If your database write is not unique on the business key, the HTTP layer alone will not save you.
CREATE UNIQUE INDEX transfers_idempotency_key_uidx
ON transfers (idempotency_key)
WHERE idempotency_key IS NOT NULL;Pair the application store (for replayable responses) with a uniqueness constraint (for the durable business record).
Testing the ugly paths
Happy-path tests are cheap. The tests that matter:
- Concurrent retries with the same key
- Retry after a crash mid-
in_progress - Key reuse with a mutated body
- Expiry and legitimate re-use after TTL
Why this matters for the business
Double execution is not a “backend bug” — it is a customer-trust and ledger-integrity event. Idempotency keys are how you make retries safe enough to be the default, which is exactly what unreliable networks require. Shipping without this pattern means your incident rate scales with traffic and client aggressiveness.
Closing
Treat idempotency as part of the product contract for any money-adjacent write. Document the header, enforce the fingerprint, and make sure the durable store agrees. Architecture that ignores retry reality is architecture that will eventually reconcile in a spreadsheet.