BouncerBouncerDocs

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

EventDescription
validation.page_visitedUser opened the verification page
validation.biometry_startedFace verification started
validation.kyc_startedKYC / ID verification started
validation.completedA 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:

FieldTypeDescription
eventstringEvent name
idUUIDVerification session ID
metadataobject/nullThe metadata you sent when creating the session (device sessions: device_id, device_code, device_name), plus keys Bouncer adds
issued_atintegerSession issue time (Unix timestamp)
expires_atintegerSession 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

FieldTypeDescription
successbooleanWhether verification passed
completed_atintegerCompletion time (Unix timestamp)
jwtstringSigned JWT containing the completion result

Notes:

  • event is always validation.completed, even when verification fails
  • success: false means not approved yet: before expires_at the person can still try again (for example with an ID document), and a later validation.completed with success: true can follow for the same id
  • Never let a success: false overwrite 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

FieldTypeDescription
visited_atintegerVisit time (Unix timestamp)
is_mobilebooleanWhether 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

FieldTypeDescription
biometry_started_atintegerStart time (Unix timestamp)
is_retrybooleanReserved: 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_retry to 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

FieldTypeDescription
kyc_started_atintegerKYC 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.

  • 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 2xx response 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 real success value). Deduplicate by session id and event, and for validation.completed also by success
  • 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 3000

Then use the generated URL as your webhook endpoint:

https://abc123.ngrok.io/webhooks/bouncer

Manual 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

  1. Respond quickly with a 2xx status.
  2. Process webhook side effects asynchronously when possible.
  3. Handle duplicate deliveries safely.
  4. Log payloads and failures.
  5. Use the event field to branch your handler logic.
  6. Do not assume every event includes success or jwt.
app.post('/webhooks/bouncer', async (req, res) => {
  res.status(200).send('OK');

  await queue.add('process-bouncer-webhook', req.body);
});

On this page