Webhooks
Receive real-time event notifications from CAIRL using signed HTTP POST requests.
Overview
CAIRL sends outbound webhook events to your configured endpoint as HTTP POST requests. Each delivery is signed with HMAC-SHA256 so you can verify it came from CAIRL.
Setup
- Go to Dashboard → API Keys
- Find the key you want to configure
- Enter your HTTPS endpoint URL in the Webhook Configuration section
- Click Save — a signing secret is generated and shown once
- Store the secret securely in your environment variables
Live keys require an https:// endpoint. Test keys accept http://localhost for local development.
Verifying signatures
Every request includes an X-CAIRL-Signature header:
X-CAIRL-Signature: sha256=<64-char hex>Verification (Node.js):
import crypto from 'crypto';
function verifyWebhookSignature(secret, rawBody, receivedSig) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Timing-safe comparison
if (expected.length !== receivedSig.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(receivedSig, 'utf8'),
);
}
// Express handler
app.post('/webhooks/cairl', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-cairl-signature'];
const secret = process.env.CAIRL_WEBHOOK_SECRET;
if (!verifyWebhookSignature(secret, req.body, sig)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Handle event...
res.status(200).send('OK');
});Important: Verify signatures against the raw request body before parsing JSON. Parsing first may alter whitespace and invalidate the signature.
Retry schedule
CAIRL retries failed deliveries (non-2xx response or network error) on an exponential backoff schedule:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | +1 minute |
| 3 | +5 minutes |
| — | Marked failed |
After 3 failed attempts, the delivery is marked failed and no further retries occur.
Make your endpoint idempotent. The same event_id may be delivered more than once — use event_id to deduplicate.
Event catalog
verification.session.completed
Fired when a user completes the CAIRL verification flow and an authorization code is issued.
{
"event": "verification.session.completed",
"event_id": "evt_018e...",
"session_id": "hvf_abc...",
"partner_id": "partner-uuid",
"user_id": "user-uuid",
"status": "complete",
"scopes": ["age_18_plus", "identity_verified"],
"completed_at": "2026-03-24T10:05:00.000Z"
}verification.session.failed
Fired when a session ends in failure (user abandoned, document rejected, etc.).
{
"event": "verification.session.failed",
"event_id": "evt_018f...",
"session_id": "hvf_xyz...",
"partner_id": "partner-uuid",
"failure_reason": "document_rejected",
"failed_at": "2026-03-24T10:08:00.000Z"
}verification.session.expired (coming soon)
This event is planned but not yet available. Sessions expire silently after 30 minutes;
poll GET /api/verify/hvf-session/{id} to detect expiry, or handle the session_expired
error on the redirect return. See the session docs.
enrollment.created
Fired the first time a user authenticates with your application (new enrollment). Not fired for returning users.
{
"event": "enrollment.created",
"event_id": "evt_019b...",
"enrollment_id": "enrollment-uuid",
"partner_id": "partner-uuid",
"user_id": "user-uuid",
"created_at": "2026-03-24T10:05:10.000Z"
}vae.resolved
Fired on each Verified Access Event — when your application exchanges an authorization code for a token on a returning user. Billing is recorded at the same time.
{
"event": "vae.resolved",
"event_id": "evt_019c...",
"partner_id": "partner-uuid",
"user_id": "user-uuid",
"claims": {
"age_18_plus": true,
"identity_verified": true
},
"resolved_at": "2026-03-24T11:00:00.000Z"
}Delivery order
Webhook delivery is always after the billing write and user redirect:
- Billing event written to CAIRL database (atomic)
- User redirected to your
redirect_uri - Webhook delivered to your endpoint (async)
Webhook delivery failure never blocks user redirect or billing.
Secret rotation
Rotate your webhook secret from Dashboard → API Keys → Rotate Secret. The old secret is invalidated immediately — update your environment variable before rotating to avoid a gap in signature validation.
Responding to events
Return a 2xx status to acknowledge delivery. CAIRL does not inspect the response body.
If your handler needs more time than a typical request timeout, acknowledge the event immediately with 200 OK and process it asynchronously.
app.post('/webhooks/cairl', express.raw({ type: 'application/json' }), (req, res) => {
// Verify first, acknowledge immediately
if (!verifyWebhookSignature(secret, req.body, req.headers['x-cairl-signature'])) {
return res.status(400).send('Invalid signature');
}
res.status(200).send('OK'); // Acknowledge before processing
// Process asynchronously
setImmediate(() => processEvent(JSON.parse(req.body)));
});