BouncerBouncerDocs

Verification Flow

Step-by-step guide to the user verification experience

This guide explains what happens during a verification session from the user's perspective and how the system processes each step.

Overview

┌─────────────┐     ┌──────────────┐     ┌─────────────┐     ┌──────────────┐
│   Create    │────▶│    User      │────▶│  Liveness   │────▶│  Complete/   │
│   Session   │     │   Visits     │     │    Check    │     │     KYC      │
└─────────────┘     └──────────────┘     └─────────────┘     └──────────────┘

Step 1: Session Creation

Your application creates a session via the API:

// On your server: the API token must never reach the browser
const response = await fetch('https://app.bouncer.digital/api/v2/session', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.BOUNCER_API_TOKEN}`,
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    age_target: 18,
    redirect_url: 'https://your-app.com/verified',
    webhook_url: 'https://your-app.com/webhooks/bouncer',
  }),
});
const { data: session } = await response.json();

// Redirect user to verification (response is wrapped in data)
res.redirect(session.verify_url);

System Actions:

  • Session record created with status created
  • Unique session ID (UUID) generated
  • Short code generated for mobile-friendly URLs
  • Expiration timer starts

Step 2: User Visits Verification Page

When the user opens the verification URL:

Cookie bypass (when Force Verification is off): If the user passed a Bouncer check recently in the same browser, for the required age or higher, and no scan has started in this session yet, the session can be treated as already verified: it becomes completed with success: true as soon as the page opens, without showing the verification flow, and only validation.completed is sent. When the session is created with force_verification: true, the cookie is ignored and the user always goes through the full flow. Device sessions always go through the full flow, whatever the device settings. force_kyc: true alone does not turn the bypass off: to require an ID document in every session, send force_kyc: true together with force_verification: true (see KYC).

User Experience:

  • Sees the organization's branding (logo, colors) on a phone and on the scanning screens
  • Views a short description of the check and a notice titled Artificial intelligence system, then taps Verify Now on a phone (a computer asks for the webcam, see below). Only sessions started from a device's printed QR code show a consent screen and a short tutorial (Position your face) first
  • Sees the target age requirement

System Actions:

  • Status changes to pending (first visit)
  • visited_at timestamp recorded
  • validation.page_visited webhook sent (if configured)
  • Mobile device detection (phones and tablets get the phone version)

QR Code Flow (Desktop)

If the user visits from a desktop:

  1. QR code is displayed, and the page asks for the webcam at once
  2. If the user allows the webcam, verification runs on the computer. If not, or if there is no camera, the user scans the QR code with a mobile device (No camera? Scan with your phone)
  3. Verification continues on mobile
  4. Desktop page auto-updates when complete

Step 3: Liveness Check

The 3D liveness check looks for a real, present person in front of the camera.

User Experience:

  1. Camera permission requested (if the browser has not granted it yet)
  2. User centers face in the frame
  3. When the scanning is over, a short transition of a few seconds, then the result

System Actions:

  • Status changes to verification_started
  • biometry_started_at timestamp recorded
  • validation.biometry_started webhook sent (if configured)
  • Liveness verification performed
  • Age estimation performed

Step 4: Age Evaluation

After the liveness check, the system evaluates the user's age.

Age Estimation Groups

The face scan does not produce an exact age: it places the user in an age group, and the check passes when that group is at or above the group of your age_target. age_target must be one of 8, 13, 16, 18, 21, 25 or 30. Any other value is rejected with 422.

Decision Logic

IF liveness is not confirmed THEN
    → No result: the user can scan again
ELSE IF age_group(estimate) >= age_group(age_target)
        AND NOT force_kyc
        AND no ID document scan started or rejected in this session THEN
    → Complete with SUCCESS
ELSE
    → Proceed to KYC
      (an estimate below age_target sets force_kyc for the rest of the session;
       no estimate at all sends only this attempt to KYC)

Step 5: KYC (If Required)

KYC is triggered when:

  • Age estimation is below target (from then on, for the rest of the session)
  • The face scan returned no age estimate (for that attempt only)
  • force_kyc is enabled on the session, through the API or the device setting Force KYC Verification (see KYC). The face scan still runs first, because the document is matched against it
  • An ID document scan already started in this session, or a document was rejected
  • The user chose Verify age with your ID on the failure screen

User Experience:

  1. Message: "Document verification is required. Please have your ID document ready for scanning"
  2. The document scanner guides the user through photographing their ID document
  3. Wait for processing

System Actions:

  • Status changes to verification_kyc
  • kyc_started_at timestamp recorded
  • validation.kyc_started webhook sent (if configured)
  • Date of birth read from the document
  • Age in whole years computed from the date of birth
  • Face-to-document matching
  • Check that the document is a physical document

KYC Checks

CheckDescription
Face MatchCompare live face to ID photo
Physical DocumentThe document must be recognized as a physical document
Date of BirthThe age from the date of birth, in whole years, must be at least age_target. If the date cannot be read, an age estimate from the document photo is used instead

All checks must pass.

Step 6: Completion

Success

User Experience:

  • Success message displayed, except where the user is sent back straight away (see below)
  • Sent back to redirect_url, in a way that depends on the Industry set on the Organization page of the dashboard: Adults, straight away; Vending or no industry set, after a 5-second countdown on the Age verified screen (a Continue button skips it); any other industry, when the user taps Continue on the Verification Successful! screen
  • After a handoff from a computer to a phone, the phone shows the success screen only, and the computer page does the redirect. A device session whose device has no Redirect URL shows the success screen only

System Actions:

  • Status changes to completed
  • success set to true
  • completed_at timestamp recorded
  • validation.completed webhook sent (if configured)

Failure

A failure only happens after an ID document check that did not pass.

User Experience:

  • We couldn't verify your age displayed, with no reason
  • Option to retry: Verify age with your ID starts again in the same session (a face scan, then an ID document scan) while the session is open. Try the validation again starts a new session for device sessions; for API sessions it reopens the same session, which shows the failure screen again
  • No redirect: the user stays on the Bouncer page

System Actions:

  • Status changes to completed
  • success set to false (not final: a retry before expires_at can still change it to true)
  • The API has no failure reason field. The metadata.facetec block is reserved: its content is not part of the contract and may change
  • validation.completed webhook sent with success: false (if configured)

Reading the result from the redirect

When we send the user back to your redirect_url (after a success only), we append a signed JWT as a query parameter:

https://your-app.com/verified?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...

The arrival itself proves nothing. Anyone can type your redirect_url into a browser. What proves the outcome is the token, signed HS256 with your organization's API Secret:

import jwt from 'jsonwebtoken';

app.get('/verified', async (req, res) => {
  try {
    const claims = jwt.verify(req.query.jwt, process.env.BOUNCER_API_SECRET, {
      algorithms: ['HS256'],          // pin it: never let the token choose
    });
    // claims.success, claims.id, claims.metadata
    if (claims.success !== true) return res.status(403).send('Not verified');
    // claims.id must be a session you created for this user (store the id when you create it)
    if (!(await isSessionOfUser(claims.id, req.user))) return res.status(403).send('Unknown session');
    // ... grant access, once per id
  } catch {
    return res.status(401).send('Invalid or expired verification token');
  }
});

isSessionOfUser is your own lookup.

Claims: success, id, metadata, issued_at, expires_at, iat, exp. The token expires one hour after issue.

The token travels in the query string, so it ends up in your access logs, in the browser history and in the Referer header of the next request. It is signed, not encrypted: anyone who sees it can read its claims, including metadata. Consume it on arrival, do not log the full URL, and do not treat it as a session credential: it proves a single verification, and nothing stops it from being presented more than once, so grant once per session id.

When to use which channel. The redirect is immediate and needs no extra call, so it is the natural choice when the user is in front of a browser, but it only happens after a success, and not in every case (for example, when the computer tab was closed after a handoff to a phone). The webhook is what you need when the outcome must reach a back end that the user never touches, such as a door controller or a vending machine. A webhook that fails may not be sent again, so treat every webhook as possibly the only delivery. The status endpoint is the fallback for both: reconcile by polling GET /api/v2/session/{id}. They are independent: one working says nothing about the others.

Retry Handling

Users can retry verification if:

  • Liveness check gives no result (camera issues, etc.): they reload the page and tap Verify Now again (on a computer, the page asks for the webcam again). The face scan can approve until an estimate below age_target makes the ID document required
  • The ID document check does not pass: Verify age with your ID on the failure screen, while the session is open

Every attempt updates the same session, with the same id, and each result sends a new validation.completed.

There is no setting for a maximum number of attempts. The session's expiration_time is what limits them: after expires_at, a new session is required.

Languages

  • The verification pages are in English and Spanish.
  • The language comes from the browser's preferred languages: the first English or Spanish entry wins. If the browser asks for neither, the pages use a default language set by Bouncer.
  • There is no parameter to choose the language and no language switcher on the page.
  • The scanner's instructions follow the same language. A few short progress messages on the scanning screen can appear in English.
  • The Terms and Conditions and Privacy Policy links point to the version in that language.

Browser and camera requirements

  • A device with a camera, and a browser that allows the page to use it. The person must allow camera access when asked.
  • Open verify_url as a top-level page: a redirect, a new tab or window, or a QR code. Embedding it in an iframe on your site is not supported: the page may refuse to load inside a frame, and the camera is blocked in frames from another site.
  • In a native app, open it in the system browser, or in a WebView that your app has allowed to use the camera.
  • If the scanner cannot start, the page shows no error message. See Technical Errors.

Duration Metrics

The Analytics page of the dashboard shows these duration metrics:

MetricDescription
Avg. Time (Biometry KPIs)Average time spent on the face scan, for sessions approved without an ID document
Avg. Time (OK) (KYC KPIs)Average time spent on the ID document step, for approved sessions
Avg. Time (KO) (KYC KPIs)Average time spent on the ID document step, for sessions not approved

Error States

Session Expired

If the user takes too long:

  • expires_at timestamp passed (it is counted from session creation, not from the first visit)
  • User sees Session expired. Its Start a new session button starts a new session for device sessions; for API sessions it reopens the same expired session, so offer a new session from your side
  • New session required
  • The status does not change and no webhook is sent: compare expires_at with your own clock
  • A user who passes just before expires_at may see this screen instead of the success screen, while the session is completed with success: true. Check the result on your server

Technical Errors

  • Camera permission denied
  • Network connectivity issues
  • SDK initialization failures

When the scanner cannot start, the page shows no error message and does not move on. Tell the user to allow the camera and reload the page, or to open the same verify_url on another device while the session is open.

Integration Example

Complete flow handling:

import crypto from 'node:crypto';
import express from 'express';

const BOUNCER_API = 'https://app.bouncer.digital/api/v2';

// 1. Create session on your server
app.post('/start-verification', async (req, res) => {
  const response = await fetch(`${BOUNCER_API}/session`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BOUNCER_API_TOKEN}`,
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      age_target: 18,
      redirect_url: `${process.env.APP_URL}/verification-callback`,
      webhook_url: `${process.env.APP_URL}/webhooks/bouncer`,
      metadata: { user_id: String(req.user.id) }
    }),
  });
  if (!response.ok) return res.status(502).send('Could not start verification');
  const { data: session } = await response.json();

  // Store session ID for later lookup
  await db.verifications.create({
    userId: req.user.id,
    bouncerSessionId: session.id,
    status: 'pending'
  });

  res.redirect(session.verify_url);
});

// 2. Handle callback
app.get('/verification-callback', async (req, res) => {
  // User has returned (only after a success): verify req.query.jwt as shown above,
  // and take the final status from the webhook or from polling
  res.render('verification-processing');
});

// 3. Handle webhook (register this route before any app.use(express.json()))
app.post('/webhooks/bouncer', express.raw({ type: () => true }), async (req, res) => {
  // Signature header: HMAC-SHA256 of the raw body, keyed with your API Secret, lowercase hex
  const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
  const expected = Buffer.from(
    crypto.createHmac('sha256', process.env.BOUNCER_API_SECRET).update(body).digest('hex')
  );
  const received = Buffer.from(String(req.get('Signature') || ''));
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  const { event, id, success } = JSON.parse(body.toString('utf8'));
  res.status(200).send('OK'); // answer within 3 seconds, then do the work

  if (event !== 'validation.completed') return; // progress events carry no result

  const verification = await db.verifications.findByBouncerSession(id);
  if (!verification || verification.status === 'verified') return; // unknown, or already approved

  await db.verifications.update(verification.id, {
    status: success ? 'verified' : 'failed', // 'failed' is not final: a retry can still succeed
    completedAt: new Date()
  });

  // Notify user, unlock content, etc.
});

// 4. Reconcile: a webhook can be lost. For verifications still 'pending' or 'failed'
//    after expires_at, call GET ${BOUNCER_API}/session/{id} with the same headers.

On this page