Skip to main content

Webhooks

Get real-time notifications throughout the complete verification session lifecycle, from session creation to completion or timeout.

Completion and timeout delivery recovery​

For newly completed verifications, Portal durably queues configured completion webhooks. A completion payload includes an additive eventId that remains stable across delivery retries. Authenticate the signed raw request body and deduplicate by that identity (or the completed session). Delivery remains at-least-once: a receiver can accept a request even when its acknowledgement is lost.

Verification success does not wait for webhook delivery. Temporary failures are retried independently of the browser session, within a bounded window. Editing an endpoint does not redirect an already queued event.

Endpoint health, automatic pause and recovery​

Each endpoint has a finite delivery budget, so one failing receiver never delays your other endpoints or verifications:

  • Retrying: after repeated failures, SafePassage slows down and sends one request at a time until your endpoint responds with a 2xx again. A Retry-After header on HTTP 429 or 503 is respected (up to one hour).
  • Paused automatically: delivery is paused when an endpoint keeps rejecting requests (for example HTTP 401, 403, 404 or 410, or a non-public destination) for at least an hour, or keeps failing for other reasons (server errors, timeouts, connection, TLS or DNS failures) for at least 24 hours. The Dashboard shows that delivery is paused and the reason.
  • While paused, new events for that endpoint are recorded as not delivered and are not sent. Your endpoint settings are unchanged. Other endpoints keep receiving events.
  • To recover, fix the receiver or its configuration, then use Test and resume in Configuration > Webhooks. A signed test event must succeed before delivery resumes. Resuming does not resend past events.
  • Redeliver sends events from the last 7 days that were not delivered, in batches of up to 500, once the endpoint is delivering again. Each redelivered event keeps its original eventId and body and is signed with the endpoint's current secret, so verify signatures as usual and deduplicate by eventId. Events are only redelivered to the URL they were created for; if you changed the URL, those events are not redirected.
  • Disabling an endpoint stops new events and any queued retries for it. Re-enable it to receive new events; recent undelivered events can then be redelivered.

Quick Start​

1. Configure Your Webhook​

In the SafePassage Dashboard:

  1. Go to Configuration > Webhooks
  2. Click Add Endpoint
  3. Enter your HTTPS webhook URL
  4. Select the events you want to receive
  5. Save your webhook secret (shown only once)

You can configure up to 20 webhook endpoints, each with its own URL, events, and secret.

Webhook destinations must resolve to public Internet addresses. Private, loopback, link-local, metadata and other non-public destinations are rejected, including hostnames with mixed public/non-public DNS answers. Destinations are checked when configured and again when connecting, including redirect targets and retries. Public redirects, ports, paths and query strings remain supported. DNS changes can therefore make a previously valid endpoint unavailable; a successful test is not a guarantee of future delivery. Payload and signature formats are unchanged.

2. Handle Webhook Events​

app.post('/webhooks/safepassage', (req, res) => {
const { event, data } = req.body;

switch (event) {
case 'session.started':
// Session created and ready for user
console.log(`Session started: ${data.sessionId}`);
if (data.externalUserId) {
console.log(`For user: ${data.externalUserId}`);
}
break;

case 'verification.completed':
// User verified successfully
console.log(`User verified: ${data.sessionId}`);
break;

case 'verification.failed':
// User failed verification
console.log(`Verification failed: ${data.reason}`);
break;

case 'verification.cancelled':
// User cancelled verification before completion
console.log(`Verification cancelled: ${data.sessionId}`);
break;

case 'session.timeout':
// Session expired without completion
console.log(`Session timed out: ${data.sessionId}`);
break;
}

res.json({ received: true });
});

Event Payloads​

All webhook events include externalUserId when provided during session creation, enabling seamless correlation with your user systems.

Session Started​

Triggered immediately after session creation:

{
"event": "session.started",
"timestamp": "2024-01-20T10:05:00.000Z",
"tenantId": "9fe431ea-ad69-46d7-a201-c3481fffdb99",
"test": false,
"data": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"verificationMode": "L1",
"challengeAge": 25,
"timestamp": "2024-01-20T10:05:00.000Z",
"externalUserId": "user-123"
}
}

Delivery note: This webhook is dispatched immediately after session creation, but delivery can be delayed by network conditions and retry logic.

Verification Completed​

Triggered when user successfully passes verification:

{
"event": "verification.completed",
"timestamp": "2024-01-20T10:05:00.000Z",
"tenantId": "9fe431ea-ad69-46d7-a201-c3481fffdb99",
"test": false,
"data": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"verified": true,
"verificationMode": "L1",
"challengeAge": 25,
"timestamp": "2024-01-20T10:05:00.000Z",
"externalUserId": "user-123"
}
}

Verification Failed​

Triggered when user fails verification:

{
"event": "verification.failed",
"timestamp": "2024-01-20T10:05:00.000Z",
"tenantId": "9fe431ea-ad69-46d7-a201-c3481fffdb99",
"test": false,
"data": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"verified": false,
"verificationMode": "L1",
"reason": "age_not_met",
"challengeAge": 25,
"timestamp": "2024-01-20T10:05:00.000Z",
"externalUserId": "user-456"
}
}

Verification Cancelled​

Triggered when user closes or abandons verification before completion:

