---
title: Verifying signatures
description: Verify the X-Zev-Signature header so you know the event really came from ZevSend.
---

import Callout from '../../../components/Callout.astro';

Every webhook delivery carries an `X-Zev-Signature` header.
Verifying it proves the payload came from ZevSend and hasn't
been tampered with in flight.

The format matches Stripe's signing convention so any team
that's worked with Stripe will recognise it.

## Header format

```http
X-Zev-Signature: t=1716553200000,v1=4f1c…
```

| Part        | Meaning                                                    |
| ----------- | ---------------------------------------------------------- |
| `t=<ms>`    | Unix timestamp **in milliseconds** when we signed.         |
| `v1=<hex>`  | HMAC-SHA256 of `<timestamp>.<raw-body>` using your secret. |

The `v1` prefix versions the algorithm. We may add `v2=…` in
the future; existing implementations keep working as long as
they verify *any* version they recognise.

## Verifying in Node.js

```ts
import crypto from 'node:crypto';

const FIVE_MINUTES_MS = 5 * 60 * 1000;

export function verifyZevSignature(req: {
  headers: Record<string, string | undefined>;
  rawBody: string;
}) {
  const header = req.headers['x-zev-signature'];
  const secret = process.env.ZEVSEND_WEBHOOK_SECRET!;
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=')),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;
  if (!timestamp || !signature) return false;

  // Replay defence — reject anything older than 5 minutes.
  if (Math.abs(Date.now() - timestamp) > FIVE_MINUTES_MS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${req.rawBody}`)
    .digest('hex');

  // Constant-time comparison.
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

<Callout type="warning" title="Use the raw body, not the parsed one">
The HMAC is computed over the **raw HTTP body** before any
JSON parsing. If your framework parses the body before your
verifier sees it, the bytes you hash won't match the bytes we
hashed and verification will fail.

In Express, use `express.raw({ type: 'application/json' })`
on the webhook route, then `JSON.parse` after verification.
</Callout>

## Verifying in Python

```python
import hmac
import hashlib
import time

FIVE_MINUTES_MS = 5 * 60 * 1000

def verify_zev_signature(header: str, raw_body: bytes, secret: str) -> bool:
    try:
        parts = dict(p.strip().split('=', 1) for p in header.split(','))
    except ValueError:
        return False

    timestamp = int(parts.get('t', '0'))
    signature = parts.get('v1', '')
    if not timestamp or not signature:
        return False

    now_ms = int(time.time() * 1000)
    if abs(now_ms - timestamp) > FIVE_MINUTES_MS:
        return False

    payload = f'{timestamp}.'.encode() + raw_body
    expected = hmac.new(
        secret.encode(), payload, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

## Verifying in Go

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "strconv"
    "strings"
    "time"
)

const fiveMinutesMs = 5 * 60 * 1000

func verifyZevSignature(header string, rawBody []byte, secret string) bool {
    var ts int64
    var sig string
    for _, part := range strings.Split(header, ",") {
        kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            ts, _ = strconv.ParseInt(kv[1], 10, 64)
        case "v1":
            sig = kv[1]
        }
    }
    if ts == 0 || sig == "" {
        return false
    }

    nowMs := time.Now().UnixMilli()
    if (nowMs-ts > fiveMinutesMs) || (ts-nowMs > fiveMinutesMs) {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%d.", ts)
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(sig))
}
```

## Rotating the secret

If you suspect your webhook secret has leaked:

1. Open **Settings → Webhooks → \<endpoint\>** in the dashboard.
2. Click **Rotate secret**.
3. Deploy the new secret to your application.
4. Click **Revoke previous** in the dashboard.

While both secrets are active, signatures computed with either
one verify. That window gives you a no-downtime rollout.