Payments & Money APIs · Charging Safely

Idempotent Charges & Double-Charge Prevention

Idempotency keys end to end — client, your API and the PSP.

The worst payment bug is charging twice. Double charges come from double clicks, client retries after timeouts, worker retries and race conditions. Defend at every hop: the client sends an Idempotency-Key per checkout attempt; your API stores the key with a request fingerprint and returns the original result on retry; your server passes a deterministic idempotency key to the PSP (derived from order ID + attempt); and a unique constraint (one successful payment per order) is the final backstop.

Defense in depth against double charges

Layer Mechanism Protects against
UI Disable button + show pending state Double clicks (not sufficient alone)
Client → your API Idempotency-Key header Retries after lost responses
Your API Idempotency store with fingerprint + locking Concurrent duplicates, key reuse with different body
Your API → PSP PSP idempotency key (deterministic) Your own retries to the PSP
Database Unique (order_id) where status='succeeded' Logic bugs
Reconciliation Daily PSP vs ledger comparison Anything that slipped through
@router.post("/v1/orders/{order_id}/pay")
async def pay(order_id: str, body: PayIn, key: str = Header(alias="Idempotency-Key")):
    async with idempotency.guard(scope=f"pay:{order_id}", key=key, fingerprint=body.fingerprint()) as slot:
        if slot.replay:
            return slot.response                                     # same result as the first call
        order = await orders.get_for_update(order_id)
        intent = stripe.PaymentIntent.create(
            amount=order.total_minor, currency=order.currency,
            payment_method=body.payment_method_id, confirm=True,
            metadata={"order_id": order_id},
            idempotency_key=f"pay:{order_id}:{order.payment_attempt}",   # deterministic per attempt
        )
        result = {"payment_intent": intent.id, "status": intent.status}
        await slot.save(200, result)
        return result

Common pitfalls

  • Random PSP idempotency keys generated per retry.
  • Idempotency only in the UI.
  • Reusing a key for a changed amount (must be rejected).

Key points

  • Idempotency at every hop
  • Deterministic PSP keys per attempt
  • Unique constraints + reconciliation as backstops

Further reading: Stripe: Idempotent requests

Practice questions

Why should the PSP idempotency key be deterministic (e.g. order ID + attempt)?

Answer

So any retry of the same attempt — even from a different worker after a crash — sends the same key and the PSP returns the original charge instead of creating a new one.

What should happen if a client reuses an idempotency key with a different amount?

Answer

Reject it (e.g. 422/409): the fingerprint differs, so it's a client bug, not a retry.

Open in the app to mark lessons complete, take notes and track your progress.