</> Payments Toolbox

razorpay_signature vs webhook signature: two different secrets

Checkout, payment links and subscriptions are keyed with your API secret. Webhooks are keyed with the webhook secret. Mixing them up is the most common cause of a signature that will never match.

Razorpay issues two signatures that look identical — both are HMAC-SHA256, both arrive as 64 hex characters — and are keyed with two different secrets. Nothing in the error message tells you which one you got wrong, so a developer whose webhooks verify perfectly can spend an afternoon on a razorpay_signature that was never going to match.

Here is the whole distinction:

  • Webhook signatures are HMAC-SHA256 over the raw request body, keyed with the webhook secret — a value you invent yourself when you create the webhook in the dashboard. It arrives in the X-Razorpay-Signature header.
  • razorpay_signature is HMAC-SHA256 over a pipe-joined list of ids, keyed with your API key secret — the one paired with your rzp_test_ or rzp_live_ key id. It is returned alongside the payment, not in a header.

The same payment, signed both ways

Below is one order and one payment, run through both schemes. The signatures were computed when this page was built, by the same verifier the tool on this site uses.

razorpay_signature — keyed with the API key secret (rzp_test_demo_api_key_secret)
signed string:  order_Deadbeef123|pay_Cafebabe456
signature:      2c142deebcf1292d150f23d94adcb23b2a6c17a7bb5b0e839f8a6cb8fda6251c
Webhook signature — keyed with the webhook secret (whsec_demo_webhook_secret)
signed string:  {"event":"payment.captured","payload":{"payment":{"entity":{"id":"pay_Cafebabe456","order_id":"order_Deadbeef123"}}}}
signature:      344360aaa1a65fbe4d5ead9ec9b562e6ffa3bf7366a20b4c285b6c7953e16307

Same payment, same algorithm, entirely different digests. That is the failure people hit: the code is correct, the algorithm is correct, and the key is from the wrong pair.

What each flow signs

There is a second trap inside razorpay_signature itself. The pipe-joined string is not the same for every flow, and the field order is not what you would guess:

Signed string by flow
Checkout / Orders    order_id | payment_id
Payment Link         payment_link_id | payment_link_reference_id | payment_link_status | payment_id
Subscription         payment_id | subscription_id

Note the subscription line. An order signs order_id then payment_id; a subscription signs payment_id first, then subscription_id. There is no principle behind the reversal — it is simply how the API behaves, and it matches Razorpay's own SDKs. Getting it backwards produces a completely unrelated digest with no clue as to the cause.

The Payment Link flow has its own quirk: razorpay_payment_link_reference_id is your own reference value, and it is part of the signed string even when it is empty. Dropping it because it looks blank shortens the string by one separator and breaks the match.

Verifying it in code

Node — Checkout / Orders
const crypto = require("node:crypto");

// The API key secret, NOT the webhook secret.
const expected = crypto
  .createHmac("sha256", process.env.RAZORPAY_KEY_SECRET)
  .update(`${razorpay_order_id}|${razorpay_payment_id}`)
  .digest("hex");

const ok = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(razorpay_signature),
);

Compare with timingSafeEqual rather than ===. It matters far less here than in a public API, but it costs nothing and the habit is the point. timingSafeEqual throws if the two buffers differ in length, so guard the length first if the signature can arrive malformed.

Still failing?

If you have confirmed the secret is the API key secret and the field order matches the flow, the remaining causes are mundane: a test-mode signature checked against a live-mode secret, or trailing whitespace on a secret pasted out of a dashboard. Paste the payload into the webhook signature verifier — it detects which flow your ids belong to, shows the exact string it signed, and tells you what it expected. If the signed string it shows is not the one you assumed, that is your bug.

If your webhook signatures are the ones failing, the cause is usually the request body rather than the secret — that has its own article.

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

Which secret signs razorpay_signature?
Your API key secret — the one paired with the rzp_test_ or rzp_live_ key id you initialise the SDK with. Not the webhook secret. The webhook secret is a separate value you choose yourself when creating a webhook in the dashboard, and it only ever signs webhook request bodies.
Why does the subscription signature use a different field order?
It genuinely is inconsistent. An order signs order_id|payment_id, but a subscription signs payment_id|subscription_id — the payment id comes first. There is no principle behind the difference; it is a quirk of the API you have to match exactly, because HMAC over the wrong order produces a completely different digest with no hint as to why.