My Customer Got Charged Twice, How to Prevent Double Charges in Stripe
6 min read
A customer just told you they were charged twice for one purchase. The usual cause is that your checkout created two payments instead of one, almost always from a double-click or a retried request, and nothing in your code stopped the second one. The fix is an idempotency key on the Stripe call plus disabling the button after the first submit. Both are small changes, and together they make a duplicate charge impossible rather than merely unlikely.
Why this happens
A vibe-coded checkout tends to do exactly one thing: when the button is clicked, call Stripe to create a payment. That works perfectly in the demo, where you click once on a fast connection. Real users are messier. Someone taps a slow button twice. A phone drops signal mid-request, the browser retries, and your server runs the payment code again. A serverless function times out and the platform re-invokes it. Each of these fires your "create a payment" code more than once, and Stripe, having no reason to think otherwise, dutifully creates a separate charge each time.
The agent built the happy path you described. You never said "and make sure the same click can't charge twice," so it didn't, and the gap is invisible until a real customer on real hardware finds it.
How to check
You do not need a bug report to find this.
- In the Stripe Dashboard, open Payments and sort by customer or by amount. Two identical charges to the same customer seconds apart is the signature.
- Look at your payment-creation code (the call to
paymentIntents.create,charges.create, or a Checkout Session). If there is noidempotencyKeypassed in, you are exposed. - Look at your submit button. If it stays clickable while the request is in flight, a fast double-tap sends two requests.
- Test it directly. On your checkout page, click Pay twice as quickly as you can, or throttle your network in browser dev tools to "Slow 3G" and click once. Then check Stripe for two payments.
The fix
-
Send an idempotency key on every write to Stripe. Generate one unique key per real purchase attempt (a UUID stored with the order, or a hash of
userId + cartId + amount) and pass it to the Stripe SDK:stripe.paymentIntents.create( { amount, currency, customer }, { idempotencyKey: purchaseId } )If the same key arrives twice within 24 hours, Stripe returns the original result instead of creating a second charge. This is the real guarantee.
-
Disable the button on submit. Set it to disabled and show a spinner the moment it is clicked, and only re-enable it on error. This kills the common double-tap before it ever reaches your server.
-
Generate the key before the user can click, not inside the click handler. Create the purchase record (and its id) when the checkout page loads, so a retry reuses the same key rather than minting a fresh one.
-
Verify. Repeat the throttled-network test from above. You should now see exactly one payment in Stripe no matter how many times you click.
The trap to avoid
The tempting wrong fix is to "check if a recent charge already exists" before creating a new one. Under a real double-click the two requests run at the same instant, so both check, both see nothing, and both charge. A lookup does not protect you against the exact race that causes the problem. The idempotency key does, because Stripe enforces it on their side atomically. Reach for the key, not a check-then-create.
Where this fits
Double charges are quiet: they do not error, they do not crash, and you only hear about them when a customer is annoyed enough to email. That makes them exactly the kind of thing that should be caught before beta, not after a refund and an apology. The free Readiness Report checks whether your Stripe calls carry idempotency keys and whether your checkout guards against repeat submits, and the Finishing Pass hands you the fixes in order. While you are in the payment flow, it is worth also reading how duplicate webhook events can unlock access twice, and how to test Stripe payments properly before you go live.