Bouncer

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.completedVerification finished (success or failure)
validation.page_visitedUser opened the verification page
validation.biometry_startedFace verification started
validation.kyc_startedKYC / ID verification started

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

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 2xx response 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 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