Why your Razorpay webhook signature verification is failing
Four causes account for nearly every INVALID_WEBHOOK_SIGNATURE: a re-serialised body, middleware ordering, a rotated secret on a retried event, and comparing the wrong encoding.
INVALID_WEBHOOK_SIGNATURE almost never means what it sounds like. In most reports the secret is correct and the algorithm is correct — what has changed is the bytes being signed. HMAC is computed over an exact sequence of bytes, and a body that is semantically identical but byte-wise different produces a completely unrelated digest.
Four causes account for nearly all of it, in roughly this order:
- The body was parsed and re-serialised before verification.
- JSON middleware ran before the raw-body middleware.
- The secret was rotated and the failing events are retries of older ones.
- Something between Razorpay and your handler rewrote the request.
1. The body was re-serialised
This is the big one. If your framework parses JSON into an object and you then call JSON.stringify on it to verify, you are signing a reconstruction of the payload, not the payload. Key order, whitespace and number formatting are all free to change in that round trip, and every one of them changes the digest.
Here is the same webhook, signed as sent and signed after a re-serialisation:
body: {"event":"payment.captured","created_at":1770000000,"amount":49900}
signature: 2fbb10d0c71a80ef48e328f4ea9d535f5a337100b5b5279823cbe901fe798587body: {
"event": "payment.captured",
"created_at": 1770000000,
"amount": 49900
}
signature: 58fc41da1d1f56de68606e519ce4199ace3fb02d7a130803455fc8ca89ef2c1bNothing about the meaning of that payload changed. The signature did. This is why “but the JSON is the same” is not a defence — the signature was never over the JSON, it was over the bytes.
2. Middleware ordering
In Express the usual mistake is mounting express.json() globally, which consumes the stream before your webhook route ever sees it. The raw-body middleware has to be mounted on the webhook route, before the JSON parser:
// This route only. Must come before app.use(express.json()).
app.post(
"/webhooks/razorpay",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("X-Razorpay-Signature");
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET)
.update(req.body) // a Buffer, untouched
.digest("hex");
if (expected !== signature) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8")); // parse after
res.sendStatus(200);
},
);
app.use(express.json()); // everything elseNext.js route handlers make this easier, because the raw body is available directly and nothing parses it for you:
export async function POST(request: Request) {
const body = await request.text(); // raw, unparsed
const signature = request.headers.get("x-razorpay-signature") ?? "";
const expected = createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(body)
.digest("hex");
if (expected !== signature) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(body);
return new Response(null, { status: 200 });
}3. A rotated secret, failing on retries
This one is genuinely counter-intuitive and is worth checking whenever failures start suddenly and affect only some events. A webhook is signed when it is generated. Rotating the secret does not re-sign anything already queued, so retries of older events keep arriving signed with the previous secret.
For the length of the retry window you therefore have to accept either: try the current secret, and only if that fails try the old one before rejecting. Compare both in constant time, and drop the old secret once the window has passed.
4. Something rewrote the request in transit
Less common, but it does happen: an API gateway, a WAF or a reverse proxy that normalises, re-compresses or pretty-prints request bodies will break every signature it touches. If verification passes locally against a captured body and fails in your deployed environment, suspect the hop in between rather than the code. Log the exact byte length of the body you received and compare it with what Razorpay reports sending — a length mismatch settles it immediately.
Narrowing it down
The fastest way to tell a body problem from a secret problem is to take one real failing request, paste the exact body and the header value into the webhook signature verifier, and try your secret. The tool shows you the string it signed and the digest it expected, both in your browser — the payload and secret are never transmitted. If it passes there and fails in your service, the body your code sees is not the body that arrived, and the cause is in the four above.
One thing to rule out early: if it is a Checkout, Payment Link or subscription signature rather than a webhook, you may be reaching for the wrong secret entirely. Those use the API key secret, not the webhook secret.
Check it against the tool
- Webhook signature verifier
Check a Razorpay, Stripe, Cashfree or PayU signature against your secret — webhooks, and Razorpay's checkout and payment-link schemes — and see exactly which string was signed.
Frequently asked questions
- Why does express.json() break webhook verification?
- Because it parses the body into an object and discards the bytes. Re-serialising that object with JSON.stringify gives you a string that is semantically identical and byte-wise different — key order, whitespace and number formatting are all free to change. HMAC is over bytes, so any of those differences produces a different digest. Mount express.raw({ type: 'application/json' }) on the webhook route before express.json() runs.
- I rotated my webhook secret and old events started failing. Why?
- A retried event is still signed with the secret that was live when it was first generated. Rotating the secret does not re-sign anything already in the retry queue, so for a period you have to accept either secret: try the new one, and on failure try the old one before rejecting. Keep the old secret for as long as the retry window lasts.