BouncerDocs

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

EventDescription
validation.session_createdSession created and ready to be visited
validation.page_visitedUser opened the verification page
validation.biometry_startedFace verification started
validation.kyc_startedKYC / ID verification started
validation.completedVerification 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:

FieldTypeDescription
eventstringEvent name
idUUIDVerification session ID
metadataarray/nullCustom metadata from session creation
issued_atintegerSession issue time (Unix timestamp)
expires_atintegerSession 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

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
  • Use success: false to 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

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:12345", "user: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_retrybooleanWhether this was a retry attempt

Notes:

  • Retries are sent as validation.biometry_started
  • Use is_retry: true to 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

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

  • Use HTTPS for your webhook endpoint
  • 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
  • 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 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://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

  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