VVEREID Docs
guides

Verifying webhook signatures

A deep-dive guide to verifying the vereid-signature header — including replay protection, key rotation, and timing-safe comparison in four languages.

Last updated 2026-05-20

The Webhooks concept page documents the wire format. This guide is the operational playbook — how to write a verifier that never breaks under key rotation, never gets fooled by a stale-but-valid signature, and never leaks a secret via a timing side-channel.

The header, one more time

vereid-signature: v1,t=1716220800,sig=8a7df40c…
  • v1 — algorithm version. The only version today; new versions will be added with v2,… syntax and both versions will be sent in the header for at least 90 days during rollout.
  • t=<unix> — the timestamp the request was signed at, in seconds.
  • sig=<hex>HMAC-SHA256(secret, "<t>.<raw body>"), lowercase hex.

The signed payload is t + "." + raw_body — bytewise concatenation. If you re-serialize the JSON body (e.g. by JSON.parse then JSON.stringify) the signature will not match.

Five rules

  1. Always use raw bytes. Frameworks default to parsing JSON. You must capture the raw body before any parser touches it.
  2. Always check the timestamp. A signature valid forever is a replay attack waiting to happen.
  3. Always use a timing-safe comparison. A naive === leaks bytes.
  4. Always handle multiple v versions — return 200 even if you don't understand a future version, as long as one understood version matches.
  5. Always log the vereid-event-id — replays send the same id; you should noop on duplicates.

Node.js — full handler

import express from "express";
import crypto from "node:crypto";
 
const SECRET = process.env.VEREID_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300;
 
const seen = new Map(); // id -> ts; flush periodically
 
export function verify(
  raw,
  header,
  secret = SECRET,
  tolerance = TOLERANCE_SEC,
) {
  // Header can contain multiple comma-grouped versions; pick v1.
  const v1 = (header ?? "")
    .split(/\s*,\s*v(?=\d)/)
    .map((p, i) => (i === 0 ? p : "v" + p))
    .find((p) => p.startsWith("v1,"));
  if (!v1) return false;
  const m = /^v1,t=(\d+),sig=([a-f0-9]+)$/.exec(v1);
  if (!m) return false;
  const [, t, sig] = m;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > tolerance)
    return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${raw}`)
    .digest("hex");
  return (
    sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  );
}
 
const app = express();
app.post(
  "/webhooks/vereid",
  express.raw({ type: "*/*", limit: "1mb" }),
  (req, res) => {
    if (!verify(req.body, req.header("vereid-signature"))) {
      return res.status(400).end();
    }
    const id = req.header("vereid-event-id");
    if (id && seen.has(id)) return res.status(204).end();
    if (id) seen.set(id, Date.now());
 
    const event = JSON.parse(req.body);
    handle(event);
    res.status(204).end();
  },
);

Python — full handler

import hmac, hashlib, time, re, json
from fastapi import FastAPI, Request, HTTPException
 
SECRET = os.environ["VEREID_WEBHOOK_SECRET"].encode()
TOLERANCE = 300
 
def verify(raw: bytes, header: str | None, tolerance: int = TOLERANCE) -> bool:
    if not header:
        return False
    # accept multi-version headers; pick v1
    parts = re.split(r",\s*(?=v\d)", header)
    v1 = next((p for p in parts if p.startswith("v1,")), None)
    if not v1:
        return False
    m = re.fullmatch(r"v1,t=(\d+),sig=([a-f0-9]+)", v1)
    if not m:
        return False
    t, sig = m.group(1), m.group(2)
    if abs(int(time.time()) - int(t)) > tolerance:
        return False
    expected = hmac.new(
        SECRET, f"{t}.".encode() + raw, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(sig, expected)
 
app = FastAPI()
seen: dict[str, float] = {}
 
@app.post("/webhooks/vereid")
async def webhook(request: Request):
    raw = await request.body()
    if not verify(raw, request.headers.get("vereid-signature")):
        raise HTTPException(400)
    event_id = request.headers.get("vereid-event-id")
    if event_id and event_id in seen:
        return Response(status_code=204)
    if event_id:
        seen[event_id] = time.time()
    event = json.loads(raw)
    await handle(event)
    return Response(status_code=204)

Go — full handler

package main
 
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "regexp"
    "strconv"
    "strings"
    "time"
)
 
var secret = []byte(os.Getenv("VEREID_WEBHOOK_SECRET"))
var sigRe = regexp.MustCompile(`^v1,t=(\d+),sig=([a-f0-9]+)$`)
 
func verify(raw []byte, header string, tolerance time.Duration) bool {
    var v1 string
    for _, part := range strings.Split(header, ",") {
        part = strings.TrimSpace(part)
        if strings.HasPrefix(part, "v1") || strings.HasPrefix(part, "v1,") {
            v1 = part
            break
        }
    }
    // header above is split too aggressively for multi-key syntax; in production
    // accept the literal "v1,t=..,sig=.." block in full
    m := sigRe.FindStringSubmatch(header)
    if m == nil {
        return false
    }
    t, _ := strconv.ParseInt(m[1], 10, 64)
    if d := time.Since(time.Unix(t, 0)); d > tolerance || d < -tolerance {
        return false
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(m[1] + "."))
    mac.Write(raw)
    return hmac.Equal([]byte(m[2]), []byte(hex.EncodeToString(mac.Sum(nil))))
}
 
func webhook(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)
    if !verify(raw, r.Header.Get("vereid-signature"), 5*time.Minute) {
        http.Error(w, "bad signature", 400)
        return
    }
    // handle...
    w.WriteHeader(204)
}

curl — quick manual check

T=1716220800
BODY='{"id":"evt_test","type":"ping"}'
SIG=$(printf "%s.%s" "$T" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}')
echo "vereid-signature: v1,t=${T},sig=${SIG}"

Rotating the secret

Generate a new secret in the developer console. VEREID will sign with both the old and new secret for a 24-hour overlap window. Update your handler to try both:

const ok = verify(raw, header, OLD_SECRET) || verify(raw, header, NEW_SECRET);

After 24 hours the old secret is revoked. Plan to deploy the new-secret-aware handler before you click rotate.

Local development

Use vereid webhooks tail --forward=http://localhost:3000/webhooks/vereid to receive real webhooks against your local server without exposing a tunnel. The CLI preserves the vereid-signature header verbatim so your verifier exercises the production code path.