CAIRLDocs
Integration

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

  1. Go to /home/f/{slug}/keys
  2. Find the key you want to configure in the Webhook Configuration panel
  3. Enter your HTTPS endpoint URL
  4. Click Save — a signing secret is generated and shown once
  5. 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:

AttemptDelay
1Immediate
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.


The mode field

Every event payload carries "mode": "test" or "mode": "live", matching the key that started the session. Route on it: a "test" event confirms your plumbing and should not update a real user's verification status.

Which events fire in test mode:

EventTest keys
verification.session.completedFires, with "mode": "test"
verification.session.failedFires on the same conditions as live, with "mode": "test"
enrollment.createdDoes not fire — no enrollment is recorded in test mode
vae.resolvedDoes not fire — no Verified Access Event is recorded in test mode

Because the last two events are suppressed in test mode, they carry "mode": "live" whenever they reach your endpoint.

The pairwise_sub on a test event is a random pws_v1_… value rather than the pairwise sub a live key returns — pws_v1_test_… from a test credential, pws_v1_demo_… from the keyless sandbox. It is minted fresh for each test event, so it differs between two test runs and it differs from the sub on the test token you exchange for afterwards. Join a test webhook to its session with session_id, not with pairwise_sub.


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...",
  "mode": "live",
  "session_id": "hvf_abc...",
  "partner_id": "partner-uuid",
  "pairwise_sub": "pws_v1_3aF9kQ2mZ8xR7tLpW1nB6yD4sH0cV5jE",
  "status": "complete",
  "scopes": ["age_18_plus", "identity_verified"],
  "completed_at": "2026-03-24T10:05:00.000Z"
}

pairwise_sub on a "mode": "live" event is the site-scoped subject identifier — the same value you receive as sub in the OAuth token. It is stable for a given user within your integration (so you can join webhook deliveries to that user), and is designed to be uncorrelatable with the same person on any other site's integration. CAIRL does not emit a global user identifier on the webhook surface. On a "mode": "test" event the field carries the synthetic, single-use value described above instead.

verification.session.failed

Fired when a session ends in failure (user abandoned, document rejected, etc.).

{
  "event": "verification.session.failed",
  "event_id": "evt_018f...",
  "mode": "live",
  "session_id": "hvf_xyz...",
  "partner_id": "partner-uuid",
  "failure_reason": "document_rejected",
  "failed_at": "2026-03-24T10:08:00.000Z"
}

verification.session.expired (planned)

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...",
  "mode": "live",
  "enrollment_id": "enrollment-uuid",
  "partner_id": "partner-uuid",
  "pairwise_sub": "pws_v1_3aF9kQ2mZ8xR7tLpW1nB6yD4sH0cV5jE",
  "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...",
  "mode": "live",
  "partner_id": "partner-uuid",
  "pairwise_sub": "pws_v1_3aF9kQ2mZ8xR7tLpW1nB6yD4sH0cV5jE",
  "claims": {
    "age_18_plus": true,
    "identity_verified": true
  },
  "resolved_at": "2026-03-24T11:00:00.000Z"
}

Delivery order

Webhook delivery happens after the billing write and user redirect:

  1. Billing event written to CAIRL database (atomic)
  2. User redirected to your redirect_uri
  3. Webhook delivered to your endpoint (async)

Webhook delivery failure does not block user redirect or billing.


Secret rotation

Rotate your webhook signing secret with the Rotate Secret button in the Webhook Configuration panel on /home/f/{slug}/keys. The old secret is invalidated immediately and the new one is shown once — be ready to update your environment variable as soon as you rotate. This button rotates only the webhook signing secret; it does not change your API key or OAuth client_secret.


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)));
  },
);

On this page