Guide · Last reviewed
How to verify a webhook signature
Every provider's signature scheme is the same four decisions made differently. Once you can name those four, a scheme you have never seen before takes about two minutes to implement — and a failing one takes about the same to diagnose.
Step by step
Verify an incoming webhook signature
Read the raw request body
Capture the body as bytes or a string before any JSON parsing. Framework body parsers re-serialise, which changes the bytes the signature was computed over. In Express use express.raw(); in Next.js read await request.text(); in Django use request.body, not request.POST.
Read the signature header
Find the header the provider documents and treat a missing header as a hard failure, not a skipped check. If the value has structure — comma-separated fields, a sha256= prefix, several space-separated versions — parse it rather than comparing the whole string.
Rebuild the signed string exactly
Concatenate whatever the provider signs, in order, with the exact delimiters. Stripe uses {timestamp}.{body}; GitHub signs the body alone; Standard Webhooks uses {id}.{timestamp}.{body}. A wrong delimiter fails identically to a wrong secret.
Compute the HMAC with the right key and encoding
HMAC-SHA256 over the signed string, keyed with the signing secret. Encode the digest the way the provider does — hex for Stripe and GitHub, base64 for Shopify and Square — and decode the secret first if the provider gives it to you base64-encoded.
Compare in constant time
Use crypto.timingSafeEqual, hmac.compare_digest, or hash_equals. A plain equality check on a digest leaks how many leading bytes matched, which is enough to forge a signature byte by byte.
Reject stale timestamps
If the scheme includes a timestamp, check it is within about five minutes of now and reject it if not. A valid signature stays valid forever, so without this check a captured request can be replayed indefinitely.
The four decisions
Every signature scheme in production is a choice on four axes. Providers differ only in which choice they made:
- What is signed. The body alone, or a timestamp plus the body, or an id plus a timestamp plus the body, or — for Twilio and Square — the request URL plus the body.
- How the pieces are joined. A dot, a colon, a pipe, or nothing at all.
- How the digest is encoded. Lowercase hex, base64, sometimes with a
sha256=prefix in front of it. - What the key is. A shared secret used as raw ASCII, a shared secret that must be base64-decoded first, or a public key for a scheme that is not an HMAC at all.
Here is the same information for eight providers:
| Provider | Signed string | Encoding | Header |
|---|---|---|---|
| Stripe | {t}.{body} |
hex | stripe-signature |
| GitHub | {body} |
hex, sha256= prefix |
x-hub-signature-256 |
| Shopify | {body} |
base64 | x-shopify-hmac-sha256 |
| Razorpay | {body} |
hex | x-razorpay-signature |
| Slack | v0:{t}:{body} |
hex, v0= prefix |
x-slack-signature |
| Paddle | {t}:{body} |
hex | paddle-signature |
| Standard Webhooks | {id}.{t}.{body} |
base64 | webhook-signature |
| Square | {url}{body} |
base64 | x-square-hmacsha256-signature |
Nothing else about them differs. If you can read a scheme off a page and fill in that row, you can implement it.
Reference implementation
This is the whole thing, in the shape that works for every HMAC provider:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
// 1. Parse the header. Different for every provider; this is Stripe's.
const parts = new Map(header.split(',').map((p) => p.split('=')));
const timestamp = parts.get('t');
const signature = parts.get('v1');
if (!timestamp || !signature) return false;
// 2. Reject anything stale before doing crypto work.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
// 3. Rebuild the signed string with the exact delimiter.
const signed = `${timestamp}.${rawBody}`;
// 4. HMAC-SHA256, encoded the way the provider encodes it.
const digest = createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
// 5. Constant-time comparison, length-checked first.
const a = Buffer.from(digest, 'utf8');
const b = Buffer.from(signature, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
To retarget it at another provider, change lines 1, 3, and 4. That is the entire porting effort.
The raw body problem
This is the one that costs people an afternoon, so it deserves its own section.
Your framework probably parses JSON before your handler runs. By the time you see req.body, it is an object. Serialising it back gives you a JSON document with the same meaning and different bytes:
received: {"amount":2900,"currency":"usd"}
re-serialised:{"amount":2900,"currency":"usd"} ← looks identical
Except the provider sent {\n "amount": 2900,\n "currency": "usd"\n}, or listed the keys in another order, or escaped a /, or wrote 2900.0. Any of those changes the digest completely, because a hash has no notion of “nearly the same input”.
How to get the real bytes:
Express app.post('/w', express.raw({ type: 'application/json' }), handler)
Fastify addContentTypeParser with parseAs: 'string', or config.rawBody
Next.js (app) const raw = await request.text()
Next.js (pages) export const config = { api: { bodyParser: false } }
Astro / Workers const raw = await Astro.request.text()
Django request.body (not request.POST)
Flask request.get_data() (not request.json)
Rails request.raw_post
Laravel $request->getContent()
Go io.ReadAll(r.Body)
Then parse after verifying, from the same string you verified:
const raw = await request.text();
if (!verify(raw, header, secret)) return new Response(null, { status: 401 });
const event = JSON.parse(raw);
Never verify one representation and act on another.
Timing-safe comparison, and why
digest === signature returns as soon as it finds a mismatched byte. That makes the comparison take measurably longer when more leading bytes are correct, and an attacker who can send many requests and time the responses can recover a valid signature one byte at a time.
The window over a public network is tiny but it is not zero, and the fix costs nothing:
Node crypto.timingSafeEqual(a, b) // throws if lengths differ — check first
Python hmac.compare_digest(a, b)
PHP hash_equals($expected, $actual)
Go hmac.Equal(expected, actual)
Ruby OpenSSL.secure_compare(a, b)
Java MessageDigest.isEqual(a, b)
Compare lengths yourself before calling Node’s version; it throws on a mismatch rather than returning false, and an uncaught throw in a verifier is a 500 where you wanted a 401.
The timestamp window
An HMAC proves the payload came from someone holding the secret. It says nothing about when. Without a freshness check, a request captured today is still perfectly valid next year — so anyone who obtains one copy of a “subscription cancelled” delivery can replay it whenever they like.
Providers that include a timestamp expect you to check it:
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) return reject(); // five minutes is the usual tolerance
Two details matter. Use Math.abs, because a receiver whose clock is behind will see timestamps from the future and reject everything. And check the timestamp before the HMAC, so a flood of stale requests cannot make you do crypto work.
For a stronger guarantee, store the delivery id for the length of your window and reject ids you have already seen. That closes the gap where a request is replayed within the five minutes. Mailgun effectively requires this, because its signature covers a token rather than the body.
The schemes that are not HMACs
Two providers here need different primitives, and knowing that up front saves the search for a secret that does not exist.
Discord uses Ed25519. The value in the Developer Portal is a public key; it cannot sign anything, and no HMAC function will produce a matching digest. The signed data is X-Signature-Timestamp concatenated directly with the body, and you verify with nacl.sign.detached.verify or your language’s Ed25519 support.
SendGrid uses ECDSA over P-256, with the verification key published in the Event Webhook settings. The signed data is {timestamp}{body}, the signature is DER-encoded and base64-wrapped, and the body is a JSON array rather than an object.
Both are stronger designs than a shared secret — a compromised web server leaks a key that cannot forge events. They just cannot be tested with an HMAC tool.
Diagnosing a failure
When verification fails and the secret is definitely right, work down this list. It is ordered by how often each one is the answer.
- Log the raw body’s length and first 40 bytes. If it does not start with the exact bytes the provider sent, nothing else matters. Compare it against a captured delivery.
- Log your signed string with visible delimiters.
1750000000.{"type"...is right for Stripe;1750000000{"type"...is a missing dot. - Print both digests. Same length but different content means the input differs. Different lengths mean an encoding mismatch — 64 characters is hex SHA-256, 44 is base64.
- Check the prefix. GitHub sends
sha256=abc…; comparing your bare digest against the whole header value never matches. - Check whether the secret needs decoding. Standard Webhooks secrets arrive as
whsec_<base64>; strip the prefix and base64-decode to bytes. Supabase adds another prefix,v1,whsec_. Using the string as ASCII gives a plausible digest that never matches. - Check you have the right secret. Each endpoint usually has its own, test and live modes differ, and a URL you deleted and recreated has a new one.
- Check nothing in front of you rewrote the request. A proxy, a CDN, or a serverless platform can drop unfamiliar headers, decompress a gzipped body, or normalise line endings — all invisible in your logs, all fatal to the signature.
The signature verifier does steps 2 and 3 for you: paste the raw body, the header, and the secret, and it shows the signed string it built and the digest it got. When that matches and your code does not, the difference is in how your code gets the body.
Answers
Common questions
Why does my signature verification always fail?+
In order of likelihood: the body was parsed and re-serialised before verification, the signed string is missing a delimiter or the timestamp, the digest encoding is hex where the provider uses base64, or the secret is the API key rather than the signing secret. All four fail identically.
Do I need the raw body, or is the parsed object enough?+
You need the raw body. `JSON.parse` followed by `JSON.stringify` produces different bytes — key order, whitespace, number formatting, Unicode escapes — and the HMAC is over the original bytes. This is the single most common cause of verification failure.
What is the difference between a signing secret and an API key?+
The API key authenticates you to the provider. The signing secret authenticates the provider to you. They are different values, usually created in different places, and each endpoint often has its own signing secret — so a URL you recreated has a new one.
Why does the timestamp matter if the signature is valid?+
Because a signature never expires on its own. Anyone who captures one valid request can send those exact bytes again forever, and the HMAC still checks out. The timestamp window is what turns a permanently valid signature into a briefly valid one.
Can I skip verification if I use a long random URL?+
No. A secret URL leaks — into dashboards, logs, browser history, screenshots, and config files in version control — and it is sent as plaintext in the request line of every proxy hop. It reduces casual traffic; it is not authentication.
Which providers do not use an HMAC?+
Discord signs with Ed25519 and SendGrid with ECDSA P-256, both public-key schemes where the value you are given is a public key, not a shared secret. Twilio and Square include the request URL in the HMAC, and Mailgun signs two body fields rather than the body.
Keep reading
Related guides
Try it on a real request
Reading about it only goes so far.
Create a URL, point a provider at it, and inspect the delivery — the real headers, the raw bytes, and a signature you can check in the browser.
No signup · No email · Ready in about a second