Webhooks
Set a webhook URL and a signing secret on the project in the dashboard. The URL must be HTTPS on port 443 or 8443, without credentials, and not a private or internal host; each delivery times out after 5 seconds. Defraudo then POSTs signed JSON to the URL:
| Webhook | When |
|---|---|
| Decision delivery | For every scored event with an allow, block or review verdict, sync or async. Body: event_id, risk_score, max_possible, decision, breakdown, flags, trust_override, hard_block_hit, latency_ms, environment, plus actions when non-empty. |
| Case resolution | An analyst or API call decides a review case. Body: event_id, case_id, type, decision, reason, actor_ref, decided_at. |
| Case woke | A snoozed case returns to the open queue. Body: type, case_id, event_id, woke_at. |
Return any 2xx to acknowledge. A non-2xx, a timeout or a redirect counts as failure and is retried with backoff, at most 15 minutes apart, until acknowledged. Delivery is at least once. Deduplicate decision webhooks on event_id. Case webhooks carry an internal event_id (or null) that is shared by the resolution and woke messages, so deduplicate those on message type + case_id + decided_at / woke_at. While a project has a URL but no secret, nothing is sent and held deliveries go out once a secret is set.
Verify the signature
Each request has the header x-antifrod-signature: t=<unix-ms>,v1=<hex>[,v1=<hex>...]. The signature is HMAC-SHA256(secret, "<t>.<raw body>") in hex. Rules:
- Use the raw request body, not re-serialised JSON.
- Accept the request if any
v1matches, compared in constant time. During the 24 hours after a secret rotation the header carries signatures for the new secret and up to three recently replaced ones. - Reject if
tis more than 5 minutes from your clock.
- Node.js
- Python
- PHP
import crypto from 'node:crypto';
export function verify(rawBody, header, secret, maxSkewMs = 300_000) {
let t;
const signatures = [];
for (const part of header.split(',')) {
const [key, value] = [part.slice(0, part.indexOf('=')).trim(), part.slice(part.indexOf('=') + 1).trim()];
if (key === 't') t = parseInt(value, 10);
if (key === 'v1' && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value);
}
if (!Number.isFinite(t) || signatures.length === 0) return false;
if (Math.abs(Date.now() - t) > maxSkewMs) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return signatures.some((signature) => crypto.timingSafeEqual(Buffer.from(signature, 'hex'), expected));
}
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, max_skew_ms: int = 300_000) -> bool:
t = None
signatures = []
for part in header.split(','):
key, _, value = part.strip().partition('=')
if key == 't' and value.isdigit():
t = int(value)
elif key == 'v1' and len(value) == 64:
signatures.append(value.lower())
if t is None or not signatures:
return False
if abs(time.time() * 1000 - t) > max_skew_ms:
return False
expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(signature, expected) for signature in signatures)
<?php
function verify(string $rawBody, string $header, string $secret, int $maxSkewMs = 300000): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) $t = (int) $value;
if ($key === 'v1' && preg_match('/^[0-9a-f]{64}$/i', $value)) $signatures[] = strtolower($value);
}
if ($t === null || !$signatures) return false;
if (abs((int) (microtime(true) * 1000) - $t) > $maxSkewMs) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) return true;
}
return false;
}