When a batch finishes, VerifyAnyEmail can POST a batch.completed event to a URL you control. That callback is convenient, but it travels over the open internet to a publicly reachable endpoint. Without verification, an attacker who guesses or discovers your webhook URL could send fake “completed” events, replay old ones, or feed your system bogus data. To make the callback trustworthy, every request carries a signature header:
X-VAE-Signature: sha256=<HMAC-SHA256(rawBody, yourWebhookSecret)>Your job is to independently recompute that HMAC on your side using the same raw bytes and the same secret, then confirm it matches. If it matches, the request is authentic and untampered. If it doesn’t, you reject it with a 401 and never process the body. Your webhook secret lives in your dashboard Settings — treat it exactly like an API key and never expose it client-side (see how to protect your API keys).
Why signatures matter
Authentication headers like a bearer token protect requests you send outward. A webhook is the reverse: someone sends a request to you, so you can’t rely on a secret in an Authorization header you control. Instead, the sender and receiver share a secret, and the sender proves it holds that secret by signing each payload. Because the signature is derived from the exact body bytes, it does double duty: it authenticates the sender and guarantees integrity — flip a single byte in transit and the HMAC no longer matches. This is the same HMAC construction defined in RFC 2104.
1. Verify the raw body — never a re-serialized object
This is the mistake that silently breaks most webhook implementations. The HMAC is computed over the exact bytes VerifyAnyEmail sent. If your framework parses the JSON into an object and you then re-serialize that object to recompute the signature, the bytes will almost never match: key order can change, whitespace differs, Unicode escaping differs, and numbers may be reformatted. The signature is a fingerprint of the original bytes, not of the parsed meaning.
So capture the raw request body before any JSON middleware touches it, compute your HMAC over those bytes, and only parse the JSON after the signature checks out. In Express that means mounting a raw body parser on the webhook route specifically; in most other frameworks it means reading the raw request stream rather than the decoded body.
2. Compute the HMAC-SHA256 with your webhook secret
Take the raw body bytes, run HMAC-SHA256 keyed with your webhook secret, hex-encode the digest, and prefix it with sha256= so it lines up with the header format. That prefixed string is what you compare against the incoming X-VAE-Signature value.
3. Compare in constant time — after a length check
Never compare signatures with ==, ===, or ordinary string equality. Those short-circuit on the first differing character, and the tiny timing difference leaks information an attacker can use to forge a valid signature byte by byte. Use a constant-time comparator instead: Node’s crypto.timingSafeEqual or Python’s hmac.compare_digest.
There’s one catch with crypto.timingSafeEqual: it throws if the two buffers differ in length. So check the lengths first (and require a well-formed header) before calling it — treat a length mismatch as a plain verification failure, not an exception. Python’s hmac.compare_digest handles unequal lengths for you and returns False, so you can call it directly.
Node.js (Express)
import crypto from "crypto";
// Express: use a raw body parser for this route so the bytes are unmodified.
app.post("/webhooks/vae", express.raw({ type: "application/json" }), (req, res) => {
const header = req.headers["x-vae-signature"]; // "sha256=…"
const signature = Array.isArray(header) ? header[0] : header;
const expected =
"sha256=" + crypto.createHmac("sha256", process.env.VAE_WEBHOOK_SECRET)
.update(req.body).digest("hex");
// timingSafeEqual throws if the buffers differ in length, so compare lengths
// first (and require a well-formed header) before the constant-time check.
const a = Buffer.from(signature ?? "", "utf8");
const b = Buffer.from(expected, "utf8");
const ok = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!ok) return res.status(401).end();
const { event, data } = JSON.parse(req.body.toString());
// event === "batch.completed", data.batchId, data.deliverable, …
res.sendStatus(200);
});Note that req.body here is a Buffer of raw bytes, not a parsed object — that’s exactly what the raw parser gives you, and it’s what the HMAC needs. Only after verification do we call JSON.parse.
Python (Flask)
import hmac, hashlib
from flask import request, abort
@app.post("/webhooks/vae")
def vae_webhook():
raw = request.get_data() # raw bytes, unmodified
signature = request.headers.get("X-VAE-Signature", "")
expected = "sha256=" + hmac.new(
VAE_WEBHOOK_SECRET.encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
payload = request.get_json()
# payload["event"] == "batch.completed"
return "", 200request.get_data() returns the untouched bytes; hmac.compare_digest is the constant-time comparison and safely handles a length mismatch. Only call request.get_json() after the check passes.
Test it live before you ship
The fastest way to confirm your implementation is correct is to reproduce the exact signature outside your app. Paste your secret, a sample raw body, and the received header into the Webhook Signature Tester — it computes the same sha256=… HMAC and tells you whether it matches, so you can tell a genuine mismatch apart from a bug in your own code. When your endpoint agrees with the tester on a known payload, you’re verifying correctly.
Good habits around verification
- Return quickly, process later. Verify the signature, enqueue the work, and return
200fast. Long processing inside the request risks a timeout and a retry, which means you handle the same event twice — so make your handler idempotent (key off the batch id). The batch processing guide covers idempotency in depth. - Fail closed. A missing header, a malformed header, or any mismatch should return
401and do nothing else. Never “try to parse it anyway.” - Keep the secret server-side and rotate it. If it ever leaks, rotate it in dashboard Settings and update your environment variable. It should never appear in client code, logs, or your repository.
- Use HTTPS. The signature guarantees integrity, but TLS keeps the payload itself private in transit. Always expose your webhook over HTTPS.
Where this fits
Signature verification is the security layer on top of the callback you set up when submitting a job with a webhookUrl. If you’re wiring this into an application, the Node.js & Express guide shows the surrounding request handling, and the full request and response shapes are in the API docs. To validate single addresses interactively while you build, the Email Verifier is the quickest sandbox.
Sources
HMAC is defined in RFC 2104, and SHA-256 in RFC 6234. Node’s constant-time comparison is documented in the Node.js crypto reference, and Python’s in the Python hmac module docs.