@xident/node
v3.2.0
Published
Official Xident Node.js SDK for age and identity verification
Maintainers
Readme
@xident/node
Official Node.js SDK for Xident age and identity verification. Try it live at demo.xident.io.
Zero runtime dependencies. TypeScript strict. Native fetch (Node 18+). Dual ESM+CJS.
Installation
npm install @xident/nodev2.0.0 — Breaking Changes
SessionResult (the return value of verification.getResult(), and the
shape of every webhook's data field) moved to the API's frozen v1 result
contract. The old blob-style fields are gone, replaced by a typed checks
object:
| Removed | Replaced by |
|---|---|
| result.livenessResult (raw object) | result.checks.liveness.performed / .passed |
| result.ageResult (raw object) | result.checks.age.performed / .passed / .gate |
| result.ocrResult (raw object) | result.checks.document.performed / .passed / .documentType / .country |
| result.faceMatchResult (raw object) | result.checks.faceMatch.performed / .passed |
| result.ocrTaskId | (removed — was never part of the frozen contract) |
| result.countryCode | result.checks.document.country |
| result.regime | (removed — was never part of the frozen contract) |
| result.requiredMethods | (removed — was never part of the frozen contract) |
| result.remainingAttempts | (removed — was never part of the frozen contract) |
New fields: result.verified (server-derived mirror of status === "success"
— prefer isVerified()), result.verificationType, result.externalUserId,
result.completedAt.
ageBracket() and method() keep their signatures but now read from
checks — ageBracket() returns checks.age.gate only when
checks.age.passed is true, null otherwise; method() returns
verificationType. isVerified() is unchanged: still status === "success".
Webhook event names were already session.success / session.failed /
session.canceled server-side; this release just fixes the SDK's own
docs/examples, which still showed the retired verification.completed /
verification.failed names from before that rename.
This SDK still tolerates payloads from a deployment predating this change —
fromObject() never throws on a missing checks object, it falls back to an
all-performed: false zero value.
Quick Start
import { Xident } from '@xident/node';
const xident = new Xident('sk_live_your_key_here');
// 1. Create a verification session, then redirect the browser to init.verifyUrl
const init = await xident.verification.init({
callback_url: 'https://your-site.com/verify/callback', // a GET route you own
min_age: 18,
user_id: 'user_12345',
});
console.log('Redirect user to:', init.verifyUrl);
// init.token is the xit_ init token — used only to build verifyUrl. Do NOT
// pass it to getResult().
// 2. The widget redirects the browser BACK to your callback_url with query
// params: ?status=success|failed|canceled&token=xtk_...&user_id=...
// In that GET route, read the xtk_ RESULT token and verify it server-side:
const result = await xident.verification.getResult(resultTokenFromQuery); // xtk_...
if (result.isVerified()) {
console.log('User verified! Age bracket:', result.ageBracket());
}Two different tokens.
init.token(xit_…) is a one-time, 10-minute init token that only builds the redirect URL. The result token (xtk_…) arrives as thetokenquery parameter on yourcallback_url— that is the one you pass togetResult(). Never reuse the init token for the result lookup.
Configuration
const xident = new Xident('sk_live_xxx', {
baseUrl: 'https://api.xident.io', // default
timeout: 30_000, // 30 seconds (default)
maxRetries: 3, // retries on 5xx errors (default)
headers: { 'X-Custom': 'value' }, // extra headers
});Verification
verification.init(params)
Create an init token for starting a verification session.
const init = await xident.verification.init({
callback_url: 'https://your-site.com/verify/callback', // required, HTTPS (or http://localhost in dev)
min_age: 18, // 1–99 (0–99 when purpose is "id_verification")
user_id: 'user_123', // your internal user ID (echoed back on the callback)
theme: 'system', // "light" | "dark" | "system" (default "system")
locale: 'de', // widget language
purpose: 'age_verification', // or "id_verification" (allows min_age: 0)
liveness_difficulty: 'medium', // optional: "easy" | "medium" | "hard"
});
// init.token — short-lived xit_ init token (one-time, 10 min); only used to build verifyUrl
// init.verifyUrl — redirect the browser here (verify.xident.io?t=<xit_...>)Handling the callback
The widget redirects the browser back to your callback_url with query
parameters (a plain GET redirect — this is NOT the webhook):
| Param | Value |
|---|---|
| status | success | failed | canceled |
| token | the xtk_… result token (different from the xit_ init token) |
| user_id | echoed back if you supplied one |
Read the token and verify it server-side. Treat the status param as a hint
only — getResult() is the source of truth.
// Express: GET route registered at your callback_url
app.get('/verify/callback', async (req, res) => {
const { status, token, user_id } = req.query;
if (status === 'canceled') return res.redirect('/verify/canceled');
const result = await xident.verification.getResult(String(token)); // xtk_...
if (result.isVerified()) {
// mark user_id as verified in your database
return res.redirect('/verified');
}
return res.redirect('/failed');
});verification.getResult(token)
Pass the xtk_ result token from the callback query string (never the
xit_ init token). Always verify server-side; never trust the status
query parameter alone.
const result = await xident.verification.getResult(resultToken); // xtk_...
result.token // the xtk_ result token
result.isVerified() // true if verification succeeded
result.isFailed() // true if verification failed
result.isPending() // true if still in progress
result.isTerminal() // true if no more changes possible
result.ageBracket() // 12, 15, 18, 21, or 25 — only when the age check passed, else null
result.method() // "full" | "age_check" | "xident_id" | "eu_wallet" (or null)
result.reason // e.g. "age_below_threshold" (empty string on success)
result.externalUserId // your user_id, if you supplied one (or null)
result.checks.liveness // { performed, passed }
result.checks.age // { performed, passed, gate } — gate is the age threshold tested against
result.checks.document // { performed, passed, documentType, country }
result.checks.faceMatch // { performed, passed }
result.createdAt // RFC 3339
result.completedAt // RFC 3339, or null while not yet terminal
result.expiresAt // RFC 3339, or null
result.status // "pending" | "in_progress" | "success" | "failed" | "canceled" | "claimed"Webhooks (optional)
Webhooks are a separate, server-to-server channel from the browser
callback above. Use them for out-of-band notifications; the browser callback +
getResult() remains the primary flow.
Verify webhook signatures using HMAC-SHA256 with constant-time comparison.
// Express.js example — use raw body!
app.post('/webhooks/xident', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = xident.webhooks.constructEvent(
req.body.toString(),
req.headers['x-xident-signature'] as string,
process.env.XIDENT_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'session.success':
// Handle success
break;
case 'session.failed':
// Handle failure
break;
case 'session.canceled':
// Handle cancellation
break;
}
res.json({ received: true });
} catch (err) {
res.status(400).json({ error: 'Invalid webhook' });
}
});webhooks.constructEvent(payload, signature, secret, tolerance?)
Verify signature and parse the event in one call.
webhooks.verifySignature(payload, signature, secret, tolerance?)
Verify only the signature (returns true or throws ValidationError).
Face 2FA
Standalone face-based second factor for your users. Both mutating calls are
async: they return a challenge in processing status and you poll
getStatus() for the pass/fail verdict. The API returns pass/fail only —
never confidence scores or biometric data.
// 1. Enroll a face for one of your users (free of charge)
const enroll = await xident.face2fa.register({
user_id: 'user_123', // YOUR identifier, opaque to Xident
image: base64Selfie, // base64-encoded face image
});
// 2. Later, at login: verify a fresh selfie against the enrolled face
const challenge = await xident.face2fa.verify({
user_id: 'user_123',
image: base64LoginSelfie,
});
// 3. Poll for the verdict
const status = await xident.face2fa.getStatus(challenge.challengeId);
status.isProcessing() // still working — keep polling
status.isTerminal() // completed | failed | expired
status.hasPassed() // the check to gate on
status.kind // "enroll" | "verify"
status.failureReason // "invalid_image" | "no_face_detected" | "not_enrolled"
// | "face_mismatch" | "blacklist_match" | "expired"
// | "internal_error" | nullface2fa.getUser(userId) / face2fa.deleteUser(userId)
const info = await xident.face2fa.getUser('user_123');
// info.enrolled — whether the user has a face enrolled
// info.enrolledAt — when, or null
await xident.face2fa.deleteUser('user_123'); // GDPR hard delete, idempotentBlacklist
Your tenant's face deny list — a blacklisted face fails any future
verification with reason blacklist_match. Entries are added by session
or image only; embeddings are derived server-side and never leave the
server. Additions are async — the entry appears in list() once processed.
// Blacklist the person from one of your completed verification sessions
// (must be terminal and within the 24h document-retention window):
await xident.blacklist.addBySession({
session_token: 'xvs_...',
reason: 'chargeback fraud',
}); // → { status: "processing" }
// Or blacklist the face in an image you supply:
await xident.blacklist.addByImage({
image: base64Image,
reason: 'known fraudster',
}); // → { status: "processing" }
// List entries (paginated, newest first):
const page = await xident.blacklist.list({ page: 1, per_page: 20 });
for (const entry of page.entries) {
console.log(entry.id, entry.reason, entry.source, entry.createdAt);
}
console.log(page.pagination.total, 'entries in', page.pagination.totalPages, 'pages');
// Un-ban (the audit record survives server-side):
await xident.blacklist.remove(entryId); // → { message: "blacklist entry removed" }Error Handling
All errors extend XidentError with errorCode, requestId, and httpStatus.
import { Xident, XidentError, AuthenticationError, RateLimitError } from '@xident/node';
try {
await xident.verification.init({ callback_url: '...' });
} catch (err) {
if (err instanceof RateLimitError) {
console.log('Retry after', err.retryAfter, 'seconds');
} else if (err instanceof AuthenticationError) {
console.log('Check your API key');
} else if (err instanceof XidentError) {
console.log(err.message, err.errorCode, err.requestId);
}
}| Error Class | HTTP Status | When |
|---|---|---|
| AuthenticationError | 401, 403 | Invalid or missing API key |
| ValidationError | 400, 422 | Invalid request parameters |
| NotFoundError | 404 | Token or resource not found |
| RateLimitError | 429 | Rate limit exceeded |
| ServerError | 5xx | Server-side error (retried automatically) |
| NetworkError | n/a | DNS, timeout, connection failure |
Requirements
- Node.js 18+ (uses native
fetch) - TypeScript 5.5+ (optional but recommended)
License
MIT
