Verifying signatures

Every webhook delivery carries a Zillo-Signatureheader. Verify it on every request — an attacker who guesses your URL can send fake payloads if you don't.

Header shape

Zillo-Signature: t=1717592400,v1=3a1b9d7e2c4f8a6b0d5e7c1f9a3b5d7e2c4f8a6b0d5e7c1f9a3b5d7e2c4f8a6b
  • t = unix seconds when we signed the payload.
  • v1 = hex-encoded HMAC-SHA256 over `${t}.${raw_body}`.

Node.js / TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyZilloSignature(opts: {
  header: string;     // value of the Zillo-Signature header
  body: string;       // raw request body — read BEFORE JSON.parse
  secret: string;     // your endpoint's signing secret
  toleranceSeconds?: number; // default 300 (5 min)
}): boolean {
  const tolerance = opts.toleranceSeconds ?? 300;
  const parts = Object.fromEntries(
    opts.header.split(",").map((p) => {
      const i = p.indexOf("=");
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  const v1 = parts.v1 as string;
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > tolerance) return false;

  const mac = createHmac("sha256", opts.secret);
  mac.update(`${t}.${opts.body}`);
  const expected = mac.digest("hex");
  if (expected.length !== v1.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Python

import hmac, hashlib, time

def verify_zillo_signature(header: str, body: bytes, secret: str, tolerance_seconds: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    v1 = parts["v1"]
    if abs(int(time.time()) - t) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Pitfalls

  • Sign the raw body, not parsed JSON. Frameworks that auto-parse the body before your code runs will change whitespace and break the HMAC.
  • Compare in constant time. Use timingSafeEqual / hmac.compare_digest — never ===.
  • Reject stale timestamps. A 5-minute tolerance kills most replay attempts.