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
const intent = await stripe.paymentIntents.create(
{ amount: order.totalMinor, currency: order.currency, payment_method: body.paymentMethodId, confirm: true,
metadata: { order_id: order.id } },
{ idempotencyKey: `pay:${order.id}:${order.paymentAttempt}` },
);
CREATE UNIQUE INDEX one_successful_payment_per_order
ON payments (order_id) WHERE status = 'succeeded';
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.