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:
| Where | When | Use it to |
|---|---|---|
GET /api/v2/session/{id} | At any time | Confirm the result. It is the reliable source |
The validation.completed webhook | Each time a result is recorded | React quickly |
The jwt parameter added to your redirect_url | Only after a success, when the person is sent back | Show 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: truemeans a document passed the check described in Document check.success: falsemeans a document was rejected. The face scan never producesfalse.success: nullmeans there is no result yet.
status | success | Meaning | Final |
|---|---|---|---|
created | null | The person has not opened verify_url yet | No |
pending | null | The page is open; no scan has started | No |
verification_started | null | The face scan has started | No |
verification_kyc | null | The document scan has started | No |
verification_started or verification_kyc | false | A document was rejected and the person is trying again | No |
completed | false | A document was rejected | Not while the session is open: the person can try again until expires_at |
completed | true | A document passed | Yes |
cancelled | null or false | Device sessions only: replaced by a newer dynamic QR scan on the same device | Yes, not verified |
A rule that works:
- Approve only when
statusiscompletedandsuccessistrue, confirmed on your server. - Until
expires_at, treat anything else as not decided yet. - After
expires_atplus a grace period (for example two minutes), treat anything else as not verified. An abandoned or expired session keeps its last status andsuccessvalue forever: there is noexpiredstatus.
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
}
}| Field | Type | Meaning |
|---|---|---|
success | boolean or null | null until there is a result, then true or false |
id | string (UUID) | The session id |
status | string | One of the statuses above, in lowercase |
metadata | object or null | Your metadata, plus the reserved facetec block once the check has run |
issued_at | integer | When the session was created, in Unix seconds |
expires_at | integer | When 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
}| Field | Type | Meaning |
|---|---|---|
event | string | validation.completed |
id | string (UUID) | The session id |
metadata | object or null | As in GET |
issued_at | integer | When the session was created, in Unix seconds |
expires_at | integer | When the session window ends, in Unix seconds |
success | boolean | true or false, never null |
jwt | string | A result token with the same claims as the redirect token (see below). Here, iat is when the webhook was sent |
completed_at | integer | When Bouncer processed the completion, in Unix seconds. Do not use it to order events |
How to handle it:
- Verify the
Signatureheader before you trust the body. See Webhooks. - Expect more than one per session. A session can send
success: falseand latersuccess: true, after the person retries with another document. Deduplicate by sessionidandevent, and for completions also bysuccess. - 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: falseandcompleted_at: nullif that session has no result yet). Grant at most once perid. - 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
}| Claim | Type | Meaning |
|---|---|---|
success | boolean | The result. Check that it is true |
id | string (UUID) | The session id. Check that it is a session you created |
metadata | object or null | As in GET |
issued_at | integer | When the session was created, in Unix seconds |
expires_at | integer | When the session window ends, in Unix seconds. This is not the token's expiry |
iat | integer | When this token was issued, in Unix seconds |
exp | integer | When this token expires: iat plus 3600 seconds |
| Property | Value |
|---|---|
| Algorithm | HS256 |
| Key | Your organization's API Secret, used as a plain string. Owners and admins find it in the dashboard under Organization, section API Tokens |
| Encryption | None. The token is signed, not encrypted: anyone who sees the URL can read the claims |
Notes:
iatis when the page built the link, not when the result was recorded. Opening the success page again issues a new token, so the sameidcan 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 tokenfind_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
faceteckey in your ownmetadata; - it travels everywhere
metadatagoes: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_urlandwebhook_url.verify_urlis 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_kycandforce_verification; redirect_urlandwebhook_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 reservedfacetecblock.
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.