When a webhook arrives, your endpoint has no built-in way to know it really came from the sender and not from someone who guessed the URL. The standard fix is a signature: the sender computes a keyed hash of the exact request body using a shared secret, and puts it in a header. You recompute the same hash on your side and check it matches. If it does, the payload is authentic and hasn’t been tampered with in transit. This tool reproduces that calculation entirely in your browser so you can debug a mismatch without shipping your secret to a server — paste the raw body, the secret and the signature you received, and see exactly what the expected value should have been.
How HMAC webhook signing works
VerifyAnyEmail signs every webhook with HMAC-SHA256 and sends the result in the X-VAE-Signature header, formatted as sha256=<hex>. HMAC is a keyed hash: it mixes your webhook secret into a SHA-256 digest of the raw request body, so only someone who holds the secret can produce a value that verifies. On your side you run the identical computation:
- Take the raw request body exactly as received — the literal bytes, before any JSON parsing.
- Compute
HMAC-SHA256(rawBody, webhookSecret)and hex-encode it. - Prefix it with
sha256=and compare against the header you were sent.
Because HMAC is deterministic, the same body and secret always yield the same signature — which is exactly why any difference in the body or the secret makes verification fail. This tester does steps 1–3 for you with the Web Crypto API (crypto.subtle.importKey + crypto.subtle.sign), so the secret you paste never leaves the browser tab.
The raw-body pitfall (the #1 cause of mismatches)
The single most common reason a legitimate webhook fails verification is that the body was re-serialized before it was hashed. A signature covers bytes, not the logical JSON object. If your framework parses the incoming JSON into an object and you then re-encode it with JSON.stringify, you will almost certainly get different bytes — a space after each colon, keys in a different order, unicode escaped differently, a trailing newline dropped. All of those change the SHA-256 digest completely, and the signature won’t match even though the secret is correct.
The fix is to capture the raw body before your JSON middleware touches it. In Express that means express.raw() on the webhook route; in Flask, request.get_data(); in PHP, file_get_contents("php://input"). The webhook docs show the exact pattern for each language. When you paste into the box above, paste that same raw string — not a prettified copy from your logs.
Verifying safely on your server: constant-time comparison
Computing the expected signature is only half the job. How you compare it matters too. A naïve equality check (expected === received) can short-circuit on the first differing character, and the tiny timing differences that leak can, in principle, let an attacker recover a valid signature byte by byte. Always compare with a constant-time function whose runtime doesn’t depend on where the strings differ:
- Node.js:
crypto.timingSafeEqual(a, b)— but it throws if the buffers differ in length, so checka.length === b.lengthfirst (and require a well-formed header) before calling it. - Python:
hmac.compare_digest(expected, received). - PHP:
hash_equals($expected, $received).
This browser tool compares case-insensitively for convenience while you’re debugging — that’s fine for a manual check, but your production handler should use one of the length-safe, constant-time comparisons above. Reject the request with a 401 whenever it fails.
Reading a mismatch
If the tester reports No match, work through the usual suspects in order: (1) the raw body isn’t byte-identical to what was signed — you pasted a reformatted copy; (2) the wrong secret — an old one, or the secret from a different environment; (3) the sha256= prefix was left on one side of the comparison but not the other; (4) an encoding mismatch, e.g. the secret was stored with a stray newline. The tool always prints the full expected value, so you can eyeball it against the header you received and spot which of these it is. Once local verification passes, wire the same computation into your handler and confirm end-to-end with a real delivery. Full copy-paste examples for Node, Python and PHP live in the webhooks section of the docs, and the rest of the API is documented under /docs.
Frequently asked questions
- Is my payload or secret sent anywhere?
- No. The HMAC is computed locally with the Web Crypto API — the payload, secret and signature never leave your browser. There’s no upload; it works offline.
- Why does my signature not match even with the right secret?
- Almost always the raw body was reformatted before hashing — signatures cover exact bytes, not the parsed object. Capture the body before JSON parsing. Also check for a stale secret or an unstripped
sha256=prefix. - What exactly does VerifyAnyEmail sign?
- The header
X-VAE-Signature: sha256=<hex>, where the hex isHMAC-SHA256(rawBody, webhookSecret)over the exact raw body. See the webhook docs for verification code. - How should I compare signatures in production?
- With a constant-time check: Node
crypto.timingSafeEqual(after a length check), Pythonhmac.compare_digest, PHPhash_equals. Never plain==. Return401on mismatch.