Razorpay Webhooks Fire Before order_status_completed — and What That Costs

Razorpay Webhooks Fire Before order_status_completed — and What That Costs

Six months ago we glossed over a small detail about Razorpay's webhook timing on WooCommerce builds. That gloss has cost real money since — duplicate GST invoice numbers, a flash-sale incident on 22 November, and a CA bill we'd rather not have paid. Here is the longer version, including the mitigation that made things worse.

15 June 2026·8 min read·Ritwik Bhattacharya

Last time we wrote about the GST invoicing break on Razorpay builds, we glossed over the actual race — payment.captured fires before WooCommerce flips the order to completed. That gloss has caused us trouble in the six months since, so here is the longer version. It's also the post we wish we had written first, because at least three readers wrote in asking variants of the same question, and we kept replying with half-formed email threads that nobody should have to read.

The short version of the argument, if you want to stop here: this is not a Razorpay bug and it is not a WooCommerce bug. It is the shape of the problem when you bolt an event-driven payment gateway onto a stateful order lifecycle and pretend the two are talking about the same thing. Treating it as a retry-logic problem — which is what most of the Stack Overflow answers do — is what causes duplicate invoice numbers, GSTR-1 mismatches at month-end, and inventory that quietly drifts off true.

What the race actually looks like in the logs

22 November. D2C skincare client, one of those mid-size brands that does most of its revenue in a four-hour window on sale days. They ran a flash sale starting 19:30 IST. Between 19:40 and 20:10 they took 41 orders. Seven of those orders ended up with duplicate sequential invoice numbers. Not seven invoices — fourteen. Paired duplicates, same number, two orders each.

Riya pulled the timeline the next morning by lining up nginx access logs against the Razorpay webhook delivery log in the dashboard. The pattern was consistent across all seven pairs. Razorpay's payment.captured webhook lands at our endpoint within roughly 200–400ms of the customer hitting the success redirect. Our handler fires, validates the signature, and calls the invoice generator. WooCommerce, meanwhile, is still inside the woocommerce_order_status_processing transition — the order hasn't moved to completed yet, and on this client's setup the move from processing to completed was being triggered by the same webhook handler, downstream of the invoice call. So the invoice was being issued against an order that, from WooCommerce's point of view, was mid-transition. Two near-simultaneous webhook calls (a payment.captured followed by an order.paid, which Razorpay sends as a separate event a beat later) were both racing to invoke the same invoice generator before either had finished writing state.

The invoice counter at the time was a row in wp_options read with get_option, incremented, written back with update_option. No row lock. No transaction. Two requests reading the same value, both writing back the same value plus one. Classic.

The cost of that night, just on the accounting side, was about ₹18,000 in CA time to reconcile the November GSTR-1 filing. Not catastrophic. But it was the second time we had paid a variant of that bill, and the first time we had a clear enough log trail to actually see what was happening.

The thing we keep coming back to — and this is the bit the Razorpay docs don't quite say out loud — is that payment.captured is a notification about a payment, not about an order. Razorpay doesn't know or care about WooCommerce's order state machine. From Razorpay's side the event is fact-of-the-world — money moved, end of story. WooCommerce, meanwhile, is still walking the order through woocommerce_order_status_pendingprocessingcompleted, and any code that needs a stable post-transition state has to wait for that walk to finish. The webhook handler doesn't wait, because nothing tells it to.

The fix that seemed obvious and made it worse

The first thing we tried was the embarrassing thing. We added a sleep(2) at the top of the webhook handler. Two seconds. Enough, we thought, for WooCommerce to finish its state transition before the invoice code ran. It worked in staging. It worked in our load test, which in retrospect was the problem — we tested with 20 concurrent orders, not 200, and we tested with a healthy Razorpay endpoint, not a Razorpay endpoint that thought our handler was slow.

