Webhooks
Webhooks

Signed, replay-safe webhooks.

Set a webhookUrl at onboarding and we POST signed JSON when a result is produced or changes. Deliveries are best-effort with retries; verify the HMAC-SHA256 signature on every payload before you trust it.

Events

EventWhenPayload
screening.completedAfter every screen (POST /v1/screenings/check).The screening result (PII-light).
monitoring.alertWhen a monitored subject's outcome changes on a watchlist update.The updated result for that subject.

Delivery headers

Each delivery is a POST with Content-Type: application/json and three signing headers:

# Headers on every webhook delivery
Content-Type:    application/json
X-AML-Event:     screening.completed
X-AML-Timestamp: 1718764800
X-AML-Signature: sha256=<hex>
HeaderMeaning
X-AML-EventThe event type — also present as event in the body.
X-AML-TimestampUnix seconds when the payload was signed. Bound into the signature to defeat replay.
X-AML-Signaturesha256=<hex> — HMAC-SHA256 over "{timestamp}.{rawBody}", lowercase hex.

Example payload — screening.completed

Bodies are camelCase and PII-light: they carry your userReference, never the subject's name.

{
  "event": "screening.completed",
  "screeningId": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
  "userReference": "customer-8821",
  "isMatch": true,
  "score": 0.97,
  "decision": "Fail",
  "riskScore": 83,
  "riskBand": "Medium",
  "createdAt": "2026-06-20T10:15:00+00:00"
}

The signature recipe (exact)

Sign over the raw request body bytes — do not re-serialize the parsed JSON, or whitespace/ordering differences will break the match. Compute HMAC-SHA256("{timestamp}.{rawBody}") with your whsec_… secret, hex-encode lowercase, prefix with sha256=, and compare in constant time.

Node.js

import crypto from "node:crypto";

// rawBody: the exact request body string. headers: lower-cased request headers.
export function verifyAmlWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = headers["x-aml-timestamp"];
  const received = headers["x-aml-signature"];
  if (!timestamp || !received) return false;

  // Reject stale deliveries (replay protection).
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (Number.isNaN(age) || age > toleranceSeconds) return false;

  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: capture the raw body, then verify before parsing.
// app.post("/aml-hooks", express.raw({ type: "application/json" }), (req, res) => {
//   const raw = req.body.toString("utf8");
//   if (!verifyAmlWebhook(raw, req.headers, process.env.AML_WHSEC)) return res.sendStatus(401);
//   const event = JSON.parse(raw); res.sendStatus(200);
// });

Python

import hashlib, hmac, time

def verify_aml_webhook(raw_body: bytes, headers: dict, secret: str,
                       tolerance_seconds: int = 300) -> bool:
    timestamp = headers.get("X-AML-Timestamp") or headers.get("x-aml-timestamp")
    received = headers.get("X-AML-Signature") or headers.get("x-aml-signature")
    if not timestamp or not received:
        return False

    # Reject stale deliveries (replay protection).
    try:
        if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
            return False
    except ValueError:
        return False

    signed = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")
    expected = "sha256=" + hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)
Respond fast. Return 2xx quickly and do slow work asynchronously. Reject anything that doesn't match, and reject old timestamps to defeat replays.