Node.js SDK
The Node.js SDK (@xident/node) is a server-side TypeScript client for creating verification sessions, verifying tokens, and handling webhooks. It uses native fetch (Node.js 18+) with automatic retries and exponential backoff.
Server-side only. This SDK uses your secret API key (sk_live_*). Never expose it in client-side code.
Installation
npm install @xident/node
Quick Start
import { Xident } from '@xident/node';
const xident = new Xident('sk_live_your_secret_key');
// 1. Create an init token (redirect user to result.verifyUrl)
const init = await xident.verification.init({
callback_url: 'https://yoursite.com/verified',
min_age: 18,
});
console.log(init.token); // 'xit_...'
console.log(init.verifyUrl); // 'https://verify.xident.io?t=xit_...'
// 2. Xident redirects the browser back to callback_url with ?token=xtk_...
// Read that RESULT token from the request -- NOT init.token, which is the
// one-time xit_ token used above -- and verify it server-side.
const resultToken = req.query.token as string; // e.g. 'xtk_golden0001'
const result = await xident.verification.getResult(resultToken);
if (result.isVerified()) {
console.log('Age bracket:', result.ageBracket()); // 18
console.log('Method:', result.method()); // 'full'
}
API version
This SDK sends the dated API version it was built against on every request, so its response types always match the payload it receives. Upgrading the SDK's major version is how you adopt a new API version; a patch or minor release never changes the shape you get.
The constant is XidentConfig.PINNED_API_VERSION. To trial a newer
version before changing your website's pin:
const xident = new Xident(apiKey, { apiVersion: '2026-08-13' })
Every response echoes the version it was served under, in the
X-API-Version header and in meta.api_version. See
API versioning.
Configuration
import { Xident } from '@xident/node';
const xident = new Xident('sk_live_your_secret_key', {
baseUrl: 'https://api.xident.io', // Default
timeout: 30000, // Request timeout in ms (default: 30s)
maxRetries: 3, // Retries on 5xx and network errors (default: 3)
headers: { // Extra headers on every request
'X-Custom-Header': 'value',
},
});
// Access config
console.log(Xident.VERSION); // '2.0.0'
console.log(xident.config.apiUrl); // 'https://api.xident.io/verify/v1'
| Option | Type | Default | Description |
|---|---|---|---|
apiKey (1st arg) | string | — | Required. Secret API key (sk_live_*). |
baseUrl | string | https://api.xident.io | API base URL. The SDK appends /verify/v1 automatically. |
timeout | number | 30000 | Request timeout in milliseconds. |
maxRetries | number | 3 | Max retries on 5xx server errors and network failures. Exponential backoff (1s, 2s, 4s + jitter). |
headers | Record<string, string> | {} | Extra headers sent with every request. |
Verification
xident.verification.init(params)
Create an init token for starting a verification session. Returns a token and the full URL to redirect the user to. The token is valid for 10 minutes.
const init = await xident.verification.init({
// Required
callback_url: 'https://yoursite.com/verified',
// Optional
min_age: 18, // Age threshold (12, 15, 18, 21, 25)
user_id: 'user-123', // Your internal user ID
theme: 'dark', // Widget theme: 'light', 'dark', 'auto'
locale: 'de', // Widget locale: 'en', 'de', 'fr', etc.
});
// InitResult
console.log(init.token); // 'xit_...' (short-lived, 10-minute TTL)
console.log(init.verifyUrl); // Full URL to redirect the user to
| Parameter | Type | Required | Description |
|---|---|---|---|
callback_url | string | Yes | URL where user is redirected after verification. |
min_age | number | No | Minimum age threshold (12, 15, 18, 21, 25). |
user_id | string | No | Your internal user ID for correlation. |
theme | string | No | Widget theme: 'light', 'dark', 'auto'. |
locale | string | No | Widget locale: 'en', 'de', 'fr', etc. |
expected | ExpectedIdentity | No | Identity data you already hold, checked against the document. See Data match. |
mismatch_policy | 'report' | 'review' | No | 'report' (default) or 'review'. Only with expected. |
xident.verification.getResult(token)
Get the verification result for a token. Call this after the user returns from the verification widget. Never trust URL parameters alone.
// After the user returns from verification, verify the RESULT token (xtk_...)
// server-side. NEVER trust URL parameters alone.
const result = await xident.verification.getResult('xtk_golden0001');
// Status helpers
result.isVerified(); // true if status === 'success'
result.isFailed(); // true if status === 'failed'
result.isPending(); // true if status === 'pending' or 'in_progress'
result.isTerminal(); // true if success, failed, or canceled
// Verification details
result.ageBracket(); // the age gate (12 | 15 | 18 | 21 | 25) if the age check passed, else null
result.method(); // 'full' | 'token' -- alias for result.verificationMode
// Session data -- the v1 tenant result, byte-identical to the session.success
// webhook's `data`
result.token; // 'xtk_golden0001'
result.status; // 'pending' | 'in_progress' | 'success' | 'failed' | 'canceled' | 'expired' | 'pending_tenant_review' | 'awaiting_retake' | 'retaken'
// (note: 'claimed' -- Xident ID account linking -- never appears here; the API
// projects it to 'success' before this field is populated)
result.verified; // boolean -- the one field to branch on for access control
result.reason; // why a non-passing session ended that way; '' on success
result.verificationMode; // 'full' | 'token'
result.externalUserId; // your user_id from init(), or null
result.createdAt; // ISO 8601 timestamp
result.completedAt; // ISO 8601 or null
result.expiresAt; // ISO 8601 or null
// Per-check detail (result.checks)
result.checks.liveness; // { performed, passed }
result.checks.age; // { performed, passed, gate }
result.checks.document; // { performed, passed, documentType, country }
result.checks.faceMatch; // { performed, passed }
result.checks.dataMatch; // { performed, passed, fields } or null unless you sent expected
Data match
Send the identity data you already hold about the user at init and the result reports, per field, whether the presented document agrees: match, mismatch or not_on_document. checks.dataMatch is null when the check was not performed, so gate on dm?.passed === true. With mismatch_policy: 'review' any mismatch sends the session to your review queue with reason data_mismatch. Background in Concepts → Data match.
// Send what you already know about the user; the document confirms it.
// The values travel server to server and never reach the browser.
const init = await xident.verification.init({
callback_url: 'https://yoursite.com/verify/callback',
purpose: 'id_verification', // a data match needs a document
expected: {
first_name: 'Jane',
last_name: 'Smith',
date_of_birth: '1990-05-14', // YYYY-MM-DD
nationality: 'GB', // ISO 3166-1 alpha-2
},
mismatch_policy: 'review', // 'report' (default): reported, outcome unchanged
});
// Later, on the result: one verdict per field you asked about.
const dm = result.checks.dataMatch; // null unless the check was performed
if (dm?.passed) {
// every field matched the document
}
// dm.fields.dateOfBirth is 'match' | 'mismatch' | 'not_on_document' | null
Webhook Verification
Xident sends webhook events to your registered endpoint when a session passes, fails, or is canceled, and when a tenant review is created or resolved. Events are signed with HMAC-SHA256 using a Stripe-style signature header. See Core Concepts → Webhooks for the full event catalog, envelope shape, and signature scheme.
// Xident sends webhook events via HTTP POST with an HMAC-SHA256 signature.
// Header: X-Xident-Signature: t=1710345600,v1=5257a869abcdef...
// constructEvent() verifies the signature AND parses the event in one call.
const event = xident.webhooks.constructEvent(
payload, // Raw JSON body string
signature, // Value of X-Xident-Signature header
secret, // Webhook secret from dashboard (whsec_xxx)
300, // Tolerance in seconds (default: 300 = 5 minutes)
);
// WebhookEvent
console.log(event.type); // 'session.success', 'session.failed', 'session.canceled', etc.
console.log(event.data); // Event payload object -- the v1 tenant result for session.*/review.* events
console.log(event.id); // Event ID or null
console.log(event.created); // Unix timestamp or null
// Or verify the signature separately:
xident.webhooks.verifySignature(payload, signature, secret, 300); // throws on failure
const event2 = xident.webhooks.parseEvent(payload); // parse without verifying
| Method | Description |
|---|---|
constructEvent(payload, signature, secret, tolerance?) | Verify signature + parse event in one call. Throws ValidationError on failure. |
verifySignature(payload, signature, secret, tolerance?) | Verify the HMAC-SHA256 signature only. Returns true or throws. |
parseEvent(payload) | Parse a webhook payload without verifying the signature. |
Important: Use the raw request body (not parsed JSON) for signature verification. Most frameworks have a way to access the raw body (Express: express.raw(), Next.js: req.text()).
Blacklist
The blacklist blocks a known bad actor by face. You add someone in one of two ways: from one of your own completed document verification sessions, or from an image you supply. The face embedding is derived server-side — a raw embedding is never accepted from you and is never returned in any response. Entries carry bookkeeping fields only.
Both add calls are asynchronous. They return status: 'processing' and the entry appears in list() once the server has finished deriving the embedding.
When a blacklisted face turns up in a later document verification, that session fails with reason blacklist_match. The same check runs inside face 2FA.
xident.blacklist.addBySession(params)
Blacklisting from a session works for 12 months after that session. The face embedding is retained for 12 months and then permanently deleted.
import { ValidationError } from '@xident/node';
// Blacklist the person from one of your own completed DOCUMENT verification
// sessions. The server lifts the face from that session -- you never handle
// the image, and a raw embedding is never accepted or returned.
try {
const result = await xident.blacklist.addBySession({
session_token: 'xtk_abc123',
reason: 'chargeback fraud, order 4471',
});
// Adding is asynchronous: the entry appears in list() once the server has
// derived the embedding.
console.log(result.status); // 'processing'
} catch (err) {
if (!(err instanceof ValidationError)) throw err;
switch (err.errorCode) {
case 'SESSION_HAS_NO_FACE_DATA':
// Browser-only check (Path A). No document or selfie was ever captured,
// so there is no face to blacklist. Retrying will never help -- use
// addByImage() if you have a picture of the person.
console.warn('session has no face data');
break;
case 'SESSION_FACE_DATA_EXPIRED':
// It was a document verification, but its face data has passed the
// 12-month retention window and has been deleted.
console.warn('face data expired -- report bad actors sooner');
break;
default:
throw err;
}
}
Session Failures Worth Handling
Both arrive as ValidationError with HTTP 422. Read errorCode to tell them apart.
| Code | HTTP | What it means |
|---|---|---|
SESSION_HAS_NO_FACE_DATA | 422 | The session was a browser-only check (Path A). No document or selfie was ever captured, so there is no face to blacklist. Retrying will never help. |
SESSION_FACE_DATA_EXPIRED | 422 | It was a document verification, but its face data has passed the 12-month retention window and been deleted. |
xident.blacklist.addByImage(params)
import { readFile } from 'node:fs/promises';
const image = (await readFile('suspect.jpg')).toString('base64');
const result = await xident.blacklist.addByImage({
image, // max ~10 MB of base64
reason: 'banned from all venues',
});
console.log(result.status); // 'processing'
xident.blacklist.list(params) and remove(id)
const page = await xident.blacklist.list({ page: 1, per_page: 50 }); // per_page max 100
for (const entry of page.entries) {
// No embedding is present -- entries carry bookkeeping fields only.
console.log(entry.id, entry.reason, entry.source, entry.sessionId, entry.createdAt);
}
console.log(page.pagination.total, page.pagination.totalPages);
// Un-ban: deactivate an entry by its ID.
await xident.blacklist.remove(page.entries[0].id);
Methods
| Method | Returns | Description |
|---|---|---|
list({ page?, per_page? }) | Promise<BlacklistList> | Page through active entries, newest first. Defaults: page 1, 20 per page (per_page max 100). |
addBySession({ session_token, reason }) | Promise<BlacklistAddResult> | Blacklist the person from one of your terminal document sessions. Async. |
addByImage({ image, reason }) | Promise<BlacklistAddResult> | Blacklist the face in a base64 image. Async. |
remove(id) | Promise<BlacklistRemoveResult> | Deactivate an entry (un-ban). |
Add Parameters
| Field | Type | Required | Description |
|---|---|---|---|
session_token | string | Yes (session) | Token of one of your terminal document verification sessions (max 100 chars). A session still in progress is rejected with HTTP 409. |
image | string | Yes (image) | Base64-encoded face image (max ~10 MB of base64, ≈7.5 MB decoded). |
reason | string | Yes | Why the person is being blacklisted (max 500 chars). |
BlacklistEntry
| Field | Type | Description |
|---|---|---|
id | number | Entry ID. Pass it to remove(). |
reason | string | The reason you supplied when adding the entry. |
source | string | How the entry was created: 'session' or 'image'. |
sessionId | number | null | Session the face was lifted from, or null for image entries. |
createdAt | string | RFC 3339 timestamp. |
Face 2FA
Face 2FA is a server-side biometric second factor for your own users. register() enrols a user's face; verify() checks a face against that enrolment (a 1:1 comparison). Both are asynchronous: they return a challenge that you poll with getStatus(). The API returns pass/fail only — never a confidence score, never biometric data.
Billing: face 2FA bills at the Verification rate, because it is a server-side biometric match. You are charged one event per decided verify. Registration is free, and so is an attempt that never reaches a verdict.
Polling a Challenge
import type { Face2FAStatus } from '@xident/node';
// Both register() and verify() return a challenge in 'processing' state.
// Poll getStatus() until it is terminal, then read the verdict.
async function waitForChallenge(
challengeId: string,
timeoutMs = 30_000,
): Promise<Face2FAStatus> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const status = await xident.face2fa.getStatus(challengeId);
if (status.isTerminal()) return status;
if (Date.now() >= deadline) {
throw new Error('face 2FA challenge did not finish in time');
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
xident.face2fa.register(params)
// Enrol a face for one of your users. user_id is your own opaque identifier
// -- Xident never interprets it. Registration is free of charge.
const challenge = await xident.face2fa.register({
user_id: 'usr_123',
image: base64Selfie,
});
const status = await waitForChallenge(challenge.challengeId);
if (status.hasPassed()) {
console.log('face enrolled');
}
xident.face2fa.verify(params)
const challenge = await xident.face2fa.verify({
user_id: 'usr_123',
image: base64Selfie,
});
const status = await waitForChallenge(challenge.challengeId);
if (status.hasPassed()) {
// Second factor passed -- complete the login.
} else {
switch (status.failureReason) {
case 'not_enrolled':
// No face on file -- send the user through register() first.
break;
case 'face_mismatch':
// Different person, or a poor capture. Let them try again.
break;
case 'blacklist_match':
// The face is on your blacklist. Do not let them in.
break;
case 'no_face_detected':
case 'invalid_image':
// Capture problem -- ask for a new photo.
break;
default:
// Treat the set as open: new reasons may be added.
console.warn('face 2FA failed:', status.failureReason);
}
}
getUser(userId) and deleteUser(userId)
// Does this user have a face on file? Use it to decide whether to offer
// face 2FA at login.
const enrollment = await xident.face2fa.getUser('usr_123');
if (enrollment.enrolled) {
console.log('enrolled at', enrollment.enrolledAt);
}
// GDPR hard delete: the stored face is removed, not flagged. Idempotent --
// it succeeds whether or not an enrollment existed.
await xident.face2fa.deleteUser('usr_123');
Methods
| Method | Returns | Description |
|---|---|---|
register({ user_id, image }) | Promise<Face2FAChallenge> | Store or replace the user's face enrolment. Async, free of charge. |
verify({ user_id, image }) | Promise<Face2FAChallenge> | Compare a face against the enrolled one. Async. |
getStatus(challengeId) | Promise<Face2FAStatus> | Poll a challenge for the pass/fail outcome. |
getUser(userId) | Promise<Face2FAEnrollment> | Whether the user has a face enrolled, and when. |
deleteUser(userId) | Promise<Face2FADeleteResult> | GDPR hard delete of the enrolment. Idempotent. |
Submit Parameters
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | Yes | Your own user identifier (max 255 chars). Opaque to Xident — it only has to be stable so a later verify() finds the registered face. |
image | string | Yes | Base64-encoded face image (max ~10 MB of base64, ≈7.5 MB decoded). |
Face2FAStatus
| Field / Method | Type | Description |
|---|---|---|
challengeId | string | The challenge being polled. |
kind | 'enroll' | 'verify' | What the challenge tracks. |
status | Face2FAChallengeStatus | Lifecycle state. |
passed | boolean | null | The verdict: true on completed, false on failed or expired, null while still processing. |
failureReason | Face2FAFailureReason | null | Why a non-passing challenge did not pass. |
expiresAt | string | RFC 3339 timestamp after which the challenge expires. |
completedAt | string | null | RFC 3339 timestamp of the terminal state. |
hasPassed() | boolean | The check to gate on. false both for a failure and for a challenge still running. |
isProcessing() | boolean | Still being processed — keep polling. |
isTerminal() | boolean | No further changes possible. |
Challenge Statuses
| Status | Terminal | Meaning |
|---|---|---|
processing | No | Accepted, not finished. Keep polling. |
completed | Yes | PASSED — the face was enrolled, or it matched. |
failed | Yes | Did not pass. Read failureReason. |
expired | Yes | Not processed within its lifetime. Submit a new challenge. |
Failure Reasons
| Reason | Meaning |
|---|---|
invalid_image | The image could not be decoded. |
no_face_detected | No face was found in the image. |
not_enrolled | verify() was called for a user with no enrolment. |
face_mismatch | The face did not match the enrolled one. |
blacklist_match | The face matched an entry on your blacklist. |
expired | The challenge expired before processing finished. |
internal_error | Processing failed on the Xident side. Safe to retry with a new challenge. |
Treat the reason set as open — new values may be added, so always keep a default branch.
Error Handling
All SDK errors extend XidentError, which carries errorCode, requestId, and httpStatus. Include requestId in support tickets.
import {
Xident,
XidentError, // Base class (has errorCode, requestId, httpStatus)
AuthenticationError, // 401/403 -- invalid or missing API key
ValidationError, // 400 -- bad request params
NotFoundError, // 404 -- token/resource not found
RateLimitError, // 429 -- rate limited (has retryAfter)
ServerError, // 5xx -- server error (auto-retried)
NetworkError, // DNS, timeout, connection refused (has cause)
} from '@xident/node';
try {
const result = await xident.verification.getResult(token);
} catch (error) {
if (error instanceof AuthenticationError) {
console.error('Invalid API key');
console.log(error.errorCode); // API error code (e.g. 'UNAUTHORIZED')
console.log(error.requestId); // Include in support tickets
console.log(error.httpStatus); // 401 or 403
} else if (error instanceof NotFoundError) {
console.error('Token not found or expired');
} else if (error instanceof RateLimitError) {
console.log('Retry after:', error.retryAfter, 'seconds');
} else if (error instanceof ValidationError) {
console.error('Bad request:', error.message);
} else if (error instanceof ServerError) {
// Auto-retried up to maxRetries times with exponential backoff
console.error('Server error after retries:', error.message);
} else if (error instanceof NetworkError) {
console.error('Network failed:', error.cause);
} else if (error instanceof XidentError) {
// Catch-all for any SDK error
console.error(error.message);
}
}
| Error Class | HTTP Status | When Thrown | Key Properties |
|---|---|---|---|
XidentError | any | Base class for all SDK errors | message, errorCode, requestId, httpStatus |
AuthenticationError | 401/403 | Invalid, expired, or missing API key | inherits XidentError |
ValidationError | 400 | Invalid request parameters | inherits XidentError |
NotFoundError | 404 | Token or resource not found | inherits XidentError |
RateLimitError | 429 | Rate limit exceeded | retryAfter (seconds or null) |
ServerError | 5xx | Server error (auto-retried with backoff) | inherits XidentError |
NetworkError | 0 | DNS, timeout, connection refused | cause (original error) |
Framework Examples
Express.js
import express from 'express';
import { Xident, XidentError, AuthenticationError, NotFoundError, RateLimitError } from '@xident/node';
const app = express();
const xident = new Xident(process.env.XIDENT_SECRET_KEY!);
// Create verification session
app.post('/api/verify', express.json(), async (req, res) => {
try {
const init = await xident.verification.init({
callback_url: 'https://your-site.com/webhooks/xident',
min_age: req.body.min_age ?? 18,
user_id: req.body.user_id,
});
res.json({ token: init.token, verifyUrl: init.verifyUrl });
} catch (err) {
if (err instanceof RateLimitError) {
res.status(429).json({ error: 'Rate limited', retryAfter: err.retryAfter });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Check verification result
app.get('/api/verify/:token', async (req, res) => {
try {
const result = await xident.verification.getResult(req.params.token);
res.json({
verified: result.isVerified(),
status: result.status,
ageBracket: result.ageBracket(),
method: result.method(),
});
} catch (err) {
if (err instanceof NotFoundError) {
res.status(404).json({ error: 'Token not found' });
} else if (err instanceof XidentError) {
res.status(err.httpStatus || 500).json({ error: err.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Webhook endpoint -- MUST use raw body for signature verification
app.post('/webhooks/xident', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-xident-signature'] as string;
try {
const event = xident.webhooks.constructEvent(
req.body.toString(),
signature,
process.env.XIDENT_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'session.success':
console.log('User verified! Token:', event.data['token']);
break;
case 'session.failed':
console.log('Verification failed:', event.data['token'], event.data['reason']);
break;
}
res.json({ received: true });
} catch {
res.status(400).json({ error: 'Invalid webhook' });
}
});
app.listen(3000);
Next.js App Router
// app/api/xident/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Xident, RateLimitError, ValidationError } from '@xident/node';
const xident = new Xident(process.env.XIDENT_SECRET_KEY!);
// POST /api/xident -- Create verification session
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const init = await xident.verification.init({
callback_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/xident/webhook`,
min_age: body.minAge ?? 18,
user_id: body.userId,
});
return NextResponse.json({ token: init.token, verifyUrl: init.verifyUrl });
} catch (err) {
if (err instanceof RateLimitError) {
return NextResponse.json(
{ error: 'Rate limited', retryAfter: err.retryAfter },
{ status: 429 },
);
}
if (err instanceof ValidationError) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// app/api/xident/webhook/route.ts -- Webhook handler
// export async function POST(req: NextRequest) {
// const body = await req.text();
// const signature = req.headers.get('x-xident-signature') ?? '';
// try {
// const event = xident.webhooks.constructEvent(
// body, signature, process.env.XIDENT_WEBHOOK_SECRET!
// );
// switch (event.type) {
// case 'session.success': /* update user */ break;
// case 'session.failed': /* handle failure */ break;
// }
// return NextResponse.json({ received: true });
// } catch {
// return NextResponse.json({ error: 'Invalid webhook' }, { status: 400 });
// }
// }
Fastify
import Fastify from 'fastify';
import { Xident, NotFoundError, RateLimitError, XidentError } from '@xident/node';
const fastify = Fastify({ logger: true });
const xident = new Xident(process.env.XIDENT_SECRET_KEY!);
// Create verification session
fastify.post('/api/verify', async (request, reply) => {
const body = request.body as { min_age?: number; user_id?: string };
try {
const init = await xident.verification.init({
callback_url: 'https://your-site.com/webhooks/xident',
min_age: body.min_age ?? 18,
user_id: body.user_id,
});
return { token: init.token, verifyUrl: init.verifyUrl };
} catch (err) {
if (err instanceof RateLimitError) {
return reply.status(429).send({ error: 'Rate limited', retryAfter: err.retryAfter });
}
throw err;
}
});
// Check verification result
fastify.get<{ Params: { token: string } }>('/api/verify/:token', async (request, reply) => {
try {
const result = await xident.verification.getResult(request.params.token);
return {
verified: result.isVerified(),
status: result.status,
ageBracket: result.ageBracket(),
method: result.method(),
};
} catch (err) {
if (err instanceof NotFoundError) {
return reply.status(404).send({ error: 'Token not found' });
}
if (err instanceof XidentError) {
return reply.status(err.httpStatus || 500).send({ error: err.message });
}
throw err;
}
});
// Webhook endpoint
fastify.post('/webhooks/xident', async (request, reply) => {
const signature = request.headers['x-xident-signature'] as string;
const rawBody = typeof request.body === 'string'
? request.body
: JSON.stringify(request.body);
try {
const event = xident.webhooks.constructEvent(
rawBody, signature, process.env.XIDENT_WEBHOOK_SECRET!
);
fastify.log.info({ type: event.type }, 'Webhook received');
return { received: true };
} catch {
return reply.status(400).send({ error: 'Invalid webhook' });
}
});
fastify.listen({ port: 3000 });
Response Types
InitResult
Returned by xident.verification.init().
| Field | Type | Description |
|---|---|---|
token | string | Short-lived init token (xit_ prefixed, 10-minute TTL). |
verifyUrl | string | Full verification URL. Redirect the user here. |
SessionResult
Returned by xident.verification.getResult(). This is the v1 tenant result contract -- the same shape returned by GET /verify/v1/result/{token} and delivered byte-identical as every session.*/review.* webhook's data. It replaces the pre-2.0.0 fields id, minAge, countryCode, requiredMethods, remainingAttempts, startedAt, livenessResult, ageResult, ocrResult, and faceMatchResult, which no longer exist on this type.
| Field / Method | Type | Description |
|---|---|---|
token | string | The result token (xtk_…). |
status | SessionStatus | Current session status. |
verified | boolean | The pass verdict as data, server-derived from status. |
reason | string | Why a non-passing session ended that way; empty on success. |
verificationMode | string | null | Which PATH produced the verdict: full (document path — OCR and/or document-to-selfie face match), age_check (browser-only — liveness and/or age bracket, no document), xident_id (returning user reused a bracket on their Xident account) or eu_wallet. Treat the set as open. Not the request-time auto/document/facial override — that steers which methods run; this reports which path it turned out to be. |
externalUserId | string | null | Your user_id from init(). |
checks | ResultChecks | Per-method performed/passed detail -- see below. |
createdAt | string | ISO 8601 creation timestamp. |
completedAt | string | null | ISO 8601 completion timestamp. |
expiresAt | string | null | ISO 8601 expiry timestamp. |
isVerified() | boolean | true if status === 'success'. |
isFailed() | boolean | true if status === 'failed'. |
isPending() | boolean | true if pending or in_progress. |
isTerminal() | boolean | true if success, failed, or canceled. |
ageBracket() | number | null | checks.age.gate if the age check passed, else null. |
method() | string | null | Alias for verificationMode. |
ResultChecks
| Field | Type | Description |
|---|---|---|
liveness | { performed, passed: boolean } | Whether a liveness check ran and passed. |
age | { performed, passed: boolean; gate: number | null } | gate is the threshold checked (e.g. 18), never the subject's actual age. |
document | { performed, passed: boolean; documentType, country: string | null } | Set only when a document was checked. |
faceMatch | { performed, passed: boolean } | Whether the selfie matched the document photo. |
WebhookEvent
Returned by constructEvent() and parseEvent(). See Core Concepts → Webhooks for the full event catalog and envelope reference.
| Field | Type | Description |
|---|---|---|
type | string | Event type: 'session.success', 'session.failed', 'session.canceled', 'review.created', 'review.approved', 'review.rejected', 'test'. ('session.expired' is subscribable but not yet emitted; 'session.completed' is a deprecated alias still sent to endpoints that subscribed under the old name.) |
data | Record<string, unknown> | For session.*/review.* events, the same fields as SessionResult above (as a plain object, not a class instance). For test, just { message: string }. |
id | string | null | Event ID (also the value of X-Xident-Delivery) -- use for de-duplication. |
created | number | null | Event creation timestamp (unix seconds). |
Session Statuses
// SessionStatus enum values (from @xident/node)
import { SessionStatus } from '@xident/node';
SessionStatus.Pending; // 'pending' -- Session created, user hasn't started
SessionStatus.InProgress; // 'in_progress' -- Verification in progress
SessionStatus.Success; // 'success' -- Passed verification
SessionStatus.Failed; // 'failed' -- Failed verification
SessionStatus.Canceled; // 'canceled' -- Canceled by user or system
SessionStatus.Claimed; // 'claimed' -- Internal lifecycle value (Xident ID account linking).
// Never returned by the result/status/webhook endpoints --
// the API projects it to 'success' first.
Environment Variables
| Variable | Description |
|---|---|
XIDENT_SECRET_KEY | Your secret API key (sk_live_*). Never commit to version control. |
XIDENT_WEBHOOK_SECRET | Webhook signing secret (whsec_*). Found in your dashboard webhook settings. |
Related
- Python SDK — Server-side Python SDK with sync and async clients
- All SDKs — Overview of all Xident SDKs
- API Reference — Full REST API documentation