Skip to main content

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
  • t is when the delivery was signed, in Unix seconds.
  • v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw body>, keyed with the secret.

To verify:

  1. Read the raw body bytes, before any JSON parsing.
  2. Split the header on ,; take t and every v1 (there may be more than one).
  3. Refuse a t more than 15 minutes from your clock: it is a replayed or stale request.
  4. Compute HMAC-SHA256(secret, t + . + body) and compare it with each v1 in 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.