Core Concepts

Understanding Xident's key concepts will help you build the best verification experience for your users.

Xident ID

A Xident ID is a unique identifier issued to users who complete verification. It enables the "Verify Once, Access Everywhere" model.

How It Works

  1. User visits Site A and completes full verification
  2. User receives a Xident ID (stored in their browser)
  3. User visits Site B (also using Xident)
  4. Site B detects the Xident ID
  5. Instant verification — no camera, no documents
  6. Site B pays the lower Xident ID rate for the returning user

User Control

Users can:

  • View their authorized OAuth apps in the Xident portal
  • Revoke OAuth consent for specific apps
  • Delete their Xident ID entirely

Verification Paths

Xident uses five verification paths, automatically selected based on user context:

User arrives at site
        |
        v
+------------------+       +------------------+
| Has Xident ID?   |--Yes->| Path D: Token    |  (instant, cheapest)
+--------+---------+       +------------------+
         | No
         v
+------------------+       +------------------+
| EU Wallet avail? |--Yes->| Path E: Wallet   |  (mso_mdoc attestation)
+--------+---------+       +------------------+
         | No
         v
+------------------+       +------------------+
| Restricted       |--Yes->| Path C: Document |  (always OCR)
| country?         |       +------------------+
+--------+---------+
         | No
         v
+------------------+       +------------------+
| ML age check     |--Pass>| Path A: ML Fast  |  (client-side, no images sent)
+--------+---------+       +------------------+
         | Fail
         v
+------------------+
| Path B: Document |  (OCR fallback -> Xident account funnel)
+------------------+

Path A: ML Fast

Client-side verification using machine learning. Face images never leave the browser.

  • Liveness Detection: Ensures a real person is present (not a photo/video)
  • Age Threshold Classification: Binary ML classifiers determine if the user is above the required age threshold (+12, +15, +18, +21, +25) via ONNX Runtime in WebAssembly
  • No face data is sent to the server — only a pass/fail result

Path B: Document Fallback

When ML classification fails (approximately 11% false rejection rate for +18), the user falls back to document verification:

  • Document OCR: ID document uploaded and processed server-side
  • Face Match: Document photo matched against liveness frame
  • This is the registration funnel — after the document pain, users are prompted to create a Xident account so they never need to do this again

Path C: Compliance

For restricted countries where ML false positive rates are insufficient for regulatory requirements:

  • Always requires document verification regardless of ML result
  • Ensures compliance with local age verification regulations

Path D: Xident Token

For returning users who already have a Xident ID with a verified age bracket:

  • Instant verification — no camera, no documents
  • Token lookup only, 60-80% cheaper than full verification
  • Same trust level as full verification

Path E: EU Wallet

For users with an EU Digital Identity Wallet that supports age attestation:

  • Uses the W3C Digital Credentials API and OpenID4VP protocol
  • Validates mso_mdoc age attestations against the EU Trusted List
  • Supports cross-device flow via QR code (user scans with wallet app on phone)
  • No face images, no documents — wallet provides a cryptographic age proof
  • See API Reference for the wallet endpoints

Privacy and Compliance

Privacy Architecture

  • ML Fast path: Face images never leave the browser
  • Document path: Document images deleted immediately after OCR
  • Server stores only 512-dimensional face embeddings (not reconstructable to faces)
  • No PII retained — only DOB-derived age bracket
  • Consumers (site owners) receive only pass/fail, never actual age

ARCOM Compliance (Unlinkability)

French Regulatory Compliance

Xident satisfies the French ARCOM unlinkability requirement: age verification does not link users to the platforms they visit. There is no account_connections table — Xident never records which sites a user has verified on. The portal's "Authorized Apps" page shows only OAuth consent grants (for "Login with Xident"), not verification history.

Verification Types

Full Verification

First-time verification with ML processing:

  • Liveness Detection: Ensures a real person is present (not a photo/video)
  • Age Threshold Classification: ML model determines if user is above required age threshold (+12, +15, +18, +21, +25)
  • Document Verification: OCR extraction from ID documents (fallback)

Token Verification

Returning user verification using Xident ID:

  • Instant, no UI required
  • 60-80% cheaper than full verification
  • Same trust level as full verification

EU Wallet Verification

Age attestation via EU Digital Identity Wallet:

  • Cryptographic age proof from government-issued wallet
  • No biometrics required
  • Cross-device flow supported (QR code scan)

Face 2FA

