Case studies
Idempotency at every hop
How idempotency keys protect a payment at each boundary, from the client to your API, from your service to the PSP and from the PSP's webhooks back to you, and why the key row must share the payment's transaction.
An idempotent operation has the same effect whether it runs once or many times. A GET is naturally idempotent. A POST /charge is not. Payments need idempotency at every boundary, because a retry can come from the client, your service, the PSP or a webhook replay. The general rules are in idempotency. This page shows how they apply to money.
The big idea
An idempotency key is a unique ID that the caller sends with a request. The server stores the first result under that key. A retry with the same key gets the stored result, not a second execution.
Keys appear in three places:
- Client to your API. An
Idempotency-Keyheader on create-payment, capture and refund calls. - Your service to the PSP. A stable key derived from your payment, so your retries do not create a second intent.
- PSP to your webhooks. Dedupe on the event ID, because PSPs deliver at least once.
Stripe's model, widely copied, works like this. It stores the response for a key once execution begins. A retry with the same key and parameters gets the same response. A duplicate that arrives while the first is still running gets a conflict. A key reused with different parameters is an error. Keys expire after about a day.
Walking through it
This sketch shows the logic for one hop. It assumes a db.idempotency helper and a createPaymentInner function:
async function createPayment(req: CreatePaymentRequest): Promise<Payment> {
const key = req.idempotencyKey
const existing = await db.idempotency.find(key, "create_payment")
if (existing?.status === "completed") return existing.response
if (existing?.status === "processing") throw conflict(409)
await db.idempotency.insert({ key, endpoint: "create_payment", status: "processing" })
try {
const payment = await createPaymentInner(req)
await db.idempotency.complete(key, payment)
return payment
} catch (e) {
await db.idempotency.fail(key) // or keep processing with a TTL for zombie rows
throw e
}
}The key row must be written in the same database transaction as the payment row, when you can. If they are separate, a crash between them can lose the key. A retry then submits to the PSP a second time.
Here is one payment through the three hops:
Client sends: Idempotency-Key: pay_9f3a
Your service: confirm sent to PSP with key derived from pay_9f3a
First attempt: PSP authorizes $42.00, but the response times out
Client retries: Idempotency-Key: pay_9f3a → service finds the completed record
→ returns the same payment ID, no second intent created
Webhook arrives: payment_intent.succeeded, event evt_123 → recorded once
Webhook repeated: evt_123 already seen → no-opCommon mistakes
- A new key on every retry click. Keep the key for the same intent.
- Splitting the key row from the payment row. A crash can lose the key.
- Reusing a key with different parameters. The API should reject it.
- Deduping webhooks on the payload instead of the event ID. Use the stable ID the PSP gives each event.
Check yourself
-
A confirm call times out. Which key do you retry with?
Answer: the same key, so the PSP returns the earlier result instead of creating a second intent.
-
Why must the key row share the payment's transaction?
Answer: if they are separate, a crash can save the payment but lose the key, and the retry then goes to the PSP again.
Key takeaways
- Put an idempotency key on every hop: client to API, service to PSP, and PSP to webhook.
- Store the first result, and return it to every retry with the same key.
- Write the key and the payment in one transaction where possible.
- Dedupe webhooks on the event ID.
Further learning
Stripe's post on idempotency explains the model behind these keys:
Stripe: Designing robust and predictable APIs with idempotencyOnline payment processing for internet businesses. Stripe is a suite of payment APIs that powers commerce for businesses of all sizes.stripe.comNext, see how the money itself is recorded, in double-entry ledger.
Related
Idempotency and retries
Making operations safe to retry, with idempotency keys, deduplicating consumers and jittered backoff, so at-least-once delivery never means a double charge.
Case study: payment system
Moving money correctly, with payment intents, idempotency at every hop, a double-entry ledger, webhooks and reconciliation when the network lies. Start here for the payments map.
Payment intents and states
How a PSP payment intent moves through its states, why every confirm can end in an unknown outcome, and why your own status column should be the one you trust.
Distributed transactions
Keeping data consistent when one business operation spans several databases or services, using two-phase commit, sagas, the transactional outbox and reservations, and redrawing boundaries first.