BouncerBouncerDocs

Integrating KYC

Request a KYC session with force_kyc and force_verification, follow it through its statuses and webhook events, tell when a success came from a document check, and set up devices.

A KYC session is an ordinary Bouncer session that requires an identity document. Tokens, request format, webhooks and their signature, and the redirect work exactly as in Age Verification. This page covers only what changes.

Request a KYC session

Create the session with POST /api/v2/session as usual, and add these two fields:

FieldValueWhat it does
force_kyctrueThe face estimate cannot approve the session: after the face scan, only an identity document can. The face scan still comes first, because the document photo is matched against it.
force_verificationtrueThe person always scans in this session. It turns off the re-verification shortcut.

Why you need both

By default (force_verification: false), a person who recently passed a Bouncer check in the same browser, for the same or a higher age target, is approved as soon as they open the page, with no scan. That shortcut does not look at force_kyc. So a session with force_kyc: true and without force_verification: true can be approved with no document at all.

With both fields set, the shortcut never applies and the face scan cannot approve. The only way to success: true is a document that passed.

Always send force_kyc: true and force_verification: true together. There is no way to add the requirement once the session exists.

Send them as JSON booleans. 1 and 0 are accepted too; the strings "true" and "false", and null, are rejected with 422. Leave a field out to get its default, false.

Give the session enough time

expiration_time counts from the moment you create the session, not from when the person opens it, and it has to cover the face scan and the document scan. The default of 300 seconds can be short for both: use 600 or more (the maximum is 3600), and create the session when the person is ready to start.

Examples

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,
    "force_kyc": true,
    "force_verification": true,
    "expiration_time": 600,
    "redirect_url": "https://shop.example.com/kyc/return",
    "webhook_url": "https://shop.example.com/webhooks/bouncer",
    "metadata": { "order_id": "ORDER-123", "check": "kyc" }
  }'
// Node.js 18 or later (built-in fetch). Run this on your server only.
const BOUNCER_API = 'https://app.bouncer.digital/api/v2';
const headers = {
  Authorization: `Bearer ${process.env.BOUNCER_API_TOKEN}`,
  Accept: 'application/json',
  'Content-Type': 'application/json',
};

export async function createKycSession(orderId) {
  const response = await fetch(`${BOUNCER_API}/session`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      age_target: 18,
      force_kyc: true, // only a document can approve
      force_verification: true, // always scan: no approval carried over from an earlier visit
      expiration_time: 600, // room for the face scan and the document scan
      redirect_url: 'https://shop.example.com/kyc/return',
      webhook_url: 'https://shop.example.com/webhooks/bouncer',
      metadata: { order_id: orderId, check: 'kyc' },
    }),
  });

  if (response.status !== 201) {
    throw new Error(`Bouncer returned ${response.status}: ${await response.text()}`);
  }

  const { data } = await response.json();
  return data; // { id, verify_url, issued_at, expires_at }: store data.id before you redirect
}

A 201 response:

{
  "data": {
    "id": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
    "verify_url": "https://app.bouncer.digital/verify/0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
    "issued_at": 1793181600,
    "expires_at": 1793182200
  }
}

Store the id with your order before you send the person to verify_url. The result never repeats force_kyc or force_verification, so record on your side that you asked for KYC. The example puts "check": "kyc" in metadata for that reason. Keep metadata to a few identifiers with no personal data: it travels inside the redirect token, which anyone who sees the URL can read.

Every call creates a new session, and there is no sandbox: sessions you create while testing are real, and completing one takes a real person with a real document. Handle a 429 with backoff. The other fields, limits and error responses are in Create session.

What the person does

  1. They open verify_url and start the face scan. On a computer without a usable webcam they can scan the QR code shown on the page and continue on a phone.
  2. After the face scan, the page shows "Document verification is required" and the document scanner opens. They photograph their identity document as the screen guides them.
  3. They see the result. On success they return to your redirect_url with a signed jwt. On failure they stay on the Bouncer page, where Verify age with your ID lets them try again in the same session.

The person needs a camera and an identity document at hand. Open verify_url as a top-level page, not in an iframe. The full flow is in Verification flow, and the document step in Document check.

Statuses and events

A KYC session moves through these steps. Every webhook carries event, id, metadata, issued_at and expires_at, plus the fields in the last column.

StepstatusWebhook eventExtra fields
Your server creates the sessioncreatednone
The person opens verify_urlpendingvalidation.page_visitedvisited_at, is_mobile
The face scan starts (on a phone, when the person taps Verify Now; on a computer, as soon as they allow the webcam)verification_startedvalidation.biometry_startedbiometry_started_at, is_retry
The document scan startsverification_kycvalidation.kyc_startedkyc_started_at
The document is decidedcompletedvalidation.completedsuccess, jwt, completed_at

What to expect:

  • Only validation.completed carries a result. validation.kyc_started means the document scan has started, not that it passed. Never grant anything on a progress event.
  • No document step without a live person. If the face scan does not confirm a live person, or the person closes the scanner, there is no document scan and no event. The session stays in verification_started until they try again or it expires.
  • A failure can be followed by a success. After success: false, the failure screen offers Verify age with your ID. That restarts the same session: the status goes back to verification_started while success stays false, then verification_kyc, then a new validation.completed. validation.biometry_started, validation.kyc_started and validation.completed can each arrive more than once for one session.
  • success: true is final. Nothing in the session changes after it.
  • Nothing is sent on expiry. A session abandoned before any verdict keeps its last status with success: null. Use expires_at as your deadline.
  • Do not base logic on is_retry.

Treat every webhook as possibly the only delivery and reconcile by polling GET /api/v2/session/{id}. The same event can also arrive more than once, so deduplicate by session id and event, and keep in mind that validation.completed can come first with success: false and later with success: true. A simple way to handle all of this: use each webhook only as a trigger to read the session with GET, and update your record from that answer.

curl https://app.bouncer.digital/api/v2/session/SESSION_ID \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json"
// BOUNCER_API and headers as in the example above.
export async function readKycSession(sessionId) {
  const response = await fetch(`${BOUNCER_API}/session/${encodeURIComponent(sessionId)}`, { headers });
  if (!response.ok) {
    throw new Error(`Bouncer returned ${response.status}`); // 404: unknown id, or not your organization's
  }
  const { data } = await response.json();
  return data; // { success, id, status, metadata, issued_at, expires_at }
}

// Approved only when completed with success: true. Before expires_at, anything else may still change.
export function kycDecision(session, graceSeconds = 120) {
  if (session.status === 'completed' && session.success === true) return 'approved';
  const now = Math.floor(Date.now() / 1000);
  return now < session.expires_at + graceSeconds ? 'pending' : 'not_verified';
}

The grace period after expires_at covers a result recorded just after it, because the last step of the scan was sent before it. How long to wait, and whether to accept a late success, is your decision. Statuses and expiry are covered in full in Sessions.

Know that a success came from a document check

No field in the result says how a session was approved. These are the signals you have:

  • What you asked for. If you created the session with force_kyc: true and force_verification: true, or it came from a device with Force KYC Verification on, a success: true can only come from a document that passed. This is the reliable signal, and the reason to record on your side that you asked for KYC.
  • validation.kyc_started in other sessions. In a session that did not require a document, Bouncer can still ask for one, for example when the face estimate is below your target. Once a document scan has started in a session, only a document can approve that session. So a session for which you received validation.kyc_started and that ends with success: true was approved by a document. That event is a progress signal that may not reach you: use it for reporting, not to decide.
  • Not the status from GET. GET returns only the current status. Once the session is completed, it no longer shows that it went through verification_kyc.
  • Not the facetec block. It is reserved, its content is not part of the API contract and it may change.

If a session's result must rest on a document, require it when you create the session.

Devices

A device (a kiosk, vending machine or point of sale) can require the document in every session it starts:

  1. In the dashboard sidebar, open Devices. If your organization's Industry is not Vending, the same item is labeled Web. You need the Owner, Admin or Manager role.
  2. Click Create Device, or open a device and click Edit Device.
  3. Under Session Settings, turn on Force KYC Verification ("Always require document verification, even if biometry passes.").
  4. Save with Create Device or Save Changes.

Device sessions always require a fresh scan: Force Verification is always on for devices and cannot be turned off. So Force KYC Verification on its own makes every session of that device a KYC session.

  • New sessions only. The setting is copied into each session when it is created. Changing it does not affect sessions already open.
  • Printed and dynamic QR codes alike. It applies to sessions started from the device's printed QR code and from links created with POST /api/v2/device/{deviceCode}/session-url. That endpoint has no field to require the document for one sale only. If a kiosk needs KYC for some sales and not others, create an API session with force_kyc and force_verification for those sales and show its verify_url as a QR code.
  • Age target. The document's age is checked against the device's Age Target, so check it on every device. A device with no age target is checked against 18.
  • Results. Device sessions carry Bouncer's own metadata (device_id, device_code, device_name), not yours. You learn their id and result from the device's Webhook URL, and after a success also from the jwt added to the device's Redirect URL. The progress events, validation.kyc_started included, are sent only when Send all events is on.

Everything else about devices is in Devices.

Shared reference

On this page