ZevCampaign Docs
Sign up

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:

  1. Recompute the HMAC over {t}.{raw body} with your secret.
  2. Compare it to v1 in constant time.
  3. 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:

  1. The body was parsed before verifying. The usual cause.
  2. The wrong secret. Each endpoint has its own, and staging and production differ.
  3. Clock skew. If your server’s clock is minutes off, the age check rejects everything. Run NTP.
  4. The signature was compared with ===. Works, but leaks timing. Fix it anyway.

Updated at, Saturday, October 10, 2026