Per-site facial authentication — a second factor keyed by your user id, separate from Xident ID:

  • Register a user's face once: POST /verify/v1/2fa/register with { user_id, image }.
  • Verify a later login: POST /verify/v1/2fa/verify with { user_id, image }.
  • Both are asynchronous — they return a challenge_id; poll GET /verify/v1/2fa/status/{id} for a pass/fail result (never a raw score).
  • The comparison is an exact 1:1 match against the one stored embedding — accuracy doesn't drift as your user base grows.
  • Registration is free; each decided verify bills as one Verification. Delete a user's enrollment (GDPR) with DELETE /verify/v1/2fa/users/{user_id}.
  • Faces are stored as 512-dimensional vectors, never as images.

Blacklist Check

A per-tenant fraud deny list. Once a face is on your blacklist it automatically fails any future verification or Face 2FA check for your account (reason blacklist_match):

  • Add by session: POST /verify/v1/blacklist/session with { session_token, reason } — blacklists the person from one of your completed verifications (within the 24-hour selfie-retention window).
  • Add by image: POST /verify/v1/blacklist/image with { image, reason }.
  • Both are asynchronous; the entry appears in GET /verify/v1/blacklist once processed. Remove with DELETE /verify/v1/blacklist/{id}.
  • You submit a session or an image — never a raw face vector (that would let a crafted vector poison your own matching). The embedding is always derived server-side, and list responses never include embeddings.
  • Your blacklist is private to your account; it never affects other Xident customers.

Sanctions & PEP Screening

On document verifications, the identity extracted from the ID can be screened against the consolidated OpenSanctions dataset — global sanctions programs, politically-exposed persons, and watchlists, 400+ sources refreshed nightly. Screening runs entirely inside Xident's own EU infrastructure; no third-party screening vendor is called and the name never leaves it.

  • The outcome appears in every result as checks.aml — the same {performed, passed} shape as the other checks. performed: false means screening did not run (non-document session, or not enabled for your plan).
  • A match never auto-rejects. A strong match routes the session to your review queue with reason aml_match and the match context (matched name, score, source lists) for your reviewer. Name matching is fuzzy by nature — a person is only ever rejected by a person.
  • Screening is a control inside your compliance program: Xident flags and routes; your team decides; your obligations under applicable AML law remain your own.

Data match

