DEV Community

Ravi Rai
Ravi Rai

Posted on • Originally published at buildbyravirai.com

Razorpay Webhooks Done Right (2026): Signature Verification, Idempotency, and Reconciliation

Almost every payment bug we get called in to fix has the same shape: the checkout works fine in testing, money moves, and then weeks later the founder notices orders that were paid but never fulfilled, or customers charged twice, or a refund that never reversed. The checkout was never the problem. The problem is what happens after the payment, in the webhook.

Razorpay (like every serious gateway) tells your server about payments through webhooks: server-to-server HTTP calls that fire when a payment is captured, fails, or is refunded. Get the webhook handling right and payments are boringly reliable. Get it wrong and money quietly goes missing. This is the developer-honest guide to doing it right: verifying the signature correctly, making the handler idempotent, and reconciling so nothing slips through. The basics of the Razorpay integration are covered elsewhere; this is the reliability layer on top.

Why the client-side callback is not enough

When a payment finishes, Razorpay Checkout calls a handler in the browser, and it is tempting to mark the order paid right there. Do not rely on that alone. The browser is the least reliable place in the whole flow: the customer closes the tab, the network drops, the phone dies, or the redirect just gets lost. The money still moved, but your client-side handler never ran, so your database thinks the order is unpaid.

The webhook is the source of truth. It comes from Razorpay's servers to yours, independent of the customer's browser, so it arrives even when the user vanishes after paying. Treat the client callback as a nice-to-have for UX, and the webhook (plus a server-side verify) as the real record of what happened.

How Razorpay webhooks work

You register a webhook URL in the Razorpay dashboard and pick the events you care about. When one happens, Razorpay sends an HTTP POST to that URL with a JSON body describing the event, and signs it so you can prove it really came from Razorpay. Your job is to verify that signature, act on the event exactly once, and respond quickly. Everything below is about those three things.

1. Verify the signature, on the raw body

Every webhook carries an X-Razorpay-Signature header. You verify it by computing an HMAC SHA256 of the request body using the webhook secret you set when creating the webhook, then comparing it to the header value with a constant-time comparison. If they do not match, reject the request, because anyone can POST to a public URL.

Here is the gotcha that breaks the most integrations: you must hash the exact raw request body, the bytes Razorpay sent. If your framework parses the JSON and you re-stringify it to hash, the whitespace and key order change, the hash changes, and verification fails for every webhook. In Express, capture the raw body (for example with a raw body parser on that route). In Laravel, use the raw request content, not the parsed array. This one detail is responsible for more 'webhooks are failing' tickets than anything else.

Note that this webhook secret is different from the signature you verify on the client-side payment callback, which is an HMAC of order_id + payment_id using your API key secret. Two different checks for two different moments. Keep them straight.

2. Make the handler idempotent

Webhooks are delivered at least once, not exactly once. Razorpay retries if your endpoint is slow, times out, or returns a non-2xx, and you will sometimes receive the same event two or three times. If your handler is not idempotent, those retries double-fulfill the order, send two confirmation emails, or credit a wallet twice.

The fix is to dedupe. Record the event id (Razorpay sends one) or the payment id plus event type in a processed-events table, inside the same database transaction as the work you do. Before acting, check whether you have already processed it; if so, return 200 and do nothing. Idempotency is not optional for money code, it is the difference between reliable and quietly broken.

3. Acknowledge fast, process asynchronously

Razorpay expects a quick 2xx. If you do heavy work inline (generate an invoice, call other APIs, send WhatsApp and email) the request can time out, Razorpay marks it failed, and retries, which is how you get duplicates. The reliable pattern is: verify the signature, persist the raw event, return 200 immediately, and do the real work in a background job off a queue. Your endpoint should be fast and dumb; the worker does the thinking.

4. Confirm the payment server-side

When a webhook says a payment succeeded, the well-built flow still confirms it against Razorpay before releasing anything valuable. Fetch the payment or order from the Payments API and check the status and amount match what you expect for that order. This protects you from spoofed or stale events and from edge cases like a payment that was authorized but not captured. Verify, then fulfill.

5. Reconcile so nothing slips through

Even with all of the above, a webhook can occasionally be missed (your server was down, a deploy dropped a request). So do not depend on webhooks as your only signal. Run a reconciliation job that periodically lists recent payments from the Razorpay API and compares them to your database, flagging any payment Razorpay knows about that your system marked unpaid, and vice versa. At month end, the gateway settlement report, your orders, and your payouts should all agree. Reconciliation is the safety net that catches the rare miss before a customer does.

The events that actually matter

You do not need to handle every event. The ones that carry real meaning for most businesses:

  • payment.captured (or order.paid): money is actually in. This is your fulfil-the-order trigger.
  • payment.failed: the attempt failed. Useful for analytics and for nudging the customer to retry.
  • payment.authorized: authorized but not yet captured. Only relevant if you use manual capture.
  • refund.processed and refund.created: reverse the order, the commission, and any tax you counted.
  • subscription.charged and related events: for recurring billing, each cycle's success or failure.
  • payout events (RazorpayX): if you pay vendors out, track the payout lifecycle here.

Razorpay changes and adds events over time, so confirm the exact names and payloads against their current documentation before you ship.

The bugs we see most often

  • Signature checked on the parsed body. Re-stringified JSON never matches. Hash the raw bytes.
  • No idempotency. Retries double-fulfill. Dedupe by event id in a transaction.
  • Trusting the browser callback only. Payments go missing when the user closes the tab. The webhook is the source of truth.
  • Slow handler. Inline heavy work causes timeouts, retries, and duplicates. Queue it.
  • Money stored as floats. Rounding silently loses paise. Store amounts as integers in paise.
  • Ignoring refunds and disputes. The money goes out but your books never reflect it.
  • No raw-payload log. When something breaks at 11pm you have nothing to debug. Log every raw event.

How we build payment flows

Because this is money code, we treat it like the rest of a billing system: amounts stored as integers in paise, every webhook signature-verified on the raw body, every handler idempotent and backed by a queue, and a full log of every raw event so any payment can be traced end to end. We build it on Laravel or Node.js, pair it with reconciliation against the Razorpay API, and wire it straight into invoicing and GST e-invoicing. It is the same payment core that sits under our MultiVendor CRM and billing builds.

Common questions about Razorpay webhooks

Why is my Razorpay webhook signature verification failing?

Almost always because you are hashing the parsed-and-re-stringified JSON instead of the raw request body. Capture the exact raw bytes Razorpay sent and compute the HMAC SHA256 of those with your webhook secret. Also confirm you are using the webhook secret (set on the webhook), not your API key secret, and that you compare with a constant-time function.

Do I still need webhooks if I verify the payment on the client callback?

Yes. The client callback is unreliable because the browser can close, lose network, or never fire. The webhook arrives from Razorpay's servers regardless, so it is the only dependable way to know a payment succeeded. Use the callback for instant UX and the webhook as the source of truth.

How do I stop duplicate webhook processing?

Make the handler idempotent. Store each processed event id (or payment id plus event type) and check it before acting, within the same database transaction as the work. Webhooks are delivered at least once, so retries are normal and your code has to expect repeats.

What if Razorpay never sends the webhook?

It is rare but it happens, usually when your server was briefly down. That is why you also run reconciliation: a scheduled job that pulls recent payments from the Razorpay API and compares them with your database, so any missed payment is caught and fixed automatically instead of becoming an angry customer.

Should I capture payments automatically or manually?

Auto-capture is simplest and right for most stores: the payment is captured immediately on success. Use manual capture only when you need to verify something (stock, fraud checks) before taking the money, and then handle the authorized state and capture step explicitly. Either way, fulfil only after you confirm the captured status server-side.

Honest summary

Razorpay checkout is the easy 20 percent. The reliable 80 percent is the webhook: verify the signature on the raw body, make the handler idempotent, acknowledge fast and process on a queue, treat the webhook as the source of truth, and reconcile against the API so nothing is ever silently lost. Do those five things and your payments stop being a source of late-night surprises.

If you are integrating payments, or you suspect orders are slipping through, the cost calculator gives a rough estimate for a build, or send us a WhatsApp message with your stack and what you are seeing, and we will reply within 24 hours.

Payments slipping through, or building a new checkout? We build reliable Razorpay flows in Noida on Laravel and Node.js: raw-body signature verification, idempotent webhooks, queues, reconciliation, refunds, and GST invoicing, wired into your billing and CRM.Scope a payments build

Top comments (0)