{
"event": "verification.cancelled",
"timestamp": "2024-01-20T10:05:00.000Z",
"tenantId": "9fe431ea-ad69-46d7-a201-c3481fffdb99",
"test": false,
"data": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"verified": false,
"verificationMode": "L1",
"reason": "cancelled",
"challengeAge": 25,
"timestamp": "2024-01-20T10:05:00.000Z",
"externalUserId": "user-456"
}
}

Session Timeout​

Triggered when session expires without completion:

{
"event": "session.timeout",
"timestamp": "2024-01-20T10:15:00.000Z",
"tenantId": "9fe431ea-ad69-46d7-a201-c3481fffdb99",
"test": false,
"data": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"verificationMode": "L1",
"challengeAge": 25,
"externalUserId": "user-789",
"expiresAt": "2024-01-20T10:15:00.000Z",
"timestamp": "2024-01-20T10:15:00.000Z"
}
}

Webhook Security (Optional)​

If you configure a webhook secret in the Dashboard, verify signatures using the raw request body:

const express = require('express');
const crypto = require('crypto');

const app = express();

// Capture raw body for webhook signature verification
app.use('/webhooks', express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));

app.post('/webhooks/safepassage', (req, res) => {
const signature = req.headers['x-safepassage-signature'];

// Verify signature if webhook secret is configured
if (process.env.WEBHOOK_SECRET && signature) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.rawBody)
.digest('hex');

if (signature !== expectedSignature) {
console.error('Webhook signature mismatch');
return res.status(401).json({ error: 'Invalid signature' });
}
}

// Process webhook
const { event, data } = req.body;
console.log(`Received ${event} for session ${data.sessionId}`);

res.json({ received: true });
});

Important: Always compute the HMAC over the raw request body bytes. Using parsed and re-encoded JSON (for example, json_encode($payload) after decoding) can change the payload format and break signature verification.

Testing Webhooks​

Test from Dashboard​

  1. Go to Configuration
  2. Click Test Webhook
  3. Check your endpoint logs

Local Development​

Use any HTTPS tunnel for local testing (ngrok, cloudflared, etc.):

# Terminal 1
npm run dev

# Terminal 2
ngrok http 3000

# Use the ngrok URL in Dashboard

For session.timeout, the ten-minute session deadline remains authoritative even if notification arrives later. Timeout payloads also include a stable eventId; retries preserve the signed body and destination. Endpoint changes do not redirect an already queued timeout.

Retry Behavior​

  • Newly completed verifications use durable retries with capped exponential backoff (up to ten minutes between attempts) for up to 24 hours. Events your endpoint did not accept in that window are recorded as not delivered and can be redelivered (see above).
  • Timeout notifications use durable recovery with the configured attempt count (three by default) and exponential delay, scheduled at five-second granularity, within the same 24-hour window.
  • Other event types retain the configured in-request retry sequence (three attempts by default). Authentication or configuration rejections (such as HTTP 401 or 404) are not retried in-request, and paused endpoints are skipped.
  • While an endpoint keeps failing, retries are paced per endpoint rather than per event.
  • Each attempt times out after 15 seconds
  • Returns 2xx status code = success
  • Any other status = retry

Timestamps​

Webhook payloads include two timestamps:

  • Top-level timestamp: event creation time for durable completion delivery, or the session deadline for timeout delivery (both stable on retries); send time for other events
  • data.timestamp: when the underlying event occurred

Multiple Webhook Endpoints​

SafePassage supports up to 20 webhook endpoints per tenant. Each endpoint can:

  • Subscribe to specific event types
  • Have its own signing secret
  • Be enabled/disabled independently (disabling also stops queued retries)
  • Report its own delivery health, last successful delivery and undelivered events
  • Be scoped to specific API key pairs (for multi-site configurations)

API Key Pair Scoping​

For multi-site deployments, you can scope webhook endpoints to specific API key pairs. This ensures each site only receives webhook notifications for verifications initiated with that site's API keys.

How it works:

  • API keys are created in pairs (public pk_ + private sk_ keys share a pairId)
  • Webhook endpoints can be scoped to a specific pairId
  • When a verification event occurs, only endpoints matching the session's API key pair receive the webhook
  • Endpoints with no pairId (account-wide) receive events from all API keys

Example scenario:

  • Site A uses API key pair with pairId: "pair-abc"
  • Site B uses API key pair with pairId: "pair-xyz"
  • Webhook endpoint scoped to pairId: "pair-abc" only receives events from Site A verifications
  • Account-wide endpoint (no pairId) receives events from both sites

Managing Endpoints in the Dashboard​

  1. Go to Configuration > Webhooks
  2. Click Add Endpoint
  3. Enter your HTTPS URL and select events
  4. Optionally select an API key pair to scope the endpoint
  5. Save your webhook secret (shown only once)

Available Event Types​

EventDescription
verification.completedUser successfully passed verification
verification.failedUser failed verification
verification.cancelledUser cancelled verification before completion
session.startedVerification session was created
session.timeoutSession expired without completion

Limits​

  • Maximum 20 endpoints per tenant
  • Maximum 20 events per endpoint
  • URLs must use HTTPS
  • Internal network URLs (localhost, private IPs) are blocked

Migrating from Single Webhook​

If you previously configured a single webhook URL in the legacy configuration:

  1. Go to Configuration > Webhooks
  2. If you see a "Migration Required" prompt, click Migrate to Endpoint
  3. Your existing URL, secret, and events will be preserved as a new endpoint

The legacy webhook configuration continues to work during the transition. Once you have at least one webhook endpoint configured, events are delivered to endpoints instead of the legacy URL.