I keep finding the same pattern in checkout code. The Pay button calls fetch('/api/checkout', { method: 'POST' }), the button stays enabled, and nothing in the handler checks whether a request is already running. On a slow connection people click twice all the time, and the second click sends a second charge.
What I do now: a pending flag with an early return, the button disabled while it's set, and an idempotency key on the Stripe call built from the order id. Webhooks get a unique constraint on the event id.
What do you do? Do you rely on the button state, the server, or both? Has anyone been bitten by a retry after a timeout where the first charge had actually gone through?
Top comments (8)
The thread already covers the main point (trust the server, same idempotency key on retries). A few things that tend to go wrong after that, going by Stripe's docs:
UPDATE orders SET status='paid' WHERE id=$1 AND status='pending', and only ship or grant access if a row changed. Stripe can deliver the same event twice and doesn't guarantee order, so dedupe on the event id and don't depend on arrival order.To test it: throttle the network in devtools, fire two POSTs at the same time from a script, and assert you end up with one charge in test mode. Add a case where the response is dropped after Stripe has already processed the charge, since that's the "timeout but it went through" case you asked about.
Thanks, this covers what most write-ups skip. The cart-changed case is the one I've seen catch people out: the retry returns an error, the frontend shows it as a failed payment, and the user tries again with a new key. Freezing the order once payment starts is the cleanest fix I know.
Both, but I only trust the server. On a UK report checkout I run, the button lock stopped the obvious double clicks, but the real bugs were server-side: two requests racing for the same order, and a success page that trusted the URL instead of the payment record. What fixed it: one order row created before payment, the idempotency key built from that order id, a database lock when marking it paid, and the webhook as the only thing that changes status.
On your timeout question: the key is to retry with the same idempotency key, never a new one. Then a timed-out first charge just returns the original result instead of charging again.
The success page trusting the URL is a great catch, and easy to miss because it passes every manual test. Making the webhook the only thing that writes status is the rule I'd put first on that list. Thanks for sharing the real setup.
Server side is the only part I would trust here. The button lock stops the obvious double click, but the idempotency key built from the order id is what saves the retry after a timeout.
Agreed. The button lock is UX, the key is the guarantee. One small addition: make sure the retry after a timeout reads the stored key from the order instead of building it again, so a code change to how keys are built can't turn one order into two charges.
state-transition and disable on click, server end gated for single use. That way the user gets immediate feedback and the server side cant process on accident, no matter how small the window was.
Both layers, yes. One thing to check on the server gate: it has to be atomic, like a conditional UPDATE or a unique constraint. A read-then-write check lets two requests that arrive together both pass it, however small the window is.