DEV Community

Bedirhan Elibol
Bedirhan Elibol

Posted on

How do you stop double charges when the user clicks Pay twice?

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)

Collapse
 
jean_carlos_4fc0f8981eb24 profile image
Jean Carlos •

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:

  1. Decide where the key is created. If each click or each retry builds a fresh key, idempotency does nothing. It has to come from something that exists before the click: the order row you create server-side, or an id issued when the checkout page loads.
  2. A key built only from the order id has two edges. Stripe compares the parameters with the first request and returns an error if they differ, so if the cart changes after the first attempt, the retry fails instead of charging the new amount. Freeze the order once payment starts, or create a new order id when the cart changes. And keys can be pruned after at least 24 hours, after which the same key starts a new request, so check the order's status in your database before you retry an old one.
  3. Treat "a request with this key is still running" as its own case. Stripe doesn't save a result for a request that collides with one that's still executing, so the second click can get an error that is safe to retry. Show "payment in progress" and check the order status, and don't show a failure to the user.
  4. Make the thing you create once per order, not once per click. Create the PaymentIntent (or the Checkout Session) once, store its id on the order, and have every later click reuse it. Then a double click is two attempts on the same object and can't become two separate charges.
  5. Make fulfillment its own guarded step: 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.

Collapse
 
bedirhandev profile image
Bedirhan Elibol •

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.

Collapse
 
jeemmo profile image
Azeem Javed •

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.

Collapse
 
bedirhandev profile image
Bedirhan Elibol •

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.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

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.

Collapse
 
bedirhandev profile image
Bedirhan Elibol •

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.

Collapse
 
unitbuilds profile image
UnitBuilds •

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.

Collapse
 
bedirhandev profile image
Bedirhan Elibol •

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.