How to stop charging customers twice with Stripe
A double charge rarely comes from one big bug. It comes from three ordinary things: a customer clicks Pay twice, your server retries a request that timed out but actually went through, or Stripe delivers the same webhook more than once and your code fulfils the order again. Each is easy to miss in review because every line looks correct. Here is how to close all three.
1. Send an idempotency key with every payment request
Stripe accepts an Idempotency-Key header on POST requests. If the same key arrives again, Stripe returns the first result instead of creating a second charge. Derive the key from something that identifies this one purchase, such as the order ID, so a retry or a second click sends the same key.
const intent = await stripe.paymentIntents.create({
amount: order.totalCents,
currency: 'usd',
customer: order.customerId,
});const intent = await stripe.paymentIntents.create(
{ amount: order.totalCents, currency: 'usd', customer: order.customerId },
{ idempotencyKey: `order-${order.id}-payment` },
);Stripe keeps keys for at least 24 hours. If the customer changes the cart, the order should get a new ID or a new key, because reusing a key with different parameters returns an error.
2. Disable the pay button while the request runs
The idempotency key protects the server. The button protects the customer from waiting on two spinners and from the case where the key was built from something that changes between clicks.
const [paying, setPaying] = useState(false);
async function pay() {
if (paying) return;
setPaying(true);
try {
await confirmPayment();
} finally {
setPaying(false);
}
}
<button onClick={pay} disabled={paying}>{paying ? 'Processing…' : 'Pay'}</button>3. Handle each webhook event once
Stripe may send the same event more than once, for example when your endpoint answered slowly the first time. If the handler ships the order or adds credits on every delivery, the customer is not charged twice but you give the goods away twice, or send two receipts. Store the event ID and skip events you have already seen.
-- once
create table processed_stripe_events (id text primary key, processed_at timestamptz default now());
// in the webhook handler, after verifying the signature
const inserted = await db.query(
'insert into processed_stripe_events (id) values ($1) on conflict do nothing returning id',
[event.id],
);
if (inserted.rowCount === 0) return new Response('already handled', { status: 200 });
await fulfil(event);Test it before you ship
checkout.session.completed or payment_intent.succeeded event from the Stripe dashboard (or with stripe events resend). The order should be fulfilled once and the second delivery should answer 200 without doing anything.How Zelsis catches this
Zelsis checks each pull request for payment calls sent without an idempotency key and for submit buttons that stay clickable while their request runs, and shows the file and line with a fix.
- FINTECH-03Missing Idempotency-Key on a payment mutation
- UI-INTERACT-11Button that can submit the same mutation twice
Check your own repo
Public repositories scan free. Paste a GitHub URL on the dashboard and every finding comes with the file, the line and a fix.
Scan a repository free