Back to blog
Article

Checkout that survives a dead network mid-payment

Checkout that survives a dead network mid-payment
S

StriveBit

4 min readE-commerce

When the connection drops after the gateway says yes

A customer in tier-3 Indore taps Pay on a Razorpay checkout. The gateway debits the UPI account. Then the phone drops to 2G, the Razorpay callback webhook reaches our server, but the browser redirect that was supposed to carry the customer back to the order-confirmation page times out. The customer sees a spinner, assumes it failed, and tries again. We now have two debits and one cart.

This is the failure mode we design around first. The payment succeeding and the storefront finding out are two different events, and the gap between them is where most duplicate orders are born.

We treat the webhook as the source of truth, not the browser redirect. Razorpay sends `payment.captured` to our server-to-server webhook endpoint. That endpoint is idempotent on `razorpay_payment_id` — we check whether we have already recorded that ID against an order before creating a fulfillment record. The redirect path, which runs through the customer's browser, only reads status. It never writes it.

The order-confirmation page does not trust the URL parameters the redirect carries. It polls an internal endpoint keyed on our order ID, which in turn reflects whatever the webhook has recorded. If the webhook has not arrived yet, the page shows "We are confirming your payment" and retries every 3 seconds for up to 90 seconds. After 90 seconds it shows a message with the order ID and a link to check status later, and we send the confirmation by SMS.

The SMS matters. A customer on a bad connection who closes the tab still gets a receipt. We send it from the webhook handler, after we have written the payment to the ledger, not from the confirmation page.

For the duplicate-payment problem, we hold the cart in a `pending_payment` state with a lock keyed on the order ID for 120 seconds after the customer taps Pay. A second Pay attempt within that window hits the same order ID and gets routed to the status-check flow instead of creating a new order. If the first payment genuinely failed — Razorpay returns `payment.failed` — we release the lock and let the customer retry.

One thing we do not do is retry the payment from the client. If the browser did not get a confirmation, the right response is to check server-side status, not to call the gateway again. Client-side retries are what create the second debit.

We also do not rely on the gateway's client SDK to tell us the payment succeeded. The SDK callback runs in the browser and can fail to fire for the same network reasons. We log it for debugging, but the webhook is what flips the order state.

The webhook endpoint itself needs to handle being called twice for the same payment. Razorpay retries a webhook if it does not get a 200 within a few seconds. Our handler returns 200 as soon as it has persisted the idempotency key, before it sends the SMS or updates the ERP. Those side effects happen on a queue. A slow SMS gateway should not cause Razorpay to retry the webhook and create a duplicate notification.

For merchants using Cashfree or PayU, the pattern is the same. The gateway names differ, the webhook payloads differ, but the architecture is identical: webhook writes status, redirect reads it, idempotency key prevents duplicates, confirmation page polls instead of trusting URL params.

The part that takes the most testing is the 90-second polling window on a genuinely dead connection. We test it by killing the network on a staging device after tapping Pay and verifying that the SMS still arrives, the order shows as paid in the admin panel, and a second Pay attempt does not create a new order. If any of those three fail, we have a bug worth fixing before shipping.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch