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:
- Go to Configuration > Webhooks
- Click Add Endpoint
- Enter your HTTPS webhook URL
- Select the events you want to receive
- 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
- Go to Configuration
- Click Test Webhook
- 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_+ privatesk_keys share apairId) - 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
- Go to Configuration > Webhooks
- Click Add Endpoint
- Enter your HTTPS URL and select events
- Optionally select an API key pair to scope the endpoint
- Save your webhook secret (shown only once)
Available Event Types
| Event | Description |
|---|---|
verification.completed | User successfully passed verification |
verification.failed | User failed verification |
verification.cancelled | User cancelled verification before completion |
session.started | Verification session was created |
session.timeout | Session 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:
- Go to Configuration > Webhooks
- If you see a "Migration Required" prompt, click Migrate to Endpoint
- 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.
Related
- Server Integration — Webhook setup in context
- Sessions API — Session endpoints and validation
- Dashboard Guide — Configure webhooks in the UI
For support, contact support@safepassageapp.com.