Idempotency in Payment Systems, Properly Explained
Why every money-moving API needs idempotency keys, how they should be scoped and stored, and the mistakes that make them useless when it matters most.
In short
A timed-out payment request is genuinely ambiguous: it may have succeeded. Idempotency keys resolve that ambiguity by making a retry return the original result rather than performing the operation again.
Consider the simplest possible failure. You send a request to charge a customer. The connection times out. Did the charge happen?
You cannot know. The request may never have arrived; it may have been processed completely with the response lost on the way back. Both look identical from where you are standing, and the two available responses are both wrong: retry and risk charging twice, or do not and risk losing a payment you promised to take.
Idempotency is the mechanism that makes this decidable.
What an idempotency key does
You attach a key to the request. The server records the key with the result of the operation. If a request arrives with a key it has already seen, it returns the stored result instead of performing the operation again.
Retrying then becomes safe by construction: the first attempt to arrive performs the work, and every subsequent one returns the same answer.
POST /api/v1/cards
Idempotency-Key: card-issue:order-8842
Content-Type: application/json
{ "initialAmount": "50.00", "cardholderEmail": "[email protected]" }
// Sent once: a card is issued.
// Sent five more times: the same card is returned. One card exists.
The mistake that makes it useless
This is the one worth internalising, because it looks correct and is exactly backwards.
// Broken. A fresh key per attempt means every retry is a new operation.
async function issueWithRetry(params) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await issueCard({ ...params, idempotencyKey: crypto.randomUUID() });
} catch (err) {
if (attempt === 2) throw err;
}
}
}
Three timeouts here can produce three cards. The key must identify the operation, not the attempt — so it is generated once, before the first try, and reused by every retry.
// Correct. One key for the operation, reused across attempts.
async function issueWithRetry(params, operationId) {
const idempotencyKey = `card-issue:${operationId}`;
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await issueCard({ ...params, idempotencyKey });
} catch (err) {
if (attempt === 2) throw err;
await sleep(2 ** attempt * 1000);
}
}
}
Choosing the key
A good key is derived from your own domain and reconstructible after a restart.
| Approach | Verdict |
|---|---|
\card-issue:${orderId}\`` | Good — stable, meaningful, reconstructible |
\payout:${payoutId}\`` | Good — one payout can only ever produce one card |
| A UUID generated once and stored | Good — as long as it really is stored before the first attempt |
| A UUID generated per call | Broken — see above |
| A hash of the request body | Risky — two genuinely separate identical operations collide |
| A timestamp | Broken — differs on every retry |
The body-hash approach deserves the warning. Two legitimately distinct operations with identical parameters — the same customer topping up $50 twice — would collide and the second would silently return the first result. Identity must come from your domain, not from the parameters.
What the server has to do
Implementing this correctly is more subtle than storing a key with a result.
- Claim the key atomically before doing the work. Two concurrent requests with the same key must not both proceed. An insert with a uniqueness constraint does this; a read-then-write does not.
- Handle the in-progress case. If a second request arrives while the first is still running, it must not start a duplicate. Returning a "request in progress" response is the usual answer.
- Store the response, not just the fact. The retry needs the same body the original returned.
- Scope keys to the account. One customer's key must not collide with another's.
- Expire them. Keys are kept for a bounded period, long enough to cover any realistic retry window.
Where idempotency does not reach
It protects one operation at one endpoint. It does not make a multi-step workflow atomic — issuing a card and then debiting your own ledger is two operations, and the second can fail after the first succeeded.
For those, the pattern is a reconciliation job rather than a bigger key: periodically compare your records against the provider's and alert on divergence. Do not auto-correct silently, because the divergence is evidence of a bug and correcting it quietly removes the evidence.
What to require of a provider
- Idempotency on every money-moving endpoint, not just some.
- A documented retention window for keys.
- A defined behaviour when the same key arrives with a different body — this should be an error, not a silent replay.
- A defined behaviour for a request arriving while the first is still in flight.
On ON5, creating a card and topping one up both require an Idempotency-Key of 8 to 128 characters, and a retry with the same key returns the original result rather than charging again. It is a required header rather than an optional one precisely because the failure it prevents is silent and expensive.
Frequently asked questions
What is an idempotency key?
A client-supplied identifier attached to a request so that repeating the request returns the original result instead of performing the operation again. It makes retrying a timed-out payment safe.
Should idempotency keys be generated per retry?
No — that defeats the mechanism entirely. One key identifies one logical operation and is reused by every retry attempt. Generate it before the first attempt and store it.
Can I use a hash of the request body as an idempotency key?
It is risky. Two genuinely separate operations with identical parameters would collide and the second would silently return the first result. Derive the key from your own domain instead.
Does idempotency make a whole workflow safe?
No. It protects a single operation at a single endpoint. Multi-step workflows still need reconciliation, comparing your records against the provider's and alerting on divergence.
Issue your first card on ON5
Fund an account with USDT or USDC and issue a branded Visa or Mastercard virtual card. The minimum is $5.
Open the dashboard