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 and never a matched entity'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",
  "classification": "sanctions",
  "pepTiers": ["Foreign"],
  "sourceLists": ["EU", "OFAC"],
  "matchCount": 3,
  "adverseMediaStatus": "Ok"
}

See Score & decision for exactly how decision, riskScore and riskBand are computed.

The outcome summary

The five fields from classification onward are the same categories the API response exposes, so you can route on the outcome — and derive your own reason codes — without calling GET /v1/screenings/{id}. They are categories, not identities: a tier and a list name say what kind of exposure was found, never who was found, which is what keeps the event PII-light.

FieldMeaning
classificationThe screening's top classification — sanctions, pep or adverse_media.
pepTiersDistinct PEP tiers across the matches: Domestic (PEP1), Foreign (PEP2), InternationalOrganization (PEP3), FamilyOrAssociate (PEP4). A set, not a single value — the tiers are not ranked against one another. Empty when no PEP matched.
sourceListsDistinct watchlists that produced a match (OFAC, EU, UK, UN, WIKIDATA). Empty on a clean screen.
matchCountHow many matches drove the decision. Excludes deceased-suppressed matches, which the decision excludes too.
adverseMediaStatusSkipped / Ok / Stale / Unavailable. Do not read an absence of media results as "clean" when this is Unavailable — we did not look.
Both arrays are always present and always sorted, so identical outcomes serialize to identical bytes; an empty array means "none". Their length is bounded by the number of distinct categories, not by the match count, so a wide screen cannot produce an oversized body. monitoring.alert carries the same five fields, describing the re-screen that triggered it.

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.