Why your Cashfree webhook signature does not match
Cashfree's scheme looks like Stripe's and differs in three details that each produce a total mismatch: no dot between timestamp and body, base64 rather than hex, and a timestamp that lives in its own header.
Cashfree's webhook signature looks enough like Stripe's that a working Stripe implementation ports across in about five minutes and then fails on every single delivery. The scheme differs in three details, and each one on its own produces a completely different digest with no clue as to which.
Cashfree signs:
base64( HMAC-SHA256( x-webhook-timestamp + raw body, client secret ) )Three things in that line catch people: the timestamp comes from a separate header, it is concatenated with no separator at all, and the result is base64.
The no-separator detail
Stripe joins its timestamp and body with a dot — t.body. Cashfree does not. The timestamp runs directly into the first character of the body:
1739510400{"type":"PAYMENT_SUCCESS_WEBHOOK",...Here is what that one character costs. Same secret, same body, same timestamp — the only difference is a dot:
FOnlgr0yZHNVBJOoMXiVu7OO7/wJ3YnNd6Rs/iWCaGs=67miI17omzOTHo/lsbS8tkipeUxj3PJJcpfuLVxLc9M=Nothing about the wrong one looks wrong. It is the right length, the right encoding, and entirely unrelated to the right answer. This is the property that makes signature bugs expensive: every failure looks identical, so the error message cannot tell you which of the four possible causes you have.
Base64, not hex
If you copied a Razorpay implementation, you are almost certainly producing hex. The same digest, in the encoding Cashfree does not use, looks like this:
14e9e582bd326473550493a8317895bbb38eeffc09dd89cd77a46cfe2582686bA quick tell: the header value is around 44 characters and ends in =; a SHA-256 hex digest is exactly 64 characters of 0-9a-f. If what you computed is 64 characters long, stop looking at your secret.
Forgetting the timestamp entirely
The timestamp arrives in x-webhook-timestamp, a header separate from the signature. It is easy to miss, and signing the body alone produces yet another plausible-looking failure:
c9dEIwh/3HHHWfTHaacZnQkNrMxDmw+NBQHsL+LGzHA=The two causes that are not Cashfree-specific
A re-serialised body. If your framework parsed the JSON before you saw it, the bytes you sign are not the bytes that were signed — key order, whitespace and number formatting are all free to change, and any of them changes the digest. This is the single most common webhook bug in any gateway, and it is covered in more detail in why your Razorpay webhook signature verification is failing.
The wrong environment. Sandbox and production have different client secrets. A sandbox webhook checked against a production secret fails exactly the way a forged payload does, which is the point of the scheme but unhelpful when the cause is your own configuration.
Checking one you have in front of you
Paste the body, the secret, the x-webhook-signature value and the x-webhook-timestamp value into the webhook signature verifier. It shows the exact string it signed, so you can see the timestamp running into the body with no separator rather than taking anyone's word for it. Everything runs in your browser — the secret is never sent anywhere.
If it reports valid there and your own code still rejects the delivery, the difference is your body, not your algorithm. Log the byte length of what you hashed and compare it with Content-Length; they should be identical.
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
- Is there a separator between the timestamp and the body?
- No. Cashfree concatenates them directly: timestamp immediately followed by the raw body, with nothing in between. This is the single most common mistake for anyone porting a Stripe implementation, because Stripe joins them with a dot. One character of difference produces a completely different digest and no hint as to why.
- Why does my hex digest never match?
- Cashfree encodes the signature as base64, not hex. If you are comparing a 64-character hexadecimal string against the header, you are comparing the right digest in the wrong encoding. Both represent the same bytes; only one matches the header as sent.
- Which secret signs a Cashfree webhook?
- Your client secret for the environment the webhook came from. Sandbox and production have different secrets, and a sandbox webhook checked against a production secret fails exactly like a tampered payload does. Check the key prefix in the payload matches the environment you are verifying against.