Python SDK
The Python SDK (xident) provides both synchronous and asynchronous clients for server-side verification. It uses httpx under the hood with automatic retries and exponential backoff. Works with Flask, Django, FastAPI, and any Python 3.10+ project.
Server-side only. This SDK uses your secret API key (sk_live_*). Never expose it in client-side code.
Installation
pip install xident
Quick Start
Synchronous
from xident import Xident
client = Xident(api_key="sk_live_your_secret_key")
# 1. Create an init token (redirect user to result.verify_url)
init = client.verification.init(
callback_url="https://yoursite.com/verified",
min_age=18,
)
print(init.token) # 'xit_...'
print(init.verify_url) # 'https://verify.xident.io/...'
# 2. Xident redirects the user back to callback_url with ?token=xtk_...
# Read that RESULT token (NOT init.token, the one-time xit_ token above)
# and verify it server-side.
result_token = request.args["token"] # e.g. 'xtk_golden0001'
result = client.verification.get_result(result_token)
if result.is_verified():
print("Age bracket:", result.age_bracket()) # 18
print("Method:", result.method()) # 'full'
Asynchronous
from xident import AsyncXident
async_client = AsyncXident(api_key="sk_live_your_secret_key")
# Same API, just await the calls
init = await async_client.verification.init(
callback_url="https://yoursite.com/verified",
min_age=18,
)
result = await async_client.verification.get_result(init.token)
if result.is_verified():
print("Verified!", result.age_bracket())
# Clean up when done
await async_client.aclose()
API version
This SDK sends the dated API version it was built against on every request, so its dataclasses always match the payload they parse. 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 xident.PINNED_API_VERSION. To trial a newer version
before changing your website's pin:
client = Xident(api_key=key, api_version="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
from xident import Xident
# All configuration options
client = Xident(
api_key="sk_live_your_secret_key", # Required
base_url="https://api.xident.io", # Default (SDK appends /verify/v1)
timeout=30, # Request timeout in seconds (default: 30)
max_retries=3, # Retries on 5xx errors (default: 3)
headers={"X-Custom": "value"}, # Extra headers on every request
)
# Access config
print(client.config.api_url) # 'https://api.xident.io/verify/v1'
print(Xident.version()) # '2.0.0'
| Parameter | Type | Default | Description |
|---|---|---|---|
api_key | str | — | Required. Secret API key (sk_live_*). |
base_url | str | http://localhost:9000 | API base URL. The SDK appends /verify/v1 automatically. |
timeout | int | 30 | Request timeout in seconds. |
max_retries | int | 3 | Max retries on 5xx server errors. Exponential backoff with jitter. |
headers | dict[str, str] | None | Extra headers sent with every request. |
Context Manager
Both Xident and AsyncXident support context managers for automatic cleanup:
# Context manager auto-closes the HTTP client
# Sync
with Xident(api_key="sk_live_xxx") as client:
result = client.verification.init(callback_url="https://example.com/cb")
# client.close() called automatically
# Async
async with AsyncXident(api_key="sk_live_xxx") as client:
result = await client.verification.init(callback_url="https://example.com/cb")
# await client.aclose() called automatically
Verification
client.verification.init(**kwargs)
Create an init token for starting a verification session. All parameters are keyword-only. Returns an InitResult with token and verify_url. The token is valid for 10 minutes.
result = client.verification.init(
# Required (keyword-only arguments)
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 (frozen dataclass)
print(result.token) # 'xit_...' (short-lived, 10-minute TTL)
print(result.verify_url) # Full URL to redirect the user to
| Parameter | Type | Required | Description |
|---|---|---|---|
callback_url | str | Yes | URL where user is redirected after verification. |
min_age | int | No | Minimum age threshold (12, 15, 18, 21, 25). |
user_id | str | No | Your internal user ID for correlation. |
theme | str | No | Widget theme: 'light', 'dark', 'auto'. |
locale | str | No | Widget locale: 'en', 'de', 'fr', etc. |
expected | Mapping[str, str] | No | Identity data you already hold, checked against the document. See Data match. |
mismatch_policy | str | No | "report" (default) or "review". Only with expected. |
client.verification.get_result(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.
result = client.verification.get_result("xtk_golden0001")
# Status helpers
result.is_verified() # True if status == 'success'
result.is_failed() # True if status == 'failed'
result.is_pending() # True if status in ('pending', 'in_progress')
result.is_terminal() # True if success, failed, or canceled
# Verification details
result.age_bracket() # the age gate (12, 15, 18, 21, 25) if the age check passed, else None
result.method() # 'full' | 'age_check' | 'xident_id' | 'eu_wallet' -- alias for result.verification_type
# Session data (frozen dataclass attributes) -- the v1 tenant result, byte-
# identical to the session.success webhook's "data"
result.token # 'xtk_golden0001' (result.id is a deprecated alias)
result.status # SessionStatus enum
result.verified # bool -- the one field to branch on for access control
result.reason # why a non-passing session ended that way; '' on success
result.verification_type # 'full' | 'age_check' | 'xident_id' | 'eu_wallet'
result.external_user_id # Your user_id from init(), or None
result.created_at # ISO 8601 timestamp
result.completed_at # ISO 8601 or None
result.expires_at # ISO 8601 or None
# Per-check detail
result.checks.liveness # LivenessCheck(performed, passed)
result.checks.age # AgeCheck(performed, passed, gate)
result.checks.document # DocumentCheck(performed, passed, document_type, country)
result.checks.face_match # FaceMatchCheck(performed, passed)
result.checks.data_match # DataMatchCheck(performed, passed, fields) or None 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.data_match is None when the check was not performed, so gate on dm is not None and dm.passed. 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.
result = client.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.
dm = session.checks.data_match # None unless the check was performed
if dm is not None and dm.passed:
... # every field matched the document
# dm.fields.date_of_birth is "match", "mismatch", "not_on_document" or None
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. The webhooks resource is stateless -- it does not use the HTTP client.
# Xident sends webhook events via HTTP POST with an HMAC-SHA256 signature.
# Header: X-Xident-Signature: t=1710345600,v1=5257a869abcdef...
# construct_event() verifies the signature AND parses the event in one call.
event = client.webhooks.construct_event(
payload=raw_body, # Raw JSON body (str or bytes)
signature=signature, # Value of X-Xident-Signature header
secret=webhook_secret, # Webhook secret from dashboard (whsec_xxx)
tolerance=300, # Max age in seconds (default: 300 = 5 minutes)
)
# Returns a dict with keys: type, data, id, created
print(event["type"]) # 'session.success', 'session.failed', 'session.canceled', etc.
print(event["data"]) # Event payload dict -- the v1 tenant result for session.*/review.* events
print(event["id"]) # Event ID or None
print(event["created"]) # Unix timestamp or None
# Or verify the signature separately:
client.webhooks.verify_signature(payload, signature, secret, tolerance=300)
# Returns True or raises ValueError
# Parse without verifying:
from xident.resources.webhooks import Webhooks
event = Webhooks.parse_event(payload) # Static method
| Method | Returns | Description |
|---|---|---|
construct_event(payload, signature, secret, *, tolerance=300) | dict | Verify signature + parse event. Raises ValueError on failure. |
verify_signature(payload, signature, secret, *, tolerance=300) | bool | Verify HMAC-SHA256 signature only. Returns True or raises ValueError. |
parse_event(payload) | dict | Parse a webhook payload without verifying the signature. Static method. |
Important: Use the raw request body for signature verification. In Flask use request.get_data(as_text=True), in Django use request.body.decode("utf-8"), in FastAPI use await request.body() (accepts both str and bytes).
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 the string "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.
client.blacklist.add_by_session(...)
Blacklisting from a session works for 12 months after that session. The face embedding is retained for 12 months and then permanently deleted.
from xident import ValidationError
# 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:
status = client.blacklist.add_by_session(
session_token="xtk_abc123",
reason="chargeback fraud, order 4471",
)
# Adding is asynchronous: the entry appears in list() once the server
# has derived the embedding.
print(status) # 'processing'
except ValidationError as e:
if e.error_code == "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 add_by_image() if you have a picture of the person.
print("session has no face data")
elif e.error_code == "SESSION_FACE_DATA_EXPIRED":
# It was a document verification, but its face data has passed the
# 12-month retention window and has been deleted.
print("face data expired -- report bad actors sooner")
else:
raise
Session Failures Worth Handling
Both arrive as ValidationError with HTTP 422. Read error_code 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. |
client.blacklist.add_by_image(...)
import base64
from pathlib import Path
image = base64.b64encode(Path("suspect.jpg").read_bytes()).decode()
status = client.blacklist.add_by_image(
image=image, # max ~10MB of base64
reason="banned from all venues",
)
print(status) # 'processing'
client.blacklist.list(...) and remove(entry_id)
page = client.blacklist.list(page=1, per_page=50) # per_page max 100
for entry in page: # BlacklistPage is iterable
# No embedding is present -- entries carry bookkeeping fields only.
print(entry.id, entry.reason, entry.source, entry.session_id, entry.created_at)
print(page.total, page.total_pages, page.has_more)
# Un-ban: deactivate an entry by its id.
client.blacklist.remove(page.entries[0].id)
Methods
| Method | Returns | Description |
|---|---|---|
list(*, page=None, per_page=None) | BlacklistPage | Page through active entries. Server defaults: page 1, 20 per page (per_page max 100). |
add_by_session(*, session_token, reason) | str | Blacklist the person from one of your terminal document sessions. Async. |
add_by_image(*, image, reason) | str | Blacklist the face in a base64 image. Async. |
remove(entry_id) | None | Deactivate an entry (un-ban). Raises ValueError if entry_id is not positive. |
Add Parameters
All parameters are keyword-only.
| Parameter | Type | Required | Description |
|---|---|---|---|
session_token | str | 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 | str | Yes (image) | Base64-encoded face image (max ~10MB of base64). |
reason | str | Yes | Why the person is being blacklisted (max 500 chars). |
BlacklistPage and BlacklistEntry
| Attribute | Type | Description |
|---|---|---|
page.entries | list[BlacklistEntry] | The entries on this page. BlacklistPage is iterable and supports len(). |
page.page / page.per_page | int | Current page number (1-based) and page size. |
page.total / page.total_pages | int | Entries across all pages, and the page count. |
page.has_more | bool | Property. Whether pages beyond this one exist. |
entry.id | int | Entry identifier. Pass it to remove(). |
entry.reason | str | The reason you supplied when adding the entry. |
entry.source | str | How the entry was created: "session" or "image". |
entry.session_id | int | None | Session the face was lifted from, or None for image entries. |
entry.created_at | str | ISO 8601 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 get_status(). 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 time
from xident import Face2FAStatus
# 'processing' is the only non-terminal state.
TERMINAL_STATUSES = ("completed", "failed", "expired")
def wait_for_challenge(challenge_id: str, timeout: float = 30.0) -> Face2FAStatus:
"""Poll a register/verify challenge until it reaches a terminal state."""
deadline = time.monotonic() + timeout
while True:
status = client.face_2fa.get_status(challenge_id)
if status.status in TERMINAL_STATUSES:
return status
if time.monotonic() >= deadline:
raise TimeoutError("face 2FA challenge did not finish in time")
time.sleep(0.5)
client.face_2fa.register(...)
# Enrol a face for one of your users. user_id is your own opaque identifier
# -- Xident never interprets it. Registration is free of charge.
challenge = client.face_2fa.register(user_id="user_42", image=b64_selfie)
status = wait_for_challenge(challenge.challenge_id)
if status.is_passed():
print("face enrolled")
client.face_2fa.verify(...)
challenge = client.face_2fa.verify(user_id="user_42", image=b64_selfie)
status = wait_for_challenge(challenge.challenge_id)
if status.is_passed():
... # Second factor passed -- complete the login
elif status.failure_reason == "not_enrolled":
... # No face on file -- send the user through register() first
elif status.failure_reason == "face_mismatch":
... # Different person, or a poor capture. Let them try again
elif status.failure_reason == "blacklist_match":
... # The face is on your blacklist. Do not let them in
elif status.failure_reason in ("no_face_detected", "invalid_image"):
... # Capture problem -- ask for a new photo
else:
# Treat the set as open: new reasons may be added.
print("face 2FA failed:", status.failure_reason)
get_user(user_id) and delete_user(user_id)
# Does this user have a face on file? Use it to decide whether to offer
# face 2FA at login.
enrollment = client.face_2fa.get_user("user_42")
if enrollment.enrolled:
print("enrolled at", enrollment.enrolled_at)
# GDPR hard delete: the stored face is removed, not flagged. Idempotent --
# it succeeds whether or not an enrollment existed.
client.face_2fa.delete_user("user_42")
Async Client
AsyncXident exposes the same resources with the same names — only the calls are awaited:
import asyncio
from xident import AsyncXident
async_client = AsyncXident(api_key="sk_live_your_secret_key")
async def wait_for_challenge(challenge_id: str, timeout: float = 30.0):
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while True:
status = await async_client.face_2fa.get_status(challenge_id)
if status.status in ("completed", "failed", "expired"):
return status
if loop.time() >= deadline:
raise TimeoutError("face 2FA challenge did not finish in time")
await asyncio.sleep(0.5)
challenge = await async_client.face_2fa.verify(user_id="user_42", image=b64_selfie)
status = await wait_for_challenge(challenge.challenge_id)
# The blacklist resource mirrors the sync one, with await:
page = await async_client.blacklist.list(page=1, per_page=50)
await async_client.blacklist.add_by_image(image=b64_image, reason="fraud")
Methods
| Method | Returns | Description |
|---|---|---|
register(*, user_id, image) | Face2FAChallenge | Store or replace the user's face enrolment. Async, free of charge. |
verify(*, user_id, image) | Face2FAChallenge | Compare a face against the enrolled one. Async. |
get_status(challenge_id) | Face2FAStatus | Poll a challenge for the pass/fail outcome. |
get_user(user_id) | Face2FAEnrollment | Whether the user has a face enrolled, and when. |
delete_user(user_id) | bool | GDPR hard delete of the enrolment. Idempotent. |
Submit Parameters
Both parameters are keyword-only.
| Parameter | Type | Required | Description |
|---|---|---|---|
user_id | str | 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 | str | Yes | Base64-encoded face image (max ~10MB of base64). |
Face2FAStatus
| Attribute / Method | Type | Description |
|---|---|---|
challenge_id | str | The challenge being polled. |
kind | str | "enroll" or "verify". |
status | str | Lifecycle state. |
passed | bool | None | The verdict: True on completed, False on failed or expired, None while still processing. |
failure_reason | str | None | Why a non-passing challenge did not pass. |
expires_at | str | ISO 8601 timestamp after which the challenge expires. |
completed_at | str | None | ISO 8601 timestamp of the terminal state. |
is_passed() | bool | The check to gate on. False both for a failure and for a challenge still running. |
is_processing() | bool | Still being processed — keep polling. |
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 failure_reason. |
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 fallback branch.
Error Handling
The SDK has a clear exception hierarchy. APIError carries status_code, error_code, and request_id for debugging.
from xident import (
Xident,
XidentError, # Base exception
APIError, # Base for HTTP errors (has status_code, error_code, request_id)
AuthenticationError, # 401/403 -- invalid or missing API key
ValidationError, # 400 -- bad request params
NotFoundError, # 404 -- token/resource not found
RateLimitError, # 429 -- rate limited (has retry_after)
ServerError, # 5xx -- server error (auto-retried)
NetworkError, # DNS, timeout, connection refused
)
try:
result = client.verification.get_result(token)
except AuthenticationError as e:
print(f"Invalid API key: {e.error_code}")
print(f"Request ID: {e.request_id}") # Include in support tickets
print(f"HTTP status: {e.status_code}") # 401 or 403
except NotFoundError:
print("Token not found or expired")
except RateLimitError as e:
print(f"Retry after: {e.retry_after} seconds")
except ValidationError as e:
print(f"Bad request: {e.message}")
except ServerError:
# Auto-retried up to max_retries times
print("Server error after retries")
except NetworkError:
print("Network failure (DNS, timeout, etc.)")
except XidentError as e:
# Catch-all for any SDK error
print(f"SDK error: {e.message}")
| Exception | HTTP Status | When Raised | Key Attributes |
|---|---|---|---|
XidentError | — | Base exception for all SDK errors | message |
APIError | any | Base for HTTP errors | status_code, error_code, request_id |
AuthenticationError | 401/403 | Invalid, expired, or missing API key | inherits APIError |
ValidationError | 400 | Invalid request parameters | inherits APIError |
NotFoundError | 404 | Token or resource not found | inherits APIError |
RateLimitError | 429 | Rate limit exceeded | retry_after (int or None) |
ServerError | 5xx | Server error (auto-retried with backoff) | inherits APIError |
NetworkError | — | DNS, timeout, SSL, connection refused | message |
Note: Webhook verification raises ValueError (not XidentError) to match the Stripe SDK convention. Catch ValueError in webhook handlers.
Framework Examples
Flask
import os
from flask import Flask, jsonify, redirect, request
from xident import Xident, XidentError
app = Flask(__name__)
client = Xident(api_key=os.environ["XIDENT_SECRET_KEY"])
@app.route("/verify")
def start_verification():
"""Redirect user to Xident verification widget."""
try:
result = client.verification.init(
callback_url=request.url_root.rstrip("/") + "/verify/callback",
min_age=18,
theme="auto",
)
return redirect(result.verify_url)
except XidentError as e:
return jsonify({"error": str(e)}), 500
@app.route("/verify/callback")
def verification_callback():
"""Verify the token server-side after user returns."""
token = request.args.get("token")
if not token:
return jsonify({"error": "Missing token"}), 400
try:
session = client.verification.get_result(token)
if session.is_verified():
return jsonify({
"status": "verified",
"age_bracket": session.age_bracket(),
})
elif session.is_failed():
return jsonify({"status": "failed"}), 403
else:
return jsonify({"status": "in_progress"}), 202
except XidentError as e:
return jsonify({"error": str(e)}), 500
@app.route("/webhook", methods=["POST"])
def webhook():
"""Handle Xident webhook events."""
payload = request.get_data(as_text=True)
signature = request.headers.get("X-Xident-Signature", "")
try:
event = client.webhooks.construct_event(
payload, signature, os.environ["XIDENT_WEBHOOK_SECRET"]
)
print(f"Webhook: {event['type']}")
return jsonify({"status": "ok"})
except ValueError as e:
return jsonify({"error": str(e)}), 400
Django
# views.py
import os
from django.conf import settings
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.views.decorators.http import require_GET, require_POST
from xident import Xident, XidentError
client = Xident(
api_key=getattr(settings, "XIDENT_SECRET_KEY", os.environ["XIDENT_SECRET_KEY"])
)
@require_GET
def start_verification(request: HttpRequest) -> HttpResponse:
"""Redirect user to Xident verification widget."""
try:
result = client.verification.init(
callback_url=request.build_absolute_uri("/verify/callback/"),
min_age=18,
user_id=str(request.user.pk) if request.user.is_authenticated else None,
theme="auto",
)
return redirect(result.verify_url)
except XidentError:
return JsonResponse({"error": "Failed to start verification"}, status=500)
@require_GET
def verification_callback(request: HttpRequest) -> HttpResponse:
"""Verify the token server-side after user returns."""
token = request.GET.get("token")
if not token:
return JsonResponse({"error": "Missing token"}, status=400)
try:
session = client.verification.get_result(token)
if session.is_verified():
if request.user.is_authenticated:
request.user.age_verified = True
request.user.age_bracket = session.age_bracket()
request.user.save()
return redirect("/verify/success/")
elif session.is_failed():
return redirect("/verify/failed/")
else:
return JsonResponse({"status": "in_progress"}, status=202)
except XidentError:
return JsonResponse({"error": "Verification check failed"}, status=500)
@require_POST
def webhook(request: HttpRequest) -> HttpResponse:
"""Handle Xident webhook events (exempt from CSRF via decorator)."""
payload = request.body.decode("utf-8")
signature = request.META.get("HTTP_X_XIDENT_SIGNATURE", "")
try:
event = client.webhooks.construct_event(
payload, signature,
getattr(settings, "XIDENT_WEBHOOK_SECRET", ""),
)
if event["type"] == "session.success":
pass # Process completed verification
return JsonResponse({"status": "ok"})
except ValueError:
return JsonResponse({"error": "Invalid signature"}, status=400)
FastAPI (Async)
Use AsyncXident with FastAPI for non-blocking I/O:
import os
from fastapi import FastAPI, Header, Request
from fastapi.responses import RedirectResponse
from xident import AsyncXident, XidentError
app = FastAPI()
# Use AsyncXident for non-blocking I/O
client = AsyncXident(api_key=os.environ["XIDENT_SECRET_KEY"])
@app.on_event("shutdown")
async def shutdown():
await client.aclose()
@app.get("/verify")
async def start_verification(request: Request):
"""Redirect user to Xident verification widget."""
try:
result = await client.verification.init(
callback_url=str(request.url_for("verification_callback")),
min_age=18,
theme="auto",
)
return RedirectResponse(url=result.verify_url)
except XidentError as e:
return {"error": str(e)}
@app.get("/verify/callback")
async def verification_callback(token: str):
"""Verify the token server-side after user returns."""
try:
session = await client.verification.get_result(token)
if session.is_verified():
return {
"status": "verified",
"age_bracket": session.age_bracket(),
"method": session.method(),
"country": session.checks.document.country,
}
elif session.is_failed():
return {"status": "failed"}
else:
return {"status": "pending"}
except XidentError as e:
return {"error": str(e)}
@app.post("/webhook")
async def webhook(
request: Request,
x_xident_signature: str = Header(""),
):
"""Handle Xident webhook events."""
payload = await request.body() # bytes -- construct_event accepts both
try:
event = client.webhooks.construct_event(
payload, x_xident_signature,
os.environ.get("XIDENT_WEBHOOK_SECRET", ""),
)
match event["type"]:
case "session.success":
pass # Process completed verification
case "session.failed":
pass # Handle failure
return {"status": "ok"}
except ValueError as e:
return {"error": f"Invalid signature: {e}"}
Response Types
InitResult
Returned by client.verification.init(). Frozen dataclass.
| Attribute | Type | Description |
|---|---|---|
token | str | Short-lived init token (xit_ prefixed, 10-minute TTL). |
verify_url | str | Full verification URL. Redirect the user here. |
SessionResult
Returned by client.verification.get_result(). Frozen dataclass with helper methods -- 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 attributes min_age, country_code, required_methods, remaining_attempts, started_at, liveness_result, age_result, ocr_result, and face_match_result, which no longer exist on this type.
| Attribute / Method | Type | Description |
|---|---|---|
token | str | The result token (xtk_…). id is a deprecated alias. |
status | SessionStatus | Current session status (enum). |
verified | bool | The pass verdict as data, server-derived from status. |
reason | str | Why a non-passing session ended that way; empty on success. |
verification_type | str | None | 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 verification_mode parameter (auto/document/facial) — that steers which methods run; this reports which path it turned out to be. |
external_user_id | str | None | Your user_id from init(). |
checks | Checks | Per-method performed/passed detail -- see below. |
created_at | str | ISO 8601 creation timestamp. |
completed_at | str | None | ISO 8601 completion timestamp. |
expires_at | str | None | ISO 8601 expiry timestamp. |
is_verified() | bool | True if status == SUCCESS. |
is_completed() | bool | Deprecated alias for is_verified(). |
is_failed() | bool | True if status == FAILED. |
is_pending() | bool | True if PENDING or IN_PROGRESS. |
is_terminal() | bool | True if success, failed, or canceled. |
age_bracket() | int | None | checks.age.gate if the age check passed, else None. |
method() | str | None | Alias for verification_type. |
Checks
| Attribute | Type | Description |
|---|---|---|
liveness | LivenessCheck(performed, passed) | Whether a liveness check ran and passed. |
age | AgeCheck(performed, passed, gate) | gate is the threshold checked (e.g. 18), never the subject's actual age. |
document | DocumentCheck(performed, passed, document_type, country) | Set only when a document was checked. |
face_match | FaceMatchCheck(performed, passed) | Whether the selfie matched the document photo. |
Session Statuses
from xident._types import SessionStatus
# SessionStatus is a str enum
SessionStatus.PENDING # 'pending' -- Session created
SessionStatus.IN_PROGRESS # 'in_progress' -- Verification in progress
SessionStatus.SUCCESS # 'success' -- Passed verification
SessionStatus.FAILED # 'failed' -- Failed verification
SessionStatus.CANCELED # 'canceled' -- Canceled
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.
# Check if terminal (no more changes possible)
status = SessionStatus.SUCCESS
status.is_terminal # True (property, not method)
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
- Node.js SDK — Server-side Node.js SDK with TypeScript
- All SDKs — Overview of all Xident SDKs
- API Reference — Full REST API documentation