Webhooks
A score is a point-in-time answer; webhooks are how you learn it changed. Every payload is signed, retries are aggressive, and delivery is at-least-once — build the receiver accordingly.
Event catalogue
| Event | Fires when | Act on it by |
|---|---|---|
| screen.deteriorated | An address you screened crosses into a worse band | Re-reviewing the customer or holding withdrawals |
| screen.sanctions_match | A previously-clear address matches a screened list | Freezing immediately — this is a legal fact, not a gradient |
| screen.batch_complete | A batch submission finishes | Fetching results; the payload carries counts, not rows |
| credit.health_factor_breach | A monitored wallet drops below your threshold | Margin call or collateral request |
| credit.liquidated | A monitored wallet is liquidated anywhere | Repricing the borrower, not just the position |
| route.settled | A payout reaches your configured confirmation depth | Releasing the recipient-facing confirmation |
| route.failed | A payout fails on chain | Re-quoting. Never blind-retry the same transaction. |
| treasury.policy_event | A multisig signer, threshold or module changes | Paging whoever owns that multisig. Highest-signal event we send. |
| treasury.exception_opened | Movement cannot be matched to a ledger entry | Assigning an owner while the context is fresh |
| coverage.degraded | A chain we ingest falls materially behind head | Deciding whether to keep serving decisions on that chain |
Payload shape
POST /your/endpoint
X-FTAI-Signature: t=1785363500,v1=8f3c…
X-FTAI-Event-Id: evt_7Kq2…
Content-Type: application/json
{
"id": "evt_7Kq2…",
"type": "screen.deteriorated",
"created_at": "2026-07-30T09:12:44Z",
"data": {
"chain": "ethereum",
"address": "0x7a25…f3b1",
"previous": { "risk_score": 22, "band": "clear" },
"current": { "risk_score": 71, "band": "elevated" },
"new_reasons": [ { "code": "MIXER_PROXIMITY", "weight": 34 } ],
"as_of_block": 20914773
}
}
Deterioration events always carry both previous and current, so your receiver never has to hold state to know what changed.
Verify the signature
The signature is an HMAC-SHA256 over {timestamp}.{raw_body} using your endpoint's signing secret. Verify against the raw bytes — parsing and re-serialising the JSON first will change the signature and fail.
// Node — Express, raw body
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('=')));
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.' + rawBody)
.digest('hex');
// constant-time compare — never ===
const ok = crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1));
// reject anything older than 5 minutes
const fresh = Math.abs(Date.now()/1000 - Number(parts.t)) < 300;
return ok && fresh;
}
Delivery semantics
- At-least-once. A network timeout on our side means you may receive the same
X-FTAI-Event-Idtwice. Deduplicate on that id; it is stable across retries. - Not ordered. Two events about the same address may arrive out of order. Use
created_atandas_of_blockto decide which is newer — not arrival order. - Retries. Any non-2xx or a timeout past 10 seconds retries at 10s, 1m, 5m, 30m, 2h, 6h, then hourly for 24 hours. After that the event is dead-lettered and retrievable from
GET /v1/webhooks/{id}/failures. - Return fast. Acknowledge with 200 and process asynchronously. A receiver that does work inline will eventually time out under a burst and turn one slow event into a retry storm.
Managing endpoints
POST /v1/webhooks // create, returns signing secret once
GET /v1/webhooks // list, with recent failure counts
PATCH /v1/webhooks/{id} // change url or event subscriptions
POST /v1/webhooks/{id}/test // send a synthetic event of any type
DELETE /v1/webhooks/{id}
Register more than one endpoint if different events belong to different teams — sanctions matches to your compliance queue, policy events to the treasury pager. Subscribing one endpoint to everything and fanning out internally works, but it makes the paging rules someone else's problem.