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:

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