Webhooks
Verifying signatures
Proving an event came from us, in about five lines.
Every webhook carries a signature header. Verify it.
An endpoint that does not is one that accepts forged events from
anyone who learns the URL — and a forged contact.unsubscribed for
your best customer is a cheap thing for someone to send.
The header
X-Zev-Signature: t=1773481800000,v1=5f2a9c...
t— when we signed it, in milliseconds since the epoch.v1— HMAC-SHA256 of{t}.{raw body}, hex encoded, using your endpoint’s signing secret.
The timestamp is part of what is signed, which is what stops somebody replaying a captured request later.
Verifying
Three steps, and the order matters:
- Recompute the HMAC over
{t}.{raw body}with your secret. - Compare it to
v1in constant time. - Reject anything older than a few minutes.
import crypto from 'node:crypto';
export function verifySignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? '')
.split(',')
.map((p) => p.split('=')),
);
const { t, v1 } = parts;
if (!t || !v1) return false;
// Reject anything older than five minutes, so a captured request
// cannot be replayed tomorrow.
const age = Math.abs(Date.now() - Number(t));
if (!Number.isFinite(age) || age > 5 * 60 * 1000) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// Constant time. `===` leaks how much of the signature matched,
// one byte at a time, which is enough to forge one given patience.
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hmac, hashlib, time
def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(time.time() * 1000 - int(t)) > 5 * 60 * 1000:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
You need the raw body
Sign the bytes we sent, not a re-serialised object.
JSON.parse then JSON.stringify will change key order, spacing or
unicode escaping, and the HMAC will not match. This is the single
most common reason verification fails on a first attempt.
In Express:
// Raw body for this route only, so the rest of your app keeps
// getting parsed JSON.
app.post(
'/webhooks/zevcampaign',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifySignature(
req.body, // a Buffer, unparsed
req.get('X-Zev-Signature'),
process.env.ZEVCAMPAIGN_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200);
// …handle it
},
);
Next.js app router:
export async function POST(request) {
const raw = await request.text(); // before any parsing
const ok = verifySignature(
raw,
request.headers.get('x-zev-signature'),
process.env.ZEVCAMPAIGN_WEBHOOK_SECRET,
);
if (!ok) return new Response('invalid signature', { status: 401 });
const event = JSON.parse(raw);
return new Response('ok');
}
Rotating the secret
Roll it from the dashboard. The new secret applies to the next delivery, so deploy the new value before rotating, or accept a short window of rejected events that will be retried.
If verification fails
In order of likelihood:
- The body was parsed before verifying. The usual cause.
- The wrong secret. Each endpoint has its own, and staging and production differ.
- Clock skew. If your server’s clock is minutes off, the age check rejects everything. Run NTP.
- The signature was compared with
===. Works, but leaks timing. Fix it anyway.
Updated at, Saturday, October 10, 2026