Python SDK

v2.0.0 Secret Key (sk_live_*) Python 3.10+ Sync + Async httpx-based

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_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.

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)BlacklistPagePage through active entries. Server defaults: page 1, 20 per page (per_page max 100).
add_by_session(*, session_token, reason)strBlacklist the person from one of your terminal document sessions. Async.
add_by_image(*, image, reason)strBlacklist the face in a base64 image. Async.
remove(entry_id)NoneDeactivate an entry (un-ban). Raises ValueError if entry_id is not positive.

Add Parameters

All parameters are keyword-only.

Parameter Type Required Description
session_tokenstrYes (session)Token of one of your terminal document verification sessions (max 100 chars). A session still in progress is rejected with HTTP 409.
imagestrYes (image)Base64-encoded face image (max ~10MB of base64).
reasonstrYesWhy the person is being blacklisted (max 500 chars).

BlacklistPage and BlacklistEntry

Attribute Type Description
page.entrieslist[BlacklistEntry]The entries on this page. BlacklistPage is iterable and supports len().
page.page / page.per_pageintCurrent page number (1-based) and page size.
page.total / page.total_pagesintEntries across all pages, and the page count.
page.has_moreboolProperty. Whether pages beyond this one exist.
entry.idintEntry identifier. Pass it to remove().
entry.reasonstrThe reason you supplied when adding the entry.
entry.sourcestrHow the entry was created: "session" or "image".
entry.session_idint | NoneSession the face was lifted from, or None for image entries.
entry.created_atstrISO 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)Face2FAChallengeStore or replace the user's face enrolment. Async, free of charge.
verify(*, user_id, image)Face2FAChallengeCompare a face against the enrolled one. Async.
get_status(challenge_id)Face2FAStatusPoll a challenge for the pass/fail outcome.
get_user(user_id)Face2FAEnrollmentWhether the user has a face enrolled, and when.
delete_user(user_id)boolGDPR hard delete of the enrolment. Idempotent.

Submit Parameters

Both parameters are keyword-only.

Parameter Type Required Description
user_idstrYesYour own user identifier (max 255 chars). Opaque to Xident — it only has to be stable so a later verify() finds the registered face.
imagestrYesBase64-encoded face image (max ~10MB of base64).

Face2FAStatus

Attribute / Method Type Description
challenge_idstrThe challenge being polled.
kindstr"enroll" or "verify".
statusstrLifecycle state.
passedbool | NoneThe verdict: True on completed, False on failed or expired, None while still processing.
failure_reasonstr | NoneWhy a non-passing challenge did not pass.
expires_atstrISO 8601 timestamp after which the challenge expires.
completed_atstr | NoneISO 8601 timestamp of the terminal state.
is_passed()boolThe check to gate on. False both for a failure and for a challenge still running.
is_processing()boolStill being processed — keep polling.

Challenge Statuses

Status Terminal Meaning
processingNoAccepted, not finished. Keep polling.
completedYesPASSED — the face was enrolled, or it matched.
failedYesDid not pass. Read failure_reason.
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 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
tokenstrThe result token (xtk_…). id is a deprecated alias.
statusSessionStatusCurrent session status (enum).
verifiedboolThe pass verdict as data, server-derived from status.
reasonstrWhy a non-passing session ended that way; empty on success.
verification_typestr | NoneWhich 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_idstr | NoneYour user_id from init().
checksChecksPer-method performed/passed detail -- see below.
created_atstrISO 8601 creation timestamp.
completed_atstr | NoneISO 8601 completion timestamp.
expires_atstr | NoneISO 8601 expiry timestamp.
is_verified()boolTrue if status == SUCCESS.
is_completed()boolDeprecated alias for is_verified().
is_failed()boolTrue if status == FAILED.
is_pending()boolTrue if PENDING or IN_PROGRESS.
is_terminal()boolTrue if success, failed, or canceled.
age_bracket()int | Nonechecks.age.gate if the age check passed, else None.
method()str | NoneAlias for verification_type.

Checks

Attribute Type Description
livenessLivenessCheck(performed, passed)Whether a liveness check ran and passed.
ageAgeCheck(performed, passed, gate)gate is the threshold checked (e.g. 18), never the subject's actual age.
documentDocumentCheck(performed, passed, document_type, country)Set only when a document was checked.
face_matchFaceMatchCheck(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