ZevSend Docs
Sign up

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…
PartMeaning
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:

  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.

Updated at, Thursday, October 1, 2026