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 withv2,…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
- Always use raw bytes. Frameworks default to parsing JSON. You must capture the raw body before any parser touches it.
- Always check the timestamp. A signature valid forever is a replay attack waiting to happen.
- Always use a timing-safe comparison. A naive
===leaks bytes. - Always handle multiple
vversions — return200even if you don't understand a future version, as long as one understood version matches. - 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.