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.
{
"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.
← API reference
Next: Errors →