DEV Community

EventDock
EventDock

Posted on • Originally published at eventdock.app

How to Verify Razorpay Webhook Signatures (and Why It Is Not the Payment Signature)

Razorpay trips people up in a way most providers do not: it has two separate signatures that use the same algorithm but different keys and different inputs, and they are easy to confuse. One verifies webhooks. The other verifies a checkout payment on your success handler. Sign the wrong thing with the wrong secret and your verification fails while your code looks correct. This post shows the exact Razorpay webhook verification, the raw-body trap, and how the webhook signature differs from the payment signature.

First: Razorpay has two different signatures

Both are HMAC-SHA256 and both end up as a hex string, which is exactly why they get mixed up. But they are not interchangeable:

  • Webhook signature arrives in the X-Razorpay-Signature header on webhook requests. It is keyed with your webhook secret and computed over the raw request body.
  • Payment signature comes back as razorpay_signature in the Checkout success handler, alongside razorpay_order_id and razorpay_payment_id. It is keyed with your API key secret and computed over order_id + "|" + payment_id.

If you verify a webhook using the key secret, or verify a payment using the webhook secret, every check fails. Keep them straight and most Razorpay signature pain disappears.

Verifying the webhook signature

Per Razorpay's docs, the webhook hash is HMAC-SHA256 with your webhook secret as the key and the raw webhook body as the message, hex-encoded, delivered in X-Razorpay-Signature.

const crypto = require('crypto');

// Set when you created the webhook in the Razorpay Dashboard
const WEBHOOK_SECRET = process.env.RAZORPAY_WEBHOOK_SECRET;

function isValidRazorpayWebhook(rawBody, signatureHeader) {
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)              // the raw bytes, not a re-serialized object
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body, verify, then parse
app.post('/webhooks/razorpay',
  express.raw({ type: '*/*' }),
  (req, res) => {
    const rawBody = req.body;     // Buffer, untouched
    const signature = req.get('X-Razorpay-Signature');

    if (!isValidRazorpayWebhook(rawBody, signature)) {
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(rawBody.toString('utf8'));  // parse only after
    // ... handle event.event (payment.captured, order.paid, etc.)
    res.json({ status: 'ok' });
  });
Enter fullscreen mode Exit fullscreen mode

The comparison uses crypto.timingSafeEqual rather than ===, because a plain string compare returns early on the first differing byte and leaks timing. Guard the length first, since timingSafeEqual throws when the two buffers differ in length.

The raw-body trap

HMAC signs bytes, not objects. If a JSON body parser runs first and you rebuild a string from the parsed object to verify against, the bytes are no longer identical to what Razorpay sent. Whitespace and key order shift and the signature never matches. Capture the raw, unparsed body, verify against it, and parse only after the check passes. In Express that means express.raw() on the webhook route rather than express.json(). This is the same trap Stripe, Paddle, and most others share, laid out in the webhook signature verification comparison.

The payment signature is a different check

When Checkout finishes, the handler gives you razorpay_order_id, razorpay_payment_id, and razorpay_signature. This is not a webhook, and it is not verified with the webhook secret. You recompute it with your API key secret over the two IDs joined by a pipe.

// Checkout success verification (NOT the webhook path)
const expected = crypto
  .createHmac('sha256', process.env.RAZORPAY_KEY_SECRET)   // API key secret here
  .update(razorpay_order_id + '|' + razorpay_payment_id)   // the two IDs, pipe-joined
  .digest('hex');

const ok = expected === razorpay_signature;  // compare to the checkout field
Enter fullscreen mode Exit fullscreen mode

Same algorithm, different key, different message. Treat the webhook path and the payment path as two separate verifications and do not share a secret between them.

The mistakes that account for most failures

  • Verifying the webhook with the API key secret instead of the per-webhook secret.
  • Verifying against a re-serialized body instead of the raw bytes.
  • Reusing the payment-signature logic (order_id|payment_id) to check a webhook, or vice versa.
  • Comparing with === instead of a constant-time function.
  • Using a test-mode webhook secret against live traffic, or the reverse.

Signatures do not cover the events you never receive

A valid signature proves a webhook really came from Razorpay. It does nothing for the payment.captured or order.paid event that never arrives because your server was mid-deploy, timed out under load, or threw before it acknowledged. Razorpay retries failed webhooks for a while, but retries only help if your endpoint comes back before they stop, and the events behind them are payments, refunds, and settlements, the ones you least want to lose.

That is the layer EventDock adds. You point Razorpay at EventDock instead of your app. EventDock verifies the signature, stores the event, and acknowledges Razorpay right away so no timeout or downtime window costs you a delivery, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait and arrive when it recovers. For the duplicate deliveries any retrying pipeline produces, the exactly-once processing pattern keeps your handler idempotent.

You can point a Razorpay test webhook at EventDock on the free tier and watch every event get verified, stored, and delivered. Start with EventDock free.

Top comments (0)