Getting charged twice for one order is one of the worst experiences a customer can have on a website. It's confusing, it makes support tickets pile up, and it's genuinely hard to catch in testing because it usually only happens when something goes wrong at exactly the wrong moment.
A common assumption is that adding Stripe's idempotency keys makes duplicate charges impossible. Idempotency keys are useful, and they do solve a real problem. But they don't cover every situation. This article walks through a real bug we found in an online ordering system, why idempotency keys didn't fully fix it, and what actually did.
How the Checkout Worked
Customers could pay two ways: with a card they'd saved before, or with a brand-new card.
Paying with a new card worked like this: the customer fills in their card details on a secure form (built with Stripe's Hosted Fields, so the raw card number never touches our own server). We create a "payment intent" on Stripe. Then, in the browser, we confirm that payment. Once it's confirmed, we send the result back to our server and save it along with the card details, so the customer can reuse that card next time.
Paying with a saved card was simpler. The customer picks a card they've used before, clicks Pay, and our server creates and confirms the payment in one step. No extra steps in the browser needed.
Both worked fine — until they didn't.
The Bug: One Payment, Two Charges
A customer told us they'd been charged twice for a single order. We dug in and managed to recreate the problem in three specific situations.
1. The internet drops right after payment. With a new card, there's a small gap between "Stripe confirms the payment" and "our server finds out about it." If the customer's internet cuts out during that gap, Stripe has already taken the money but our system still thinks the order is unfinished. When the customer tries again, they get charged a second time.
2. The browser closes at the wrong moment. Same gap, different cause. If the tab closes, the page crashes, or the process gets interrupted right after Stripe confirms the payment but before our server hears about it, the result is the same: our system thinks nothing happened, so the customer pays again.
3. Something breaks on our end. This one could happen with either payment method. If our server hit an error — a database problem, a bug, a bad deployment — right after Stripe confirmed the payment, the money was already taken, but our system never recorded it. Once the bug got fixed and the customer tried again, they were charged twice.
In every case, the root problem was the same: Stripe knew the payment succeeded, but our system didn't.
Why Idempotency Keys Didn't Fully Fix It
Stripe's usual advice for this kind of problem is an idempotency key — basically a unique code you attach to a request. If that exact request gets sent again, Stripe just returns the original result instead of charging the customer a second time.
That sounds like exactly what we needed, so we added it. It worked well for simple retries but our situation kept breaking it in three ways.
Different card, same key. If a customer's payment got interrupted and they came back and tried a different card, Stripe still returned the details of the original card, since the idempotency key hadn't changed. This didn't cause a duplicate charge, but it caused confusion — the receipt showed the wrong card.
Switching to a saved card. If the first attempt used a new card and the retry used a saved one, Stripe flat-out rejected the request. Its rule is simple: the same idempotency key can't be reused with different request details.
Changing the order. If the customer added or removed an item before retrying — which changes the total price — Stripe rejected that too, for the same reason: different amount, same key, not allowed.
So the idempotency key worked great for "send the exact same request again," but a customer coming back after an interruption almost never sends the exact same request. They switch cards, or they tweak their cart. That mismatch is what kept breaking things.
The Fix: A Recovery Page Instead of a Retry
Once we understood the real problem, the fix became clear: stop trying to make retries safe, and instead make sure a payment that already succeeded never gets asked for twice.
Here's the workflow we built, and it works the same way for both new and saved cards:
Step 1 — Save the payment intent right away. The moment Stripe creates a payment intent (before it's even confirmed), we save its ID in our database against that customer. This is the key change, now we have a record of the payment no matter what happens next.
Step 2 — Lock the order if something looks stuck. If a customer comes back to an order that already has a payment intent saved, we don't let them do anything risky. They can't edit the order, retry with a new card, or retry with a saved card. Instead, we send them to a recovery page.
Step 3 — Tell them exactly what happened. On that page, we show a plain message like: "Your payment of $35.00 was successfully processed, but a network issue prevented the completion of your order. Please click here to complete your order. You cannot make changes to the order." No guessing, no confusion, just a clear explanation.
Step 4 — Finish the order when they confirm. When the customer clicks through, we mark the order complete using the payment that already went through and we don't charge them again. They land on a normal order confirmation page with a thank-you message, just like any other successful checkout.
A quick note on webhooks: Stripe also offers a feature called webhooks, which notify your server directly when a payment succeeds even if the customer's browser or internet connection drops. If you're building something similar, it's worth setting up a webhook for payment_intent.succeeded alongside this recovery page. The webhook confirms the payment on the backend no matter what happens to the customer's connection; the recovery page is what the customer actually sees and interacts with. The two work well together rather than replacing each other.
Why This Worked Better
The idempotency key tries to make a repeated request safe. But our customers weren't repeating the exact same request — they were coming back to finish an order that had already been paid for, sometimes with small changes. Trying to force that into "safe retry" logic kept running into edge cases.
Saving the payment intent early and treating "paid but not finished" as its own clear state solved the problem more directly. Instead of guessing whether a retry was safe, we just checked: has this order already been paid for? If yes, don't ask for money again and help the customer finish it.
What We'd Recommend
If you're building something similar with Stripe:
- Use idempotency keys, they're still useful for safely retrying identical requests, like a simple network hiccup.
- Set up a webhook for
payment_intent.succeededso your server always finds out when a payment goes through, even if the customer's connection drops. - Save the payment intent ID in your database as early as possible, ideally right when it's created.
- Build a way to detect "payment succeeded but order isn't finished" as its own state, and handle it separately from a normal retry.
- Be upfront with the customer if something interrupted their order, a clear message builds more trust than a confusing error.
Frequently Asked Questions
Should I still use idempotency keys? Yes. They're still worth using, they protect against accidental duplicate requests caused by simple things like a network blip. They just aren't a complete solution on their own.
Is this recovery page a replacement for idempotency keys or webhooks? No. All three work together. Idempotency keys prevent accidental duplicate requests. Webhooks make sure your server always learns about a successful payment. The recovery page is what handles the customer's experience when something has already gone wrong.
Could this happen with other payment providers, not just Stripe? Yes. Any payment system where the money can be taken before your own app finishes recording it can run into this same problem.
Key Takeaways
A payment can succeed on Stripe's side while your own system still thinks it's unfinished — and that gap is what causes duplicate charges. Idempotency keys help, but they only work cleanly when a retry is truly identical to the original request, which isn't always true in real life. Saving the payment intent early, recognizing when an order is stuck in that in-between state, and giving the customer a clear way to finish (instead of pay again) closed the gap for us and stopped the duplicate charges we were seeing.
Further reading:
- Stripe Documentation — Idempotent Requests
- Stripe Documentation — Webhooks
- Stripe Documentation — Payment Intents
Discussions (0)
No comments yet. Start the discussion below!
Leave a Reply