Webhooks
Receive real-time notifications for verification events
Webhooks
Webhooks let you receive real-time HTTP notifications when verification events occur.
Overview
Instead of polling the API for session status, configure a webhook URL to receive notifications when:
- A session is completed
- A user visits the verification page
- Biometric verification starts
- KYC begins
Delivery Behavior
Bouncer supports webhooks in two ways:
Per-Session Webhooks
Include a webhook_url when creating a session:
curl -X POST https://your-bouncer-instance.com/api/v2/session \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"age_target": 18,
"redirect_url": "https://your-app.com/callback",
"webhook_url": "https://your-app.com/webhooks/bouncer"
}'For API-created sessions, Bouncer sends all supported webhook events to the provided webhook_url.
Device Webhooks
You can also configure a webhook URL in device settings.
For device-created sessions:
- Completion webhooks are always sent
- Page visited, biometric started, and KYC started webhooks are sent only when Send all events is enabled for the device
Event Types
| Event | Description |
|---|---|
validation.completed | Verification finished (success or failure) |
validation.page_visited | User opened the verification page |
validation.biometry_started | Face verification started |
validation.kyc_started | KYC / ID verification started |
Payload Format
Webhook payloads use a flat JSON structure.
Base Fields
These fields are included in all webhook events:
| Field | Type | Description |
|---|---|---|
event | string | Event name |
id | UUID | Verification session ID |
metadata | array/null | Custom metadata from session creation |
issued_at | integer | Session issue time (Unix timestamp) |
expires_at | integer | Session expiration time (Unix timestamp) |
Completion Webhook
When a verification completes, Bouncer sends:
{
"event": "validation.completed",
"id": "550e8400-e29b-41d4-a716-446655440000",
"success": true,
"metadata": ["order:12345", "user:67890"],
"issued_at": 1705314600,
"expires_at": 1705314900,
"completed_at": 1705314865,
"jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}Completion Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Whether verification passed |
completed_at | integer | Completion time (Unix timestamp) |
jwt | string | Signed JWT containing the completion result |
Notes:
eventis alwaysvalidation.completed, even when verification fails- Use
success: falseto detect failed completions
Page Visited Webhook
When the user first opens the verification page, Bouncer sends:
{
"event": "validation.page_visited",
"id": "550e8400-e29b-41d4-a716-446655440000",
"metadata": ["order:12345", "user:67890"],
"issued_at": 1705314600,
"expires_at": 1705314900,
"visited_at": 1705314620,
"is_mobile": true
}Page Visited Fields
| Field | Type | Description |
|---|---|---|
visited_at | integer | Visit time (Unix timestamp) |
is_mobile | boolean | Whether the page was opened on a mobile device |
Biometry Started Webhook
When biometric verification starts, Bouncer sends:
{
"event": "validation.biometry_started",
"id": "550e8400-e29b-41d4-a716-446655440000",
"metadata": ["order:12345", "user:67890"],
"issued_at": 1705314600,
"expires_at": 1705314900,
"biometry_started_at": 1705314635,
"is_retry": false
}Biometry Started Fields
| Field | Type | Description |
|---|---|---|
biometry_started_at | integer | Start time (Unix timestamp) |
is_retry | boolean | Whether this was a retry attempt |
Notes:
- Retries are sent as
validation.biometry_started - Use
is_retry: trueto detect retry attempts
KYC Started Webhook
When KYC / ID verification starts, Bouncer sends:
{
"event": "validation.kyc_started",
"id": "550e8400-e29b-41d4-a716-446655440000",
"metadata": ["order:12345", "user:67890"],
"issued_at": 1705314600,
"expires_at": 1705314900,
"kyc_started_at": 1705314650
}KYC Started Fields
| Field | Type | Description |
|---|---|---|
kyc_started_at | integer | KYC start time (Unix timestamp) |
Handling Webhooks
Basic Handler (Node.js / Express)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/bouncer', async (req, res) => {
const { event, id, success, metadata = [] } = req.body;
console.log(`Received ${event} for session ${id}`);
switch (event) {
case 'validation.completed':
if (success) {
await handleVerificationSuccess(id, metadata);
} else {
await handleVerificationFailure(id, metadata);
}
break;
case 'validation.page_visited':
await updateSessionStatus(id, 'user_viewing');
break;
case 'validation.biometry_started':
await updateSessionStatus(id, 'biometry_started');
break;
case 'validation.kyc_started':
await updateSessionStatus(id, 'kyc_started');
break;
default:
console.log(`Unhandled event: ${event}`);
}
res.status(200).send('OK');
});
async function handleVerificationSuccess(sessionId, metadata) {
const orderId = metadata.find(m => m.startsWith('order:'))?.split(':')[1];
if (orderId) {
await db.orders.update(orderId, { verified: true });
}
}
async function handleVerificationFailure(sessionId, metadata) {
const orderId = metadata.find(m => m.startsWith('order:'))?.split(':')[1];
if (orderId) {
await db.orders.update(orderId, { verified: false });
}
}PHP Handler (Laravel)
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Order;
class BouncerWebhookController extends Controller
{
public function handle(Request $request)
{
$event = $request->input('event');
$sessionId = $request->input('id');
$success = $request->input('success');
$metadata = $request->input('metadata', []);
switch ($event) {
case 'validation.completed':
$this->handleCompletion($sessionId, $success, $metadata);
break;
case 'validation.page_visited':
$this->logPageVisit($sessionId);
break;
case 'validation.biometry_started':
$this->logBiometryStart($sessionId);
break;
case 'validation.kyc_started':
$this->logKycStart($sessionId);
break;
}
return response('OK', 200);
}
private function handleCompletion(string $sessionId, bool $success, array $metadata): void
{
$orderId = collect($metadata)
->first(fn ($m) => str_starts_with($m, 'order:'));
if ($orderId) {
$orderId = str_replace('order:', '', $orderId);
Order::where('id', $orderId)->update([
'age_verified' => $success,
'bouncer_session_id' => $sessionId,
]);
}
}
private function logPageVisit(string $sessionId): void
{
// handle page visit
}
private function logBiometryStart(string $sessionId): void
{
// handle biometry start
}
private function logKycStart(string $sessionId): void
{
// handle KYC start
}
}Python Handler (Flask)
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/bouncer', methods=['POST'])
def bouncer_webhook():
data = request.json or {}
event = data.get('event')
session_id = data.get('id')
success = data.get('success')
metadata = data.get('metadata', [])
if event == 'validation.completed':
handle_completion(session_id, success, metadata)
elif event == 'validation.page_visited':
log_page_visit(session_id)
elif event == 'validation.biometry_started':
log_biometry_start(session_id)
elif event == 'validation.kyc_started':
log_kyc_start(session_id)
return 'OK', 200
def handle_completion(session_id, success, metadata):
order_id = None
for item in metadata:
if item.startswith('order:'):
order_id = item.split(':')[1]
break
if order_id:
db.orders.update(order_id, {
'verified': success,
'bouncer_session_id': session_id,
})Security
Bouncer signs webhook requests using your organization API secret.
Recommended practices:
- Use HTTPS for your webhook endpoint
- Treat webhook handlers as untrusted input handlers
- Return a
2xxresponse quickly after accepting the payload - Make your webhook processing idempotent
- Store webhook payloads for debugging and auditing
Testing Webhooks
Local Development
Use a tunnel such as ngrok to expose your local server:
ngrok http 3000Then use the generated URL as your webhook endpoint:
https://abc123.ngrok.io/webhooks/bouncerManual Testing
Create a session with a webhook URL and complete the verification flow:
curl -X POST https://your-bouncer-instance.com/api/v2/session \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"age_target": 18,
"redirect_url": "https://your-app.com/callback",
"webhook_url": "https://abc123.ngrok.io/webhooks/bouncer"
}'Best Practices
- Respond quickly with a
2xxstatus. - Process webhook side effects asynchronously when possible.
- Handle duplicate deliveries safely.
- Log payloads and failures.
- Use the
eventfield to branch your handler logic. - Do not assume every event includes
successorjwt.
app.post('/webhooks/bouncer', async (req, res) => {
res.status(200).send('OK');
await queue.add('process-bouncer-webhook', req.body);
});