Integration

Webhooks

Signed events for sessions, users, transactions and the Travel Rule.

#Set up

Console: Developers → Webhooks, or by API:

bash
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

EventWhen
status.updatedA session changes status
data.updatedSession data changes (for example after a manual edit)
user.status.updatedA user becomes active, flagged or blocked
user.data.updatedA user's data changes
transaction.created, transaction.status.updatedTransaction monitoring
transaction.review, transaction.blockedA transaction needs review or was blocked
alert.created, alert.updatedMonitoring alerts
kyb.approved, kyb.in_review, kyb.declinedBusiness verification results
travel_rule.status.updatedA Travel Rule transfer changes status

#Payload (v3)

json
{
  "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.

HeaderSigned 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-SignatureThe 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.

js
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);
});
python
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.