Verify a delivery
Every delivery is signed with the subscription's secret. Check the signature before you trust the body.
X-Zaitrack-Signature: t=1761631925,v1=5f2b…c9a1
tis when the delivery was signed, in Unix seconds.v1is the lowercase hex HMAC-SHA256 of the string<t>.<raw body>, keyed with the secret.
To verify:
- Read the raw body bytes, before any JSON parsing.
- Split the header on
,; taketand everyv1(there may be more than one). - Refuse a
tmore than 15 minutes from your clock: it is a replayed or stale request. - Compute HMAC-SHA256(secret,
t+.+ body) and compare it with eachv1in constant time. One match is enough.
When you rotate the secret (POST /v1/webhooks/{id}/secrets:rotate), the old secret keeps signing beside the new
one for the rotation's overlap (24 hours unless you name another, at most 168): the header then carries two v1
values under one t, and the code below accepts the delivery when either matches. Deploy the new secret within
the overlap.
TypeScript (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_S = 15 * 60;
export function verifyZaitrackSignature(
secret: string,
header: string | undefined,
rawBody: Buffer,
nowS: number = Date.now() / 1000,
): boolean {
if (!header) return false;
let t = 0;
const signatures: string[] = [];
for (const piece of header.split(",")) {
const [key, value = ""] = piece.trim().split("=", 2);
if (key === "t") t = Number(value);
else if (key === "v1" && value) signatures.push(value);
}
if (!Number.isSafeInteger(t) || t <= 0 || signatures.length === 0) return false;
if (Math.abs(nowS - t) > TOLERANCE_S) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
With Express, keep the raw body for that route:
app.post("/zaitrack", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyZaitrackSignature(process.env.ZAITRACK_WEBHOOK_SECRET!, req.get("X-Zaitrack-Signature"), req.body)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(204); // answer fast; do the work after
handle(event);
});
Python
import hashlib
import hmac
import time
TOLERANCE_S = 15 * 60
def verify_zaitrack_signature(secret: str, header: str | None, raw_body: bytes, now: float | None = None) -> bool:
if not header:
return False
t, signatures = 0, []
for piece in header.split(","):
key, _, value = piece.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1" and value:
signatures.append(value)
if t <= 0 or not signatures:
return False
if abs((time.time() if now is None else now) - t) > TOLERANCE_S:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
With Flask, read request.get_data() (the raw bytes) and request.headers.get("X-Zaitrack-Signature").
Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
const tolerance = 15 * time.Minute
// Verify reports whether header is a valid X-Zaitrack-Signature for body.
func Verify(secret, header string, body []byte, now time.Time) bool {
var t int64
var signatures []string
for _, piece := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(piece), "=")
switch key {
case "t":
t, _ = strconv.ParseInt(value, 10, 64)
case "v1":
signatures = append(signatures, value)
}
}
if t <= 0 || len(signatures) == 0 {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, s := range signatures {
if got, err := hex.DecodeString(s); err == nil && hmac.Equal(got, expected) {
return true
}
}
return false
}
In an http.Handler, read the body with io.ReadAll(r.Body) and pass r.Header.Get("X-Zaitrack-Signature").
Test it
POST /v1/webhooks/{id}:test sends a signed delivery to your endpoint now: zaitrack.webhook.test.v1, or with
{"event": "<name>"} any event of the catalogue with its sample data. Your handler should answer 2xx
after verifying it. See Test a subscription.