Because here's the thing about sleep(2) in a webhook handler. Razorpay's delivery contract expects a 2xx response within, give or take, 5 seconds (the exact figure has moved at least once in the time we've been integrating against them — last we checked it was 5, it may be different now). If you don't respond in time, Razorpay retries. Aggressively. And during a flash sale, when your PHP-FPM pool is already saturated and individual requests are taking 1.5–3 seconds to do real work, adding two more seconds at the top means a meaningful fraction of webhook calls time out from Razorpay's perspective. Razorpay then retries the same event. Sometimes twice. Sometimes the retry arrives while the original is still sleeping. Now you have two handlers, both sleeping, both about to wake up and race for the same counter — the exact thing the sleep was supposed to prevent, now happening twice as often.

We caught this in a sale window about three weeks after we deployed the sleep. Different client, similar pattern, fewer orders but a higher duplicate rate. Pulled it out the same afternoon. Riya has not let us forget it.

(Aside: we still occasionally see advice on Indian developer forums that suggests adding a sleep or a delay to "let the order settle". Please don't. The webhook contract is not a place to absorb timing slack. If you need timing slack you need a queue, which is a different conversation.)

Where we landed, and what we're still unsure about

The current shape, which has held for about four months across three clients now, has two pieces.

First piece: an idempotency table. A single MySQL table keyed on razorpay_payment_id, with a database-level UNIQUE constraint on that column. The webhook handler's first real action — after signature verification, before anything else — is an INSERT into this table. If the insert fails with a duplicate-key error, the handler returns 200 OK and does nothing else. If it succeeds, the handler proceeds. This means Razorpay can retry as many times as it likes; only the first delivery does any work. It also means two simultaneous deliveries of the same event race at the database level, which is the only place that race can be resolved correctly, because the database is the only thing that has a real lock.

Second piece, and this is the part the client's CX team did not love: invoice generation no longer happens in the webhook handler at all. The webhook handler now does two things — record the payment in the idempotency table, and call $order->payment_complete(), which is what triggers WooCommerce's own transition to processing or completed depending on the product type. Invoice generation is hooked to woocommerce_order_status_completed and pushed onto a deferred queue (Action Scheduler, in our case, because it's already in WooCommerce core and we did not want another moving part). The queue worker runs every 30 seconds and processes pending invoice jobs serially per-site, which means the counter increments are naturally single-threaded.

The honest tradeoff: the customer's emailed invoice now arrives 30–90 seconds after the payment confirmation page, instead of within a few seconds. The CX lead at the skincare client pushed back hard on this. Her argument was that customers refresh their inbox immediately after checkout and a missing invoice generates support tickets. Our argument was that a wrong invoice generates a CA bill and a GSTR-1 amendment. We compromised on a "your invoice is being prepared, it will reach you shortly" line on the thank-you page and a soft SLA of 90 seconds. We have not had a duplicate invoice since. We have had, by Riya's count, four or five support tickets where the customer asked where their invoice was — manageable.

What we are still unsure about, and the reason we are writing this rather than calling it solved, is the silent-failure case. Razorpay marks a payment as captured. The webhook never reaches us — maybe our endpoint was down for thirty seconds during a deploy, maybe Cloudflare ate it, maybe the delivery queue on Razorpay's side hiccupped, we have seen all three. Razorpay does retry, but the retry policy isn't infinite and we have on at least two occasions seen a payment that was captured on Razorpay's dashboard with no corresponding webhook delivery in the delivery log at all. We have a cron-based reconciliation that hits the Razorpay /payments API every four hours and pulls anything we don't have locally. Four hours is too long. By the time the cron picks it up, the customer has usually raised a ticket. Running it every fifteen minutes would help but the API has rate limits and we have not done the maths properly on a busy sale day. Running a polling worker continuously is the obvious next move but it feels wrong to poll a system that has a webhook.

There's a related thing about how Action Scheduler behaves under load when the wp-cron is also under pressure — actually, let me not get into that here, it's a different post and the answer involves moving the scheduler off wp-cron entirely.

If someone has built a clean reconciliation path for the silent-failure case, send it our way. We'd read it.

Ritwik Bhattacharya

Written by

Ritwik Bhattacharya

Principal Engineer

Eleven years in front-end engineering, based in Gurgaon. Builds in Next.js and Node, reluctantly maintains the WordPress installs we inherit from older clients. Writes here about the engineering side of the practice — frameworks, performance budgets, and the bugs that keep coming back (usually hydration).

                    

Ready to build something that works?

Get a free website mockup for your business. No commitment, no fluff — just what it could look like.

Call us — picked up by a human+91 88820 82228WhatsApp us — replies in minutes+91 88820 82228