BouncerBouncerDocs

Results

What a KYC session returns in GET, in the webhook and in the redirect token, field by field, when a result is final, what is not returned and what Bouncer keeps.

A KYC session reports its result in three places, with the same values:

WhereWhenUse it to
GET /api/v2/session/{id}At any timeConfirm the result. It is the reliable source
The validation.completed webhookEach time a result is recordedReact quickly
The jwt parameter added to your redirect_urlOnly after a success, when the person is sent backShow the person the right page

The fields are the same as in age verification. This page reads them for a KYC session. For signature verification, the other webhook events and polling, see Webhooks and Sessions.

How to read the result

In a KYC session (force_kyc and force_verification both true), only a document check can set success:

  • success: true means a document passed the check described in Document check.
  • success: false means a document was rejected. The face scan never produces false.
  • success: null means there is no result yet.
statussuccessMeaningFinal
creatednullThe person has not opened verify_url yetNo
pendingnullThe page is open; no scan has startedNo
verification_startednullThe face scan has startedNo
verification_kycnullThe document scan has startedNo
verification_started or verification_kycfalseA document was rejected and the person is trying againNo
completedfalseA document was rejectedNot while the session is open: the person can try again until expires_at
completedtrueA document passedYes
cancellednull or falseDevice sessions only: replaced by a newer dynamic QR scan on the same deviceYes, not verified

A rule that works:

  1. Approve only when status is completed and success is true, confirmed on your server.
  2. Until expires_at, treat anything else as not decided yet.
  3. After expires_at plus a grace period (for example two minutes), treat anything else as not verified. An abandoned or expired session keeps its last status and success value forever: there is no expired status.

success never goes from true back to false. Once you have seen true for an id, keep it, and never let a later event overwrite it.

The session status (GET)

curl https://app.bouncer.digital/api/v2/session/01234567-89ab-7def-8123-456789abcdef \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json"

Response 200 OK after a document passed:

{
  "data": {
    "success": true,
    "id": "01234567-89ab-7def-8123-456789abcdef",
    "status": "completed",
    "metadata": {
      "order_id": "ORDER-123",
      "facetec": { "...": "..." }
    },
    "issued_at": 1793181600,
    "expires_at": 1793182200
  }
}

And while the person tries again after a rejected document:

{
  "data": {
    "success": false,
    "id": "01234567-89ab-7def-8123-456789abcdef",
    "status": "verification_kyc",
    "metadata": {
      "order_id": "ORDER-123",
      "facetec": { "...": "..." }
    },
    "issued_at": 1793181600,
    "expires_at": 1793182200
  }
}
FieldTypeMeaning
successboolean or nullnull until there is a result, then true or false
idstring (UUID)The session id
statusstringOne of the statuses above, in lowercase
metadataobject or nullYour metadata, plus the reserved facetec block once the check has run
issued_atintegerWhen the session was created, in Unix seconds
expires_atintegerWhen the session window ends, in Unix seconds

A session id that does not exist and a session of another organization both return 404. Do not retry a 401 or a 404: fix the request. Handle a 429 or a 5xx by retrying with backoff. See Get session status.

The completion webhook

validation.completed is the only event that carries a result. It is sent each time a result is recorded:

{
  "event": "validation.completed",
  "id": "01234567-89ab-7def-8123-456789abcdef",
  "metadata": {
    "order_id": "ORDER-123",
    "facetec": { "...": "..." }
  },
  "issued_at": 1793181600,
  "expires_at": 1793182200,
  "success": true,
  "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "completed_at": 1793181855
}
FieldTypeMeaning
eventstringvalidation.completed
idstring (UUID)The session id
metadataobject or nullAs in GET
issued_atintegerWhen the session was created, in Unix seconds
expires_atintegerWhen the session window ends, in Unix seconds
successbooleantrue or false, never null
jwtstringA result token with the same claims as the redirect token (see below). Here, iat is when the webhook was sent
completed_atintegerWhen Bouncer processed the completion, in Unix seconds. Do not use it to order events

How to handle it:

  • Verify the Signature header before you trust the body. See Webhooks.
  • Expect more than one per session. A session can send success: false and later success: true, after the person retries with another document. Deduplicate by session id and event, and for completions also by success.
  • Expect repeats. The same event can reach you more than once. For example, the Test Webhook button of a device sends the real result of that device's latest session again (with success: false and completed_at: null if that session has no result yet). Grant at most once per id.
  • Values are read when the webhook is sent, not when the event happened. A late webhook can already carry a newer result.
  • Treat every webhook as possibly the only delivery for that event, and reconcile by polling GET /api/v2/session/{id}.
  • Ignore fields you do not recognize. New fields can be added.

The redirect token

After a success, Bouncer sends the person to your redirect_url with a jwt query parameter. There is no redirect after a rejection or after expiry, so a token on your redirect URL always says success: true. How and when the person is sent back depends on your organization's settings; see Verification flow.

Decoded claims:

{
  "success": true,
  "id": "01234567-89ab-7def-8123-456789abcdef",
  "metadata": {
    "order_id": "ORDER-123",
    "facetec": { "...": "..." }
  },
  "issued_at": 1793181600,
  "expires_at": 1793182200,
  "iat": 1793181862,
  "exp": 1793185462
}
ClaimTypeMeaning
successbooleanThe result. Check that it is true
idstring (UUID)The session id. Check that it is a session you created
metadataobject or nullAs in GET
issued_atintegerWhen the session was created, in Unix seconds
expires_atintegerWhen the session window ends, in Unix seconds. This is not the token's expiry
iatintegerWhen this token was issued, in Unix seconds
expintegerWhen this token expires: iat plus 3600 seconds
PropertyValue
AlgorithmHS256
KeyYour organization's API Secret, used as a plain string. Owners and admins find it in the dashboard under Organization, section API Tokens
EncryptionNone. The token is signed, not encrypted: anyone who sees the URL can read the claims

Notes:

  • iat is when the page built the link, not when the result was recorded. Opening the success page again issues a new token, so the same id can reach your redirect URL more than once.
  • Do not require claims that are not listed here, and ignore claims you do not recognize.
  • Verify the token on your server, never in the browser: that would expose your API Secret.
const jwt = require('jsonwebtoken'); // npm install jsonwebtoken

// `app` is your Express app. Node.js 18 or later, for the built-in fetch.
app.get('/kyc/return', async (req, res) => {
  let claims;
  try {
    claims = jwt.verify(String(req.query.jwt || ''), process.env.BOUNCER_API_SECRET, {
      algorithms: ['HS256'],
      clockTolerance: 60, // seconds of clock skew allowed
    });
  } catch {
    return res.status(400).send('This link is invalid or has expired.');
  }

  const order = await findOrderByBouncerSession(claims.id); // your own lookup
  if (!order) return res.sendStatus(404);

  // Confirm on the server before granting anything.
  const response = await fetch(
    `https://app.bouncer.digital/api/v2/session/${encodeURIComponent(claims.id)}`,
    { headers: { Authorization: `Bearer ${process.env.BOUNCER_API_TOKEN}`, Accept: 'application/json' } },
  );
  if (!response.ok) return res.status(503).send('Please try again in a moment.');
  const { data } = await response.json();

  if (data.status === 'completed' && data.success === true) {
    await markOrderVerified(order, claims.id); // idempotent: once per session id
  }

  res.redirect(303, `/orders/${encodeURIComponent(order.id)}`); // a clean URL, without the token
});

findOrderByBouncerSession and markOrderVerified are your own functions.

<?php
require __DIR__ . '/vendor/autoload.php'; // composer require firebase/php-jwt

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

JWT::$leeway = 60; // seconds of clock skew allowed

try {
    $claims = JWT::decode((string) ($_GET['jwt'] ?? ''), new Key(getenv('BOUNCER_API_SECRET'), 'HS256'));
} catch (Throwable $e) {
    http_response_code(400);
    exit('This link is invalid or has expired.');
}

$order = find_order_by_bouncer_session($claims->id); // your own lookup
if ($order === null) {
    http_response_code(404);
    exit;
}

// Confirm on the server before granting anything.
$ch = curl_init('https://app.bouncer.digital/api/v2/session/' . rawurlencode($claims->id));
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('BOUNCER_API_TOKEN'),
        'Accept: application/json',
    ],
]);
$response = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($response === false || $status !== 200) {
    http_response_code(503);
    exit('Please try again in a moment.');
}

$data = json_decode($response, true)['data'];
if ($data['status'] === 'completed' && $data['success'] === true) {
    mark_order_verified($order, $claims->id); // idempotent: once per session id
}

header('Location: /orders/' . rawurlencode($order['id']), true, 303); // a clean URL, without the token

find_order_by_bouncer_session and mark_order_verified are your own functions.

The reserved facetec block

Bouncer adds a facetec key to metadata during or after the check. It is reserved for the biometric engine:

  • its content is not part of the API contract and can change, and it can be absent;
  • do not build logic on it, and do not send a facetec key in your own metadata;
  • it travels everywhere metadata goes: GET, every webhook, the result token on your redirect URL, and Verification Details in the dashboard.

Because the redirect token is signed but not encrypted, everything in metadata can be read by anyone who sees the redirect URL, for example in browser history or server logs. Remove the token from the address bar after reading it, and keep your own metadata to identifiers that are not personal data. For more on metadata, see Sessions.

What is not returned

No documented field, in any of the three channels, gives you:

  • the data on the document: no name, date of birth, document number, document type, issuing country or expiry date;
  • an image of the face or of the document;
  • the reason a document was rejected;
  • how the session was approved;
  • the settings of the session: age_target, force_kyc, force_verification, redirect_url and webhook_url. verify_url is only in the create response.

GET has no completion time; the completion webhook has completed_at. If you need any of your own values later, store them on your side or put them in metadata.

What Bouncer keeps

The session record. For each session, Bouncer keeps:

  • the status, the result, age_target, force_kyc and force_verification;
  • redirect_url and webhook_url;
  • the time of each step: created, expiry, page opened, face scan started, document scan started, completed;
  • whether a document scan was used, whether the re-verification shortcut approved the session, and whether it was opened on a computer;
  • your metadata, stored encrypted, together with the reserved facetec block.

An event log for each session: session created, page opened (and whether on a phone or tablet), face scan started, document scan started and each result, with their times.

The session record and the event log contain no face image, no document image, and not the person's name, date of birth or document number. When a document passed and its date of birth was read, the record keeps the age in whole years worked out from it, not the date itself.

The scans. Bouncer does not store data.

In your dashboard. Your team sees each session in Verifications. Verification Details shows its status, result, webhook URL and metadata, the reserved block included.

In the person's browser. A successful check leaves the cookie used by the re-verification shortcut. It can approve a later session that allows the shortcut (see Verification flow), but never a KYC session, because those have force_verification: true.

On this page