Webhooks
Verifying signatures
Verify the X-Zev-Signature header so you know the event really came from ZevSend.
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
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
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);
}
Verifying in 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
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:
- Open Settings → Webhooks → <endpoint> in the dashboard.
- Click Rotate secret.
- Deploy the new secret to your application.
- 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.
Updated at, Thursday, October 1, 2026