Signing
Every webhook is signed with HMAC-SHA256. Verify the signature on every request — never trust an unsigned body.
Headers
Each POST from Allison carries three signing headers:
X-Allison-Signature— the HMAC value. Format:v1=<hex>. Thev1=prefix reserves space for future signing versions.X-Allison-Timestamp— Unix seconds when the request was signed. Required as part of the signed payload.X-Allison-Event-Id— the event's stable id (shared across retries). Use this for deduplication.
The formula
signed_payload = timestamp + "." + raw_request_body signature = HMAC_SHA256(secret, signed_payload) header_value = "v1=" + hex_lowercase(signature)
Critical: use the exact bytes of the HTTP body, before any parser touches them. If you re-serialize a parsed JSON object, whitespace and key ordering will change and your computed signature will not match ours. Capture the raw body first, verify, then parse.
Verify in Node.js
import { createHmac, timingSafeEqual } from 'crypto';
const MAX_AGE_SEC = 300; // 5 minutes
function verifyAllisonWebhook(req, rawBody, secret) {
const sigHeader = req.headers['x-allison-signature'];
const timestamp = req.headers['x-allison-timestamp'];
if (!sigHeader?.startsWith('v1=') || !timestamp) {
return false;
}
// Replay protection — reject timestamps too far from now.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > MAX_AGE_SEC) {
return false;
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = sigHeader.slice('v1='.length);
if (received.length !== expected.length) return false;
try {
return timingSafeEqual(Buffer.from(received), Buffer.from(expected));
} catch {
return false;
}
}Verify in Python
import hmac
import hashlib
import time
MAX_AGE_SEC = 300
def verify_allison_webhook(headers, raw_body: bytes, secret: str) -> bool:
sig_header = headers.get("x-allison-signature", "")
timestamp = headers.get("x-allison-timestamp", "")
if not sig_header.startswith("v1=") or not timestamp:
return False
try:
age = abs(time.time() - float(timestamp))
except ValueError:
return False
if age > MAX_AGE_SEC:
return False
signed_payload = f"{timestamp}.{raw_body.decode()}".encode()
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
received = sig_header[len("v1="):]
return hmac.compare_digest(received, expected)Rotating a secret
Secrets can be rotated at any time — from the dashboard under Settings → Webhooks → Rotate, or via POST /v1/webhook-subscriptions/{id}/rotate-secret.
Rotation is atomic. The old secret stops working immediately — there's no overlap window — so plan to update your receiver in the same session. The response includes the new plaintext exactly once; subsequent reads expose only secret_preview.
If you're rotating on suspicion of compromise, rotate first and update the receiver second. That order closes the window where an attacker could still sign valid-looking traffic.
Common mistakes
- Hashing the parsed body. If your framework auto-parses JSON before your handler runs, re-serializing changes whitespace and the signature won't match. Keep the raw body.
- Using
===or==. String comparison leaks byte positions via response time. Use a constant-time comparator (timingSafeEqual,hmac.compare_digest). - Skipping the timestamp check. A valid signature on captured traffic can be replayed forever if you don't bound the age.
- Logging the secret. Never write the secret to logs, error reports, or screenshots. Load it from your secret manager.