Webhooks
Signed events for sessions, users, transactions and the Travel Rule.
#Set up
Console: Developers → Webhooks, or by API:
curl -X PATCH https://{{HOST}}/v3/webhook/ -H "x-api-key: sk_live_..." -H "Content-Type: application/json" \
-d '{"webhook_url":"https://yourapp.com/webhooks/kyc","webhook_version":"v3"}'Add up to 20 more endpoints, each with its own events and secret, with webhook destinations.
#Events
| Event | When |
|---|---|
status.updated | A session changes status |
data.updated | Session data changes (for example after a manual edit) |
user.status.updated | A user becomes active, flagged or blocked |
user.data.updated | A user's data changes |
transaction.created, transaction.status.updated | Transaction monitoring |
transaction.review, transaction.blocked | A transaction needs review or was blocked |
alert.created, alert.updated | Monitoring alerts |
kyb.approved, kyb.in_review, kyb.declined | Business verification results |
travel_rule.status.updated | A Travel Rule transfer changes status |
#Payload (v3)
{
"event_id": "evt_...", "webhook_type": "status.updated", "timestamp": 1762640000,
"environment": "live", "session_id": "ver_...", "session_number": 1042,
"status": "Approved", "workflow_id": "wf_...", "workflow_version": 3,
"vendor_data": "user-123", "metadata": { "plan": "gold" },
"decided_by": "system", "decision": { "...": "full decision, as from GET /decision/" }
}decision comes with Approved, Declined, In Review and Abandoned. resubmit_info comes with Resubmitted. When ongoing AML moves a user to review, the event has "trigger": "ongoing_monitoring".
#Verify the signature
Each delivery is signed with HMAC-SHA256 using your webhook secret, and re-signed with a fresh X-Timestamp on each retry.
| Header | Signed over |
|---|---|
X-Signature-V2 (use this) | The JSON body with keys sorted and no spaces, so it still works after your framework parses the JSON |
X-Signature | The exact raw body |
X-Signature-Simple | {timestamp}:{session_id}:{status}:{webhook_type} |
Reject a request if the timestamp is more than 300 seconds old, compare signatures in constant time, and ignore an event_id you have already handled.
const crypto = require('crypto');
const canon = (v) => Array.isArray(v) ? `[${v.map(canon).join(',')}]`
: v && typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`
: JSON.stringify(v);
app.post('/webhooks/kyc', express.json(), (req, res) => {
const ts = Number(req.get('x-timestamp'));
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401);
const want = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET).update(canon(req.body)).digest('hex');
const got = req.get('x-signature-v2') || '';
if (got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) return res.sendStatus(401);
// dedupe on req.body.event_id, then act on req.body.status
res.sendStatus(200);
});import hmac, hashlib, json, time
def verify(body: dict, headers, secret: str) -> bool:
if abs(time.time() - int(headers["X-Timestamp"])) > 300:
return False
canon = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
want = hmac.new(secret.encode(), canon.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(want, headers.get("X-Signature-V2", ""))#Retries
Answer with a 2xx within 10 seconds. Failed deliveries are retried 8 times with growing gaps. Every attempt is in the delivery log in the console, where you can also send a test event.
#Rotating the secret
Rotate in the console or with {"rotate_secret": true} on PATCH /v3/webhook/. For 24 hours the old secret still signs *-Previous headers, so you can switch without missing events.