Webhooks
Receive real-time notifications for verification events
Webhooks let you receive real-time HTTP notifications when verification events occur.
Overview
Configure a webhook URL to receive notifications when:
- A session is completed
- A user visits the verification page
- Biometric verification starts
- KYC begins
Webhooks tell you quickly; Get Session Status tells you for sure. Treat every webhook as possibly the only delivery, and reconcile by polling (see Delivery and retries).
Delivery Behavior
Bouncer supports webhooks in two ways:
Per-Session Webhooks
Include a webhook_url when creating a session:
curl -X POST https://app.bouncer.digital/api/v2/session \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json" \
-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. Keep webhook_url to 255 characters or fewer.
Device Webhooks
You can also set a Webhook URL in the device's Webhook 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 (it is enabled by default)
Event Types
| Event | Description |
|---|---|
validation.page_visited | User opened the verification page |
validation.biometry_started | Face verification started |
validation.kyc_started | KYC / ID verification started |
validation.completed | A result was recorded: approved (success: true) or not approved (success: false) |
Only validation.completed carries the verdict. The other three are progress signals: use them to
follow a session in real time, never to infer an outcome. They can also be skipped: when a recent
verification in the same browser approves an API session at once, only validation.completed is
sent. validation.biometry_started and validation.kyc_started can be sent more than once in the
same session, for example after a failed attempt, so seeing them twice is normal and means nothing
about success.
These are the only events. Creating a session sends no webhook (even though the description of Send all events in the dashboard mentions "session created"), and nothing is sent when a session expires, is abandoned or is cancelled.
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 | object/null | The metadata you sent when creating the session (device sessions: device_id, device_code, device_name), plus keys Bouncer adds |
issued_at | integer | Session issue time (Unix timestamp) |
expires_at | integer | Session expiration time (Unix timestamp) |
Send metadata as a flat JSON object with descriptive keys, as in the examples below. After a scan,
Bouncer adds a facetec key: it is a reserved block whose content is not part of the contract and
may change, so do not build logic on it, and do not send a key named facetec yourself. Once that
key is added, a JSON list you sent becomes an object with keys "0", "1" and so on.
The *_at times are set when Bouncer processes the event. Under load they can be later than the
moment it happened: do not use them to order events.
Completion Webhook
When a verification completes, Bouncer sends:
{
"event": "validation.completed",
"id": "550e8400-e29b-41d4-a716-446655440000",
"metadata": {
"order_id": "12345",
"user_id": "67890",
"facetec": { "...": "..." }
},
"issued_at": 1705314600,
"expires_at": 1705314900,
"success": true,
"jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"completed_at": 1705314865
}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 failssuccess: falsemeans not approved yet: beforeexpires_atthe person can still try again (for example with an ID document), and a latervalidation.completedwithsuccess: truecan follow for the sameid- Never let a
success: falseoverwrite an approval you already stored
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_id": "12345", "user_id": "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_id": "12345", "user_id": "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 | Reserved: do not base logic on it |
Notes:
- This event can be sent again in the same session, for example when the person tries again after a failed attempt
- Do not use
is_retryto detect retries
KYC Started Webhook
When KYC / ID verification starts, Bouncer sends:
{
"event": "validation.kyc_started",
"id": "550e8400-e29b-41d4-a716-446655440000",
"metadata": { "order_id": "12345", "user_id": "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
These handlers show how to route events. Verify the Signature header before you act on any of
them, as shown in Security.
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?.order_id;
if (orderId) {
await db.orders.update(orderId, { verified: true });
}
}
async function handleVerificationFailure(sessionId, metadata) {
// Not final: a later validation.completed with success: true can follow.
// Never overwrite an approval.
const orderId = metadata?.order_id;
const order = orderId && await db.orders.find(orderId);
if (order && order.verified !== true) {
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 = $metadata['order_id'] ?? null;
if ($orderId) {
$order = Order::find($orderId);
// success: false is not final: never overwrite an approval.
if ($order && ! $order->age_verified) {
$order->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 = metadata.get('order_id') if isinstance(metadata, dict) else None
if order_id:
order = db.orders.get(order_id)
# success False is not final: never overwrite an approval.
if order and not order.get('verified'):
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 your organization's API Secret, shown in the dashboard under Organization, section API Tokens (only the Owner and Admin roles see it). Use it as a plain string: do not hex-decode or base64-decode it. 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.
// Register this route before any app.use(express.json()).
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 of yours that also holds the API Secret, and that service can verify the
verdict on its own without trusting whoever passed it along. Its claims are the same as those of
the redirect token: success, id, metadata, issued_at, expires_at, iat and exp (see
Verification flow).
Here iat is when the webhook was sent, and the token expires one hour later.
Recommended practices
- Use HTTPS for your webhook endpoint, and answer at that exact URL: do not answer with a redirect
- 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 (for example
when someone presses Test Webhook on a device, which sends the device's most recent session
again as
validation.completed, with its realsuccessvalue). Deduplicate by sessionidandevent, and forvalidation.completedalso bysuccess - Store webhook payloads for debugging and auditing
Delivery and retries
Each delivery has a 3 second timeout. A slow endpoint is treated as a failed one, so accept the payload first and do the heavy work afterwards.
Do not count on a failed delivery being sent again. A delivery fails when your endpoint is down,
answers anything other than 2xx, or takes longer than 3 seconds. Treat every webhook as possibly
the only delivery, and reconcile by polling.
If a delivery fails, the result is not lost: query it with
Get Session Status, and handle a 429 with
backoff. 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. For device sessions, the session id reaches you only through webhooks: keep
Send all events on, so that validation.page_visited gives you the id early and you can still
poll if the completion is lost.
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://app.bouncer.digital/api/v2/session \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json" \
-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);
});