Skip to main content
WebhooksHMAC signature verification
Webhooks

HMAC signature verification

Make sure every webhook really came from Aly before your system acts on it.

The X-Aly-Signature header format and constant-time verification — Node, Python examples.

Every webhook delivery carries an HMAC-SHA256 signature in the X-Aly-Signatureheader. Verifying it ensures the payload came from Aly, hasn't been tampered with, and isn't a replay older than your tolerance window.

Header format

http
X-Aly-Signature: t=1748112900,v1=4f3c9e...d2

t is the unix timestamp (seconds) Aly built the signature.
v1 is the hex-encoded HMAC-SHA256 over t + "." + raw_body.

Verification algorithm

  1. Parse t and the v1 hex digest from the header.
  2. Compute expected = HMAC_SHA256(secret, "${t}.${raw_body}").
  3. Compare v1 to hex(expected) with a constant-time compare.
  4. Reject if |now - t| > 5 minutes.
Use the raw body
Verify against the raw request body bytes, before any JSON parsing or middleware re-serialization. Most signature-mismatch bugs come from a framework that reformats the body before you see it.

Node + Express

webhook.tstypescript
import { createHmac, timingSafeEqual } from "node:crypto";import express from "express"; const SECRET = process.env.ALY_WEBHOOK_SECRET!;const TOLERANCE_SECONDS = 5 * 60; const app = express(); // Capture raw body — Aly verifies against bytes, not re-parsed JSONapp.use(  express.raw({ type: "application/json", limit: "1mb" })); app.post("/aly/webhooks", (req, res) => {  const header = req.get("x-aly-signature") ?? "";  const parts = Object.fromEntries(    header.split(",").map((kv) => kv.split("="))  );  const t = Number(parts.t);  const v1 = parts.v1;   if (!t || !v1) return res.status(400).end();   // Reject stale or future signatures  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) {    return res.status(400).end();  }   const raw = (req.body as Buffer).toString("utf8");  const expected = createHmac("sha256", SECRET)    .update(`${t}.${raw}`)    .digest("hex");   const ok =    expected.length === v1.length &&    timingSafeEqual(Buffer.from(expected), Buffer.from(v1));  if (!ok) return res.status(400).end();   const event = JSON.parse(raw);  // ... enqueue event.id for processing ...  res.status(200).end();});

Python + Flask

webhook.pypython
import hmac, hashlib, timefrom flask import Flask, request, abort SECRET = "whsec_..."  # from registrationTOLERANCE = 5 * 60 app = Flask(__name__) @app.post("/aly/webhooks")def webhook():    header = request.headers.get("X-Aly-Signature", "")    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)    try:        t = int(parts["t"])        v1 = parts["v1"]    except (KeyError, ValueError):        abort(400)     if abs(int(time.time()) - t) > TOLERANCE:        abort(400)     raw = request.get_data()    mac = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256)    if not hmac.compare_digest(mac.hexdigest(), v1):        abort(400)     event = request.get_json(force=True)    # ... enqueue event["id"] for processing ...    return "", 200

Common failure modes

  • Body mutated by a middleware — biggest cause. Capture raw bytes before JSON parsing.
  • Wrong secret — rotated, or pulled from a different env. Rotate again to confirm.
  • Clock skew — receiver behind NTP. Sync, or widen the tolerance.
  • Encoding — read body as bytes, hash as bytes. UTF-8 surrogates in product names trip naive string handling.

Replay protection

The timestamp window prevents replays older than 5 minutes. To defend against in-window replay (e.g. an attacker who saw a delivery seconds ago), track event.id in a short-lived set and reject duplicates.

Updated

Was this page helpful?