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
| Event | When | Payload |
|---|---|---|
screening.completed | After every screen (POST /v1/screenings/check). | The screening result (PII-light). |
monitoring.alert | When 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>
| Header | Meaning |
|---|---|
X-AML-Event | The event type — also present as event in the body. |
X-AML-Timestamp | Unix seconds when the payload was signed. Bound into the signature to defeat replay. |
X-AML-Signature | sha256=<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.
| Field | Meaning |
|---|---|
classification | The screening's top classification — sanctions, pep or adverse_media. |
pepTiers | Distinct 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. |
sourceLists | Distinct watchlists that produced a match (OFAC, EU, UK, UN, WIKIDATA). Empty on a clean screen. |
matchCount | How many matches drove the decision. Excludes deceased-suppressed matches, which the decision excludes too. |
adverseMediaStatus | Skipped / Ok / Stale / Unavailable. Do not read an absence of media results as "clean" when this is Unavailable — we did not look. |
monitoring.alert carries the same
five fields, describing the re-screen that triggered it.The signature recipe (exact)
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)
2xx quickly and do slow work asynchronously.
Reject anything that doesn't match, and reject old timestamps to defeat replays.