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.session_created | Session created and ready to be visited |
validation.page_visited | User opened the verification page |
validation.biometry_started | Face verification started |
validation.kyc_started | KYC / ID verification started |
validation.completed | Verification finished (success or failure) |
Only validation.completed carries the verdict. The other four are progress signals: use them to
follow a session in real time, never to infer an outcome. In particular, validation.biometry_started
fires again on each retry, so seeing it twice is normal and means nothing about success.
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
Every webhook request carries a Signature header. Verify it before you trust the payload.
Without this check, anyone who learns your webhook URL can post a forged "success": true to it.
How the signature is built
Signature: hash_hmac('sha256', <raw JSON request body>, <your organization API secret>)The secret is the same API secret shown in your dashboard. Three details matter:
- Sign the raw body, exactly as received. Do not re-encode the parsed JSON: a different key order or spacing produces a different hash and the check fails for no visible reason.
- Compare in constant time (
hash_equals,crypto.timingSafeEqual,hmac.compare_digest). A plain==leaks timing information. - Reject the request if the header is missing. Treat absence as failure, not as "unsigned".
Verifying the signature
import crypto from 'crypto';
// express.raw() is required: the signature is over the bytes, not over the parsed object.
app.post('/webhooks/bouncer', express.raw({ type: 'application/json' }), (req, res) => {
const received = req.get('Signature') || '';
const expected = crypto
.createHmac('sha256', process.env.BOUNCER_API_SECRET)
.update(req.body)
.digest('hex');
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
// ... handle the event
res.status(200).send('OK');
});$received = $request->header('Signature', '');
$expected = hash_hmac('sha256', $request->getContent(), config('services.bouncer.api_secret'));
if (! hash_equals($expected, $received)) {
abort(401, 'Invalid signature');
}import hmac, hashlib
from flask import request, abort
received = request.headers.get('Signature', '')
expected = hmac.new(
BOUNCER_API_SECRET.encode(),
request.get_data(), # raw bytes, not request.json
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received):
abort(401)The JWT is a second, independent proof
Completion events also include a jwt field, signed HS256 with the same API secret. It is not
a replacement for the signature check, but it is useful in one specific case: you can hand that
token to another service, and that service can verify the verdict on its own without trusting
whoever passed it along. Its claims include success, id, metadata, iat and exp.
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: the same event can arrive more than once
- Store webhook payloads for debugging and auditing
Delivery and retries
Each attempt has a 3 second timeout, and we make 3 attempts: immediately, then after about 10 seconds, then after about 100 seconds. A slow endpoint is treated as a failed one, so accept the payload first and do the heavy work afterwards.
If all three fail, the result is not lost: query it with Get Session Status. For anything where a missed webhook has a physical consequence, such as a door that does not open or a machine that does not dispense, treat polling as a required fallback rather than an optional extra.
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);
});