Skip to main content

Webhooks

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

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.

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. The example above uses Express's verify callback to capture the raw buffer before JSON parsing. Using JSON.stringify(req.body) will fail because Express reformats the JSON during parsing.

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

Retry Behavior

  • SafePassage retries failed deliveries up to 3 times
  • Exponential backoff (1s, 2s, 4s)
  • 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: when the webhook was sent
  • 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
  • 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.


For support, contact support@safepassageapp.com.