Guide

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:

  1. Client to your API. An Idempotency-Key header on create-payment, capture and refund calls.
  2. Your service to the PSP. A stable key derived from your payment, so your retries do not create a second intent.
  3. 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:

TypeScript
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-op

Common 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

  1. 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.

  2. 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.com

Next, see how the money itself is recorded, in double-entry ledger.