Node.js SDK

v2.0.0 Secret Key (sk_live_*) TypeScript-first Node.js 18+ Native fetch

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_DATA422The 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_EXPIRED422It 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_tokenstringYes (session)Token of one of your terminal document verification sessions (max 100 chars). A session still in progress is rejected with HTTP 409.
imagestringYes (image)Base64-encoded face image (max ~10 MB of base64, ≈7.5 MB decoded).
reasonstringYesWhy the person is being blacklisted (max 500 chars).

BlacklistEntry

Field Type Description
idnumberEntry ID. Pass it to remove().
reasonstringThe reason you supplied when adding the entry.
sourcestringHow the entry was created: 'session' or 'image'.
sessionIdnumber | nullSession the face was lifted from, or null for image entries.
createdAtstringRFC 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_idstringYesYour own user identifier (max 255 chars). Opaque to Xident — it only has to be stable so a later verify() finds the registered face.
imagestringYesBase64-encoded face image (max ~10 MB of base64, ≈7.5 MB decoded).

Face2FAStatus

Field / Method Type Description
challengeIdstringThe challenge being polled.
kind'enroll' | 'verify'What the challenge tracks.
statusFace2FAChallengeStatusLifecycle state.
passedboolean | nullThe verdict: true on completed, false on failed or expired, null while still processing.
failureReasonFace2FAFailureReason | nullWhy a non-passing challenge did not pass.
expiresAtstringRFC 3339 timestamp after which the challenge expires.
completedAtstring | nullRFC 3339 timestamp of the terminal state.
hasPassed()booleanThe check to gate on. false both for a failure and for a challenge still running.
isProcessing()booleanStill being processed — keep polling.
isTerminal()booleanNo further changes possible.

Challenge Statuses

Status Terminal Meaning
processingNoAccepted, not finished. Keep polling.
completedYesPASSED — the face was enrolled, or it matched.
failedYesDid not pass. Read failureReason.
expiredYesNot processed within its lifetime. Submit a new challenge.

Failure Reasons

Reason Meaning
invalid_imageThe image could not be decoded.
no_face_detectedNo face was found in the image.
not_enrolledverify() was called for a user with no enrolment.
face_mismatchThe face did not match the enrolled one.
blacklist_matchThe face matched an entry on your blacklist.
expiredThe challenge expired before processing finished.
internal_errorProcessing 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
tokenstringThe result token (xtk_…).
statusSessionStatusCurrent session status.
verifiedbooleanThe pass verdict as data, server-derived from status.
reasonstringWhy a non-passing session ended that way; empty on success.
verificationModestring | nullWhich 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.
externalUserIdstring | nullYour user_id from init().
checksResultChecksPer-method performed/passed detail -- see below.
createdAtstringISO 8601 creation timestamp.
completedAtstring | nullISO 8601 completion timestamp.
expiresAtstring | nullISO 8601 expiry timestamp.
isVerified()booleantrue if status === 'success'.
isFailed()booleantrue if status === 'failed'.
isPending()booleantrue if pending or in_progress.
isTerminal()booleantrue if success, failed, or canceled.
ageBracket()number | nullchecks.age.gate if the age check passed, else null.
method()string | nullAlias 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
typestringEvent 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.)
dataRecord<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 }.
idstring | nullEvent ID (also the value of X-Xident-Delivery) -- use for de-duplication.
creatednumber | nullEvent 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