---
title: Verifying signatures
description: 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.

```js
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);
}
```

```python
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:

```js
// 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:

```js
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.