Two bugs cause most of the Razorpay problems we are asked to fix in existing integrations: an order marked paid because the browser said so, and a payment credited twice because the same webhook arrived twice. Both are avoidable with two checks Razorpay already provides and some discipline in how your server records events.
This guide explains the payment signature and the webhook signature, the mistakes that make verification fail or quietly pass, how to make webhook processing idempotent, which events to subscribe to, and a test checklist to run before you switch to live keys.
Quick answer: Razorpay gives you two checks. After checkout, your server recomputes an HMAC-SHA256 of the order ID and payment ID, joined by a pipe, using your API key secret, and compares it with the returned razorpay_signature. For webhooks, it computes HMAC-SHA256 over the raw request body with your webhook secret and compares it with the X-Razorpay-Signature header. Then each event is processed exactly once.
The two signatures and what each one proves
The two checks are often confused, so it helps to see them side by side:
| Check | What you hash | Secret used | What it proves |
|---|---|---|---|
| Payment signature | razorpay_order_id + "|" + razorpay_payment_id | Your API key secret | The payment ID sent by the browser really belongs to the order your server created |
| Webhook signature | The raw, unmodified request body | The webhook secret you set when creating the webhook in the Dashboard | The event came from Razorpay and was not altered in transit |
The payment signature protects the moment the customer returns from checkout. The webhook covers everything after that: captures, failures, refunds and subscription charges, including cases where the customer closed the tab before your success page loaded. You need both. Razorpay's official SDKs include helpers for each check (in PHP, verifyPaymentSignature and verifyWebhookSignature on the utility class), and using them avoids subtle mistakes.
A valid signature proves authenticity, not correctness. After verifying, compare the payment's amount and currency with the order in your database, and fetch the payment from the API if anything looks unusual.
Five mistakes that break verification
- Hashing parsed JSON instead of the raw body. Frameworks such as Express and Laravel parse the body before your code runs. If you re-encode the parsed object, spacing or key order can change and the signature will not match. Read the raw body instead:
$request->getContent()in Laravel, orexpress.raw()on the webhook route in Node.js. - Mixing up the secrets. The payment signature uses the API key secret; the webhook signature uses the separate webhook secret. Teams that use one where the other belongs often "fix" the failure by switching the check off.
- Trusting the browser. The checkout handler runs in the customer's browser. If your server marks the order paid on that callback without checking the signature, anyone with developer tools can fake a success.
- Leaking secrets. The key secret belongs on the server only, never in JavaScript, a mobile app or a public Git repository. Keep secrets in environment variables or a secrets manager, and rotate them if they are exposed.
- Plain comparisons and slow handlers. Use a constant-time comparison such as
hash_equalsin PHP orcrypto.timingSafeEqualin Node.js. And do not send emails or generate invoices before responding to the webhook: if Razorpay does not get a timely success response, it retries, and you receive the same event again.
Designing webhook processing to be idempotent
Idempotent means that processing the same event twice has the same effect as processing it once. Razorpay can deliver an event more than once, for example when your server times out, and events can arrive out of order, such as payment.captured before payment.authorized. Design for both:
- Record every event first: Store the event ID, which Razorpay sends in the
x-razorpay-event-idheader, in a table with a unique constraint. If the insert fails because the ID already exists, return success and stop. - Make the business action unique too: Put a unique constraint on the payment ID in your payments or ledger table, so even two different events for the same payment cannot credit a wallet twice.
- Use a state machine: An order moves from created to paid to refunded, never backwards. If a late
payment.authorizedarrives after the order is already paid, ignore it. - Lock the order row: Update the order inside a database transaction with a row lock, so the browser callback and the webhook racing each other cannot both fulfil it.
- Acknowledge fast, process later: Verify, store and queue the event, return a 2xx response, and let a background worker do the fulfilment.
- Keep a safety net: A scheduled job that fetches the status of orders still pending after a set time catches any webhook that never arrived.
Which events to subscribe to
Subscribe only to what you act on. A typical e-commerce or SaaS setup uses:
order.paidorpayment.captured: the trigger for fulfilment. Pick one as the single source of truth to avoid handling the same payment twice.payment.failed: to offer the customer a retry and close abandoned attempts.refund.processedandrefund.failed: to update the order and inform the customer.- Subscription events: such as
subscription.chargedandsubscription.halted, if you use Razorpay Subscriptions.
Also check your capture setting. If automatic capture is off, an authorised payment must be captured through the API. Payments left uncaptured are refunded to the customer after a period, which can look like a lost sale.
Test checklist before going live
| Test | Expected result |
|---|---|
| Send a webhook with one character of the body changed | Rejected with a 4xx response and logged |
| Replay the same event twice | One fulfilment and one ledger entry |
| Pay, then close the browser before the success page loads | Order still marked paid through the webhook |
| Send a tampered payment ID from the browser callback | Signature check fails; order stays unpaid |
Deliver payment.captured before payment.authorized | Order ends in the paid state |
| Stop the webhook worker for a while, then restart it | Queued events are processed; pending orders are resolved by the status job |
| Rotate the webhook secret | The new secret works and the old one is rejected |
Run these in test mode with a tunnelling tool or a staging server that Razorpay can reach, then repeat a few with small live payments after switching keys. If you already have a live integration with duplicate orders or unexplained pending payments, an audit against this list usually finds the cause. Our Razorpay integration service covers both new builds and fixes to existing ones.
How EthicsComputer can help
We build and repair Razorpay integrations on your own Razorpay account, in PHP, Laravel, Node.js, WordPress or mobile apps, with both signatures verified, idempotent webhook processing, refunds and a reconciliation report. For finance teams that need every settlement matched to orders and bank credits, we also build payment reconciliation software, and our API security testing can check your payment endpoints independently.
Seeing duplicate orders or payments stuck as pending?
Tell us your stack and what is going wrong, and we will review your Razorpay verification and webhook flow.
About the author
Vinay Grover founded EthicsComputer in Rohtak, Haryana in 2018 and leads its cybersecurity, software and AI work for clients across India and abroad. EthicsComputer is ISO 9001, ISO/IEC 27001 and ISO 20000-1 certified.