If you already hold identity data about the user — from your signup form, your KYC record, your customer file — send it at init as expected and Xident tells you, per field, whether the document the live person presented agrees with it. The values never come back; only verdicts do.

  • Send any subset of first_name, last_name, date_of_birth (YYYY-MM-DD), document_number and nationality (ISO alpha-2). The session must read a document: use purpose: id_verification or verification_mode: document, or init is refused with EXPECTED_REQUIRES_DOCUMENT.
  • The outcome appears as checks.data_match{performed, passed, fields}, where fields carries one verdict per field you asked about: match, mismatch, or not_on_document (the document does not carry that field, for example nationality on a US driver's licence). passed is true only when every requested field matched.
  • Absent means not performed. Unlike the other checks, data_match is omitted when you sent no expected, when no document was read, or when the reference could not be bound to the session. Gate on passed === true; that fails closed.
  • Each field is compared against every zone of the document Xident reads: the printed text, the machine-readable zone (the issuer's own Latin spelling, with check digits), and the barcode on North American licences. Names tolerate accents, punctuation, a missing middle name and a single misread letter on longer names; dates and document numbers must match exactly after normalising the format.
  • A mismatch never auto-fails a session. With mismatch_policy: report (default) it is recorded and the verification completes as it otherwise would; with review the session goes to your review queue with reason data_mismatch, and your reviewer sees the per-field outcomes. Names are fuzzy by nature — a wrong read must not terminally fail a real person.
  • The claims are stored encrypted for at most 24 hours and never reach the user's browser.

Fraud Risk Band

Document verifications also carry an optional top-level risk object — {"band": "low" | "elevated" | "high"} — an aggregate of the document and capture analysis Xident already performs on the session.

  • The band is a closed set you may switch on; any addition would be announced in the changelog first.
  • When the key is absent, no document-path signals ran (browser-only checks, wallet sessions) — absent is a weaker claim than "low"; don't treat a missing band as an assessed-clean document.
  • There is deliberately no numeric score on the API: a precise score would let a fraudster iterate a forgery against the number. The per-signal detail behind the band is visible to your reviewers in the dashboard.
  • A high band never auto-fails a session — it informs your own review and risk decisions.

Tokens

A verification flow involves two different tokens — don't confuse them:

  • Init token (xit_…): created by POST /verify/v1/init and carried in the verification URL as ?t=. One-time use, 10-minute expiry. It bootstraps the widget (it never appears in your callback).
  • Result token (xtk_…): returned to your callback URL as ?token= after verification. You pass it to GET /verify/v1/result/{token} to read the outcome. Single-use; its expiry is returned as expires_at in the result response.

Result Token Lifecycle

Verification Complete
        |
        v
+-------------------+
| Result token      | --- xtk_… returned to your callback (see expires_at)
+--------+----------+
         |
         v
+-------------------+
| Backend Verifies  | --- Token consumed
+--------+----------+
         |
         v
+-------------------+
| Token Invalidated | --- Cannot be reused
+-------------------+

Token Properties

  • One-time use: tokens are invalidated after they are consumed
  • Short-lived: the init token expires in 10 minutes; the result token's expiry is returned as expires_at
  • Tamper-proof: opaque, randomly generated, server-validated

Result Status Values

This is the canonical status vocabulary for three surfaces that all share it: GET /verify/v1/result/{token} (tenant result), GET /verify/v1/status/{token} (subject/widget status), and every session.*/review.* webhook's data.status. It is a different, narrower set than the callback redirect's ?status= query parameter (see Getting Started, Step 4) — the callback only ever reports a terminal verdict (success, failed, or canceled), while the values below cover the whole session lifecycle, including states no callback ever fires for.

Status Description
pending Session created; the user hasn't started the verification flow yet
in_progress The user is actively going through liveness/age/document capture
success Passed — the required age threshold and every required check succeeded
failed Explicitly failed (threshold not met, document or face mismatch, fraud-list match)
canceled Canceled by the user or the client before completion (note the single-L American spelling)
expired The session's TTL elapsed (~30 minutes) before it reached a terminal state. Nobody's browser is still open to redirect at that point, so this value never appears in a callback — only via a later GET /result/GET /status call or a webhook
pending_tenant_review Non-terminal. The verification ran and produced something a human must adjudicate (see review.created below) — the session is parked awaiting that decision
awaiting_retake A tenant reviewer asked for a retake (blur, glare, partial capture) rather than a final rejection — the widget lets the user recapture in a new, linked session
retaken This session was the original attempt in a retake chain and has been superseded by a newer session — query the new session's token for the current outcome

Account claiming never changes this value. When a user later creates or uses a Xident ID to link an already-passed session to their portal account, that internal bookkeeping step (prevents the same session from being linked twice) never surfaces as its own status — a passed verification keeps reporting success before and after.

API Keys

Xident uses a Stripe-style dual key model. You have two types of API keys:

Secret Key (sk_*) — the one you need

  • Used in backend only (Node.js, Python, PHP, Go)
  • Never expose in client-side code
  • Authenticates the whole flow: POST /verify/v1/init and GET /verify/v1/result/:token
  • Always starts with sk_live_. A key created with restricted scopes starts with ak_live_ instead.

Public Key (pk_*) — optional

  • Safe to expose in client-side code; only needed if you embed the widget from the browser yourself instead of redirecting from your backend
  • Can call POST /verify/v1/init and widget-facing endpoints (requirements, liveness, OCR) — but cannot read verification results
  • Always starts with pk_live_
  • The standard backend integration does not use it

Auth Header

Both key types use the same header:

X-API-Key: pk_live_... or sk_live_...

Environments

Xident runs one environment. There is no separate sandbox stack and no test-key mode — your keys are live keys, and the API you integrate against is the API that serves production. Keys never change at go-live.

What acts as a sandbox is the Free plan: the same API, the same code path, real verifications and real webhooks — spending a one-time credit (1,000 Checks and 100 document Verifications) instead of a card. Upgrading to a paid plan changes billing only; there is nothing to re-integrate.

The practical benefit is that there is no sandbox-versus-production behavioural gap to be caught by later: what you test is byte-for-byte what runs live.

Isolating staging from production

Create two projects. Each has its own key pair, its own webhook endpoint and its own signing secret. Point one at your staging URL and one at production. http://localhost:* callback URLs are accepted, so local development needs no tunnel or HTTPS certificate.

Testing each decision branch

Because verifications are real, there are no fixture identities that return canned verdicts. Every outcome is still reachable deterministically:

  • Pass — a real document and a matching selfie.
  • Fail, under age — send a high min_age (for example 99) on POST /verify/v1/init. Any real person then fails with reason age_below_threshold, with no underage document required.
  • Fail, identity not matched — one person's document with a different person at the selfie step gives face_mismatch.
  • Undecidable — a deliberately blurred or part-cropped document gives document_unreadable or dob_unreadable. These are not billed: charges apply to decisions, not attempts.

Webhooks

Receive real-time notifications for verification events. Configure endpoints in your Dashboard; the shared secret (whsec_…) is shown once, at creation time.

Event catalog

Event Description
session.success User passed — the required threshold and every required check succeeded
session.failed Verification was explicitly failed by the server (threshold not met, document or face mismatch)
session.canceled The client canceled an in-progress verification
session.expired Reserved for a session whose TTL elapses without completion — subscribable today, but not currently emitted by the server
review.created A session landed in the tenant review queue and needs a human decision
review.approved A reviewer approved a queued session
review.rejected A reviewer rejected a queued session
test Sent by the "Test webhook" button in the dashboard

These are the only event types Xident sends. session.completed is a deprecated legacy alias for session.success — endpoints that subscribed to it before the 2026-07 rename keep receiving the pass event, spelled the old way, so nothing breaks. New endpoints should subscribe to session.success directly. If you've seen a dotted event name anywhere that isn't in the table above, it isn't real — this catalog is exhaustive.

Payload

Every delivery is a small envelope wrapping the event-specific data:

{
  "id": "evt_a1b2c3d4e5f6",
  "type": "session.success",
  "api_version": "2026-08-13",
  "created": 1785751350,
  "data": {
    "token": "xtk_golden0001",
    "status": "success",
    "verified": true,
    "verification_type": "full",
    "ip_country": "DE",
    "external_user_id": "cust-4711",
    "checks": {
      "liveness": { "performed": true, "passed": true },
      "age": { "performed": true, "passed": true, "gate": 21 },
      "document": { "performed": true, "passed": true, "document_type": "passport", "country": "DE" },
      "face_match": { "performed": true, "passed": true },
      "eu_wallet": { "performed": false, "passed": false },
      "aml": { "performed": true, "passed": true },
      "data_match": { "performed": true, "passed": true, "fields": { "last_name": "match", "date_of_birth": "match" } }
    },
    "risk": { "band": "low" },
    "created_at": "2026-08-03T10:00:00Z",
    "completed_at": "2026-08-03T10:02:30Z",
    "expires_at": "2026-08-03T10:15:00Z"
  }
}

For every session.* and review.* event, data is byte-identical to the tenant result your secret key gets back from GET /verify/v1/result/{token} — see Getting Started for the full field reference. api_version is the dated API version this payload's shape belongs to (see API versioning); created is a Unix timestamp in seconds. The special test event instead carries { "message": "..." }.

api_version is the dated API version this payload's shape belongs to. It is the version pinned to the webhook endpoint, or to the website if the endpoint has no pin of its own — never the version of whatever request happened to finish the session. A redelivery replays the original bytes, in the version that produced them. See API versioning.

Verifying the signature

Every request carries three headers:

X-Xident-Signature: t=1785751350,v1=5257a869e7bfa7e...
X-Xident-Event: session.success
X-Xident-Delivery: evt_a1b2c3d4e5f6
  • X-Xident-Signaturet={unix timestamp},v1={hex HMAC}
  • X-Xident-Event — the event type, e.g. session.success
  • X-Xident-Delivery — the event's unique id (also the id field in the body). Use it as your idempotency key: retries reuse the same id, and consumers must deduplicate on it.

The signature is an HMAC-SHA256 of the timestamp and the raw request body (not a re-serialization of parsed JSON), keyed by your endpoint's whsec_… secret:

signed_payload = timestamp + "." + raw_request_body
expected = hex(HMAC-SHA256(webhook_secret, signed_payload))
if !constant_time_equal(expected, v1_from_header): reject
if abs(now - timestamp) > 300: reject  // recommended tolerance

Compare digests in constant time. We recommend rejecting anything older than 300 seconds to limit replay of a captured payload.

Retries

A delivery is a success on any 2xx response; anything else (including a timeout) counts as a failure. Xident retries up to 5 times with backoff. An endpoint is automatically deactivated after 5 consecutive failures across any event type; a single successful delivery resets that counter back to zero.

Rate Limits

Endpoint Limit
Token verification 100 req/min
SDK initialization 1000 req/min
Wallet endpoints 60 req/min
Webhook delivery 5 retry attempts; endpoint auto-deactivates after 5 consecutive failures

Next Steps