# ThirdFactor Docs > Developer documentation for Obsidian — ThirdFactor’s multi-tenant identity, KYC, AML and onboarding platform. Native document, liveness, face-match and NFC verification with web, iOS, Android and Flutter SDKs. This document contains the full content of all documentation pages for AI consumption. --- ## Authentication **URL:** https://docs.v3.thirdfactor.ai/docs/authentication **Description:** Tenant API keys, scopes, and the partner-signed JWT session flow. Every request is authenticated with a **tenant API key**. Keys are tenant-scoped: one tenant can never read or mutate another tenant's data. Obsidian exposes two authentication shapes over that one underlying key: - **Raw key, per-request** — the ThirdFactor Verify API (`/v3`) and the partner applications/lists API (`/api/v1`) both take the key directly, as a header. - **Short-lived signed JWT** — the partner hosted-session endpoint (`POST /api/v1/sessions/`) takes a JWT you sign yourself with a tenant-specific signing secret, not the raw key. ## Which auth model do I use? | | `/v3` API key | `/api/v1/sessions/` JWT | | --- | --- | --- | | **What you send** | The raw tenant API key, as `x-api-key` | A short-lived HS256 JWT you construct and sign | | **What signs it** | Nothing — the key itself is the secret | Your tenant's dedicated JWT signing secret (separate from the API key) | | **Surface** | ThirdFactor Verify — sessions, decisions, tools, workflows, usage, credits | Partner hosted-application sessions + risk lists | | **Where creation logic lives** | You call `POST /v3/session/` directly | You embed a `flow_id` + optional `payload` in the signed JWT, then `POST` it | | **Lifetime of the credential** | Until revoked in the console | The JWT itself expires in ≤ 5 minutes; the signing secret is long-lived | | **Best for** | Direct KYC integrations, custom onboarding backends, the SDKs | Partner integrations built around Obsidian's flow/application model | | **Detailed reference** | [API reference → Sessions](/docs/api-reference/sessions) | [API reference → Applications](/docs/api-reference/applications) | ## API keys Create keys in the console under **Settings → API keys**. The raw key value is shown **once**, at creation time — the console only ever displays a `token_prefix` afterward, so store it in your secrets manager immediately. Send the key on the ThirdFactor Verify API (`/v3`) as an `x-api-key` header: ```http POST /v3/session/ x-api-key: Content-Type: application/json ``` The partner API (`/api/v1`, for applications and lists) accepts the same key as a bearer token: ```http Authorization: Bearer ``` ### Worked example: the `/v3` API key model There's no construction step — you send the raw key on every request and the server does the work. On the server side, Obsidian never stores your raw key: it SHA-256 hashes the value you send and looks up the matching `TenantAPIKey.key_hash`. That's why a lost key can't be "recovered" from the console — only revoked and replaced. A missing or invalid key returns `401`; a suspended tenant also fails closed with `401` regardless of key validity — see [Errors](/docs/resources/errors). ## Partner-signed JWT (hosted sessions) The partner endpoint `POST /api/v1/sessions/` takes a short-lived HS256 JWT you sign with your **tenant's JWT signing secret** (console: **Settings → API keys**, shown as "JWT signing secret for ``"), rather than the raw API key in the body. This is used for the onboarding/application flow. ```http POST /api/v1/sessions/ Authorization: Bearer Content-Type: application/json { "jwt": " ### Worked example: constructing the JWT An HS256 JWT is just two base64url-encoded JSON objects and an HMAC-SHA256 signature, joined with `.`. The shell version below builds it the same way `generate_sdk_url.sh` in the repo does — no library required, so you can debug a signature mismatch by hand. Response (`200`): ```json { "ok": true, "session_id": "ge9vgmlmfbi9k5q", "url": "https://your-domain.example/s/", "expires": true, "expires_at": "2026-07-12T12:30:00Z", "credits_remaining": 99 } ``` ## Key rotation Two independent things can rotate, and mixing them up is the most common support question in this area: ## Idempotency Send an `Idempotency-Key` header on session creation. A repeat create with the same key returns the original session and is never charged twice — safe for retries and at-least-once job queues. ```http Idempotency-Key: ``` On the JWT model, `jti` serves the same purpose — see the JWT mistakes above. ## Errors Branch on the HTTP status first, then on the stable string code. | Status | Meaning | | --- | --- | | `400` | Validation error. | | `401` | Missing / invalid API key. | | `402` | `insufficient_credits`. | | `404` | Not found. | | `409` | Duplicate. | | `429` | Rate-limited — back off and retry. | See [Resources → Errors](/docs/resources/errors) for the full table and the per-surface body shapes. ## Frequently asked questions ## Related --- ## ThirdFactor Obsidian **URL:** https://docs.v3.thirdfactor.ai/docs **Description:** Native identity, KYC, AML and onboarding — one hosted flow, client SDKs, server libraries, and a full REST API. **Obsidian** is ThirdFactor's multi-tenant identity platform. It runs the whole verification journey — document capture, passive & active liveness, face match, NFC ePassport reads, AML/PEP screening and onboarding — behind one hosted flow, four native client SDKs, server libraries, and a REST API. No external verification vendor required. ## How it works Every request — session creation, decision reads, tools — goes through the same tenant-scoped API key. There is no separate "verification vendor" credential to provision: the KYC engine, the AML screening, and the hosted UI all live inside your Obsidian deployment. ## Ways to integrate ### Choose your integration path All three paths run the **identical** server-side engine and the **identical** hosted capture UI — `/verify/`. The difference is entirely in delivery: how the URL reaches the applicant, and whether your app gets lifecycle events or a redirect. | | Hosted link | Native SDK | Direct API + webhooks | | --- | --- | --- | --- | | **Setup time** | Minutes — one `POST`, one redirect | Under an hour — add a package, call `verify()` | Same as hosted link, plus your own webhook receiver | | **Client code required** | ✅ None | ⚠️ A few lines (open flow, handle result) | ✅ None on the client — server-to-server only | | **NFC chip reading** | ❌ Falls back to manual review | ✅ iOS / Android / Flutter only | ➖ Depends on which delivery method you pair it with | | **Lifecycle events in your app** | ❌ Only a `callback_url` redirect | ✅ `ready` / `completed` / `error` / `close` over the bridge | ✅ Async via signed webhook | | **Where the result lands** | Query params on `callback_url` (UX signal only) | SDK resolves one `VerificationResult` (UX signal only) | Webhook to your backend (**authoritative**) | | **Best for** | Emailed/SMS links, QR codes, fastest MVP | In-app verification that feels native; mobile passport chip reads | Onboarding backends, core-banking integrations, custom UX around the result | ## What you can verify | Module | What it does | | --- | --- | | **Document verification** | OCR, MRZ, barcode, authenticity & tamper checks across passports and local IDs. | | **Liveness** | Passive, colour-flash, or 3D-action — server-enforced. | | **Face match** | 1:1 selfie ↔ document, plus 1:N face search. | | **NFC** | Reads the ePassport/eID chip on mobile and passively authenticates it. | | **AML / PEP** | Sanctions, PEP and watchlist screening (OpenSanctions + custom lists). | | **Proof of address** | Address document capture + extraction. | These map to workflow feature keys — `ID_VERIFICATION`, `LIVENESS`, `FACE_MATCH`, `NFC_VERIFICATION`, `AML_SCREENING`, `PROOF_OF_ADDRESS`, and a longer tail (`AGE_ESTIMATION`, `DOCUMENT_AI`, `IP_ANALYSIS`, `EMAIL_VERIFICATION`, `PHONE_VERIFICATION`, and more) — configured per workflow in **Console → Config → KYC Workflows**. See the full [module catalog](/docs/kyc/overview). ### Common use cases The right module mix depends on your risk model and regulatory obligations — this is general guidance on what integrators in each space typically enable, not a fixed requirement. | Industry | Typical need | Modules usually enabled | | --- | --- | --- | | **Fintech onboarding** | Fast, low-friction account opening | Document, Liveness, Face match | | **Banking / regulated KYC** | Full identity assurance + sanctions checks | Document, Liveness, Face match, AML/PEP, Proof of address | | **Marketplace seller verification** | Confirm the seller is a real, matched person | Document, Face match, AML/PEP | | **Crypto exchange onboarding** | Strong identity binding + ongoing screening | Document, Liveness, Face match, NFC, AML/PEP | | **Telecom SIM registration** | Government-mandated subscriber identity | Document, Face match, Proof of address | | **Gig-economy driver verification** | Repeatable identity + liveness re-checks | Document, Liveness, Face match | ## For AI tools This site publishes an [`/llms.txt`](/llms.txt) index and a full [`/llms-full.txt`](/llms-full.txt) dump so you can feed the docs straight into an LLM. See [AI context](/docs/resources/ai-context). ## Frequently asked questions ## Related --- ## Quickstart **URL:** https://docs.v3.thirdfactor.ai/docs/quickstart **Description:** Verify your first user end-to-end in under 10 minutes. This walks through the shortest path: create a session on your server, send the user through the hosted flow, and read the decision from a webhook. It uses the ThirdFactor Verify API (`/v3`) — the same surface the SDKs and the hosted-flow console tooling call underneath. ## Prerequisites ## 1. Create a verification session Call `POST /v3/session/` from your backend. Never call this from a browser or mobile app — the API key is secret. Response (`201`): ```json { "id": "6f0e2b7a-3c1a-4e0d-9b2a-7f4c1d2e8a90", "session_kind": "user", "status": "not_started", "url": "https://v3.thirdfactor.ai/verify/", "vendor_data": "user-42", "decision": "in_progress", "expires_at": "2026-07-13T08:00:00Z", "created_at": "2026-07-12T08:00:00Z" } ``` | Field | Meaning | | --- | --- | | `id` | The session UUID — use it for `GET`, `update-status`, `tags`, `logs`, `generate-pdf`. | | `url` | Redirect the applicant here, or hand it to a client SDK. Absolute URL on your tenant's flow domain. | | `status` | Raw session status. Starts `not_started`; branch on `decision`/webhooks for external logic, not this. | | `decision` | `in_progress` until terminal. Becomes `approved` / `declined` / `manual_review` / `expired`. | | `expires_at` | Hosted-URL expiry, derived from `expires_in_hours` (`1..720`, default `48`). | Creating a session debits the tenant's `SESSION_CREATE` credit rate immediately — before the applicant does anything. A `workflow_id` field is also accepted to pick a specific KYC workflow (from `GET /v3/workflows/`); omit it to use the tenant default. ## 2. Send the user through verification The flow itself runs whatever modules your workflow enables — document capture, liveness, face match, AML, and so on — and the server enforces the configured liveness method; it can't be downgraded from the client. See the [module catalog](/docs/kyc/overview) for what each module does. ## 3. Receive the decision Terminal sessions emit a signed webhook: ```json { "event_type": "identity.kyc.session.approved", "session_id": "6f0e2b7a-3c1a-4e0d-9b2a-7f4c1d2e8a90", "vendor_data": "user-42", "decision": "approved" } ``` The four terminal event types are `identity.kyc.session.approved`, `.declined`, `.review`, and `.expired`. Verify the `X-Webhook-Signature` (see [Webhooks → Signatures](/docs/webhooks/signatures)) **before** you parse the body, then act on `decision`. Prefer polling? Call `GET /v3/session//decision/`: ```json { "session_id": "6f0e2b7a-3c1a-4e0d-9b2a-7f4c1d2e8a90", "status": "approved", "decision": "approved", "decision_v3": { "id_verifications": [], "liveness_checks": [], "face_matches": [], "aml_screenings": [], "reviews": [] }, "reason": "" } ``` ### Edge cases worth handling now - **Session never completed (expired).** If the applicant never finishes before `expires_at`, the session moves to a terminal `expired` state and fires `identity.kyc.session.expired`. Design your UI to let the user request a fresh session rather than reusing a dead `url`. - **Partial capture, then abandonment.** A user who uploads a document but closes the tab before liveness/face-match leaves the session non-terminal until it expires — you will not get a webhook until then. Don't treat "no webhook yet" as a decline; check `GET /v3/session//` if you need an earlier read. - **Retried session creation.** With a stable `Idempotency-Key`, a repeat `POST /v3/session/` with the same key returns the **original** session (still `201`) and is never charged twice — safe to call from an at-least-once job queue or a client retry. - **Malformed request body.** An unknown `prefill` key is silently ignored (not an error); a genuinely invalid field (e.g. malformed `date_of_birth`) or an unknown `workflow_id` returns `400` with `{ "detail": "..." }`. An exhausted credit balance returns `402` with `{ "detail": "insufficient_credits" }` — handle it as a hard stop, not a retry. ## Next steps ## Frequently asked questions --- ## Applications **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference/applications **Description:** Partner /api/v1 hosted sessions (JWT), application reads, session-expiry control, stable decisions. The partner API (`/api/v1`) drives the hosted **application** flow: you sign a short-lived JWT with your API key, exchange it for a hosted session URL, and read the resulting application by ID. Applications carry a stable `decision` contract for long-term branching, and — because a flow is a console-designed sequence of steps (form fields, KYC, blocklist checks, review) — they support richer flows than a bare KYC session. ## Stable application decisions Application workflow statuses are intentionally more detailed than the public decision contract. Integrations should prefer `application.decision` where available. | Decision | Meaning | | --- | --- | | `approved` | Application completed successfully. | | `rejected` | Application was rejected by identity/risk/operator logic. | | `manual_review` | Application requires operator review. | | `in_progress` | Applicant has not reached a final decision yet. | | `abandoned` | Application was closed/abandoned. | ## Create a hosted session ```http POST /api/v1/sessions/ Authorization: Bearer Content-Type: application/json ``` The body is a single partner-signed HS256 JWT — not the raw key, and not the applicant fields directly. ```json { "jwt": " ### Idempotent creates `jti` doubles as the idempotency key. A repeat `POST` with the same `jti` re-mints a fresh hosted URL for the **same underlying application** and is not charged again — the response includes `"replay": true` in that case so you can distinguish it from a first-time create. ### Errors | Status | Code | When | | --- | --- | --- | | `400` | `invalid_request` | Missing `jwt` in the body, or `flow_id` blank in the decoded claims. | | `400` | `invalid_request` (`session_ttl_minutes must be 0 or 1..525600`) | `session_ttl_minutes` outside the valid range. | | `401` | `invalid_token` | `Authorization` header missing/malformed, or the Bearer token doesn't match a tenant API key. | | `401` | `invalid_jwt` (`missing iat/exp`, `iat in future`, `expired`, `lifetime > 5min`, `missing jti`) | JWT fails claim validation. | | `402` | `insufficient_credits` | Tenant balance can't cover the `SESSION_CREATE` fee. | | `404` | `flow_not_found` | `flow_id` doesn't resolve to a flow for this tenant. | | `500` | `internal` | The credit debit failed for a reason other than an insufficient balance. | ## Get an application ```http GET /api/v1/applications// ``` Fetch the full record for a single application using your API key. The response is scoped to your tenant — a `404` is returned for IDs that belong to a different tenant or do not exist, indistinguishable from a truly unknown ID. ### Errors | Status | Code | When | | --- | --- | --- | | `401` | invalid API key | Missing/invalid `Authorization`/`X-API-Key`. | | `404` | `not_found` | Unknown ID, or the ID belongs to another tenant. | | `429` | rate-limited | Over the tenant's read bucket. | ## Update session expiry ```http PATCH /api/v1/applications// ``` Response is `{ "ok": true, "application": { ... } }` — the same shape as [Get an application](#get-an-application). ### Errors | Status | Code | When | | --- | --- | --- | | `400` | `invalid_session_expires_at` | `session_expires_at` isn't a parseable timestamp. | | `400` | `no_supported_fields` | Body has neither `expire_now` nor `session_expires_at`. | | `401` | invalid API key | Missing/invalid `Authorization`/`X-API-Key`. | | `404` | `not_found` | Unknown ID, or the ID belongs to another tenant. | ## Webhooks Application events are delivered with the stable signed envelope (`payload_version`, `event`, `application`, `form_data`, `sdk_response`, `blocklist_match`, `created_at`). New integrations should key on `application.decision`. See [Webhooks → Events](/docs/webhooks/events) and [Webhooks → Signatures](/docs/webhooks/signatures). ## Common pitfalls ## FAQ ## Related --- ## API reference **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference **Description:** The full ThirdFactor Obsidian REST API — with an interactive Postman-style console, OpenAPI spec, and Postman collection. --- ## API reference **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference/introduction **Description:** Two REST surfaces, one tenant API key — ThirdFactor Verify (/v3) and partner (/api/v1). Obsidian exposes two REST surfaces behind one tenant API key: the **ThirdFactor Verify API** (`/v3`) that drives the built-in verification engine, and the **partner API** (`/api/v1`) for hosted application sessions and risk lists. Both are tenant-scoped — one tenant can never read or mutate another tenant's data, regardless of which surface, endpoint, or credential is used. Pick the surface by what you're building, not by habit — the two overlap on "start a hosted verification" but answer different integration shapes: ## Base URLs | Surface | Base URL | Use it for | | --- | --- | --- | | ThirdFactor Verify | `https:///v3` — **no** `/api/v1` prefix | Verification sessions, decisions, standalone tools, usage. | | Partner | `https:///api/v1` | Hosted application sessions (JWT), applications, risk lists. | `` is your tenant's flow domain, e.g. `acme.demo-obsidian.thirdfactor.ai`. ## Authentication Both surfaces authenticate with the same **tenant API key**, created in **Console → Settings → API keys**. The `/v3` surface accepts the key as either `X-API-Key` or `x-api-key`. The `/api/v1/sessions/` create call is special: the `Bearer` token is your API key, but the request **body** is a short-lived JWT you sign with that same key — see [Applications → JWT claims](/docs/api-reference/applications#jwt-claims). Every other `/api/v1` endpoint (`applications`, `lists`) takes the raw key directly, as either `Authorization: Bearer` or `X-API-Key`. See [Authentication](/docs/authentication) for the full key + JWT model, including key rotation and scoping. ## The two surfaces ## API design philosophy A few conventions hold across both surfaces. Understanding them up front saves a round trip to support when a retry, a stale page, or an unfamiliar status code shows up in production. ### Idempotency Session creation is the one call integrators retry most (network blips, load balancer timeouts, at-least-once job queues), so both create endpoints are idempotent on a caller-supplied key: | Surface | Idempotency key | Behavior on replay | | --- | --- | --- | | `POST /v3/session/` | `Idempotency-Key` header (any string, ≤128 chars) | Returns the **original** session, `201`, not charged again. | | `POST /api/v1/sessions/` | JWT `jti` claim | Returns the original application's session (a freshly minted hosted URL, since the raw token isn't recoverable), `credits_remaining` unchanged. | Every other write (`PATCH /v3/session//update-status/`, list entry upserts, tag add/remove) is naturally idempotent or safely re-runnable — re-sending the same body produces the same end state rather than a duplicate. ### Pagination `/v3` list endpoints use `page` / `page_size` query parameters uniformly, but the response *shape* depends on when the endpoint was added: | Endpoint | Body | Totals | | --- | --- | --- | | `GET /v3/session/` | bare JSON array | `X-Total-Count` / `X-Page` / `X-Page-Size` **response headers** | | `GET /v3/credits/ledger/` | `{ count, page, page_size, results }` | inline in the body | | `GET /api/v1/lists//entries/` | `{ ok, total, page, page_size, entries }` | inline in the body | Newer endpoints put paging metadata in the body because it's easier to consume from a browser fetch; `GET /v3/session/` predates that convention and keeps a bare array for backward compatibility — read its totals from headers, not the body. Always read `page_size` from the actual response, not just what you requested — every endpoint clamps it to a maximum (`200` for sessions, `500` for the ledger and list entries). ### Versioning stance There is no `v2`/`v3` per-request negotiation — `/v3` is the current and only version of the ThirdFactor Verify surface (the `/v3` name is a legacy artifact, not a live version number you can pin to), and `/api/v1` is the current and only version of the partner surface. Both are additive: new fields appear on existing responses without warning, so integrations should read named keys and ignore unknown ones rather than asserting an exact response shape. Breaking changes (field removal, semantic changes to an existing field) ship as a new endpoint or a new field, with the old one kept working — announced in [Resources → Changelog](/docs/resources/changelog). ### Decisions are advisory until confirmed server-side Any `decision` or `status` your browser or mobile SDK observes — a redirect query param, an SDK completion callback, a client-rendered "Approved!" screen — is **advisory**. The end user's device is not a trusted source of truth. Always confirm the real outcome one of two ways before granting access or provisioning an account: 1. The signed webhook (`identity.kyc.session.*` / `identity.application.*`) — push, arrives once the decision is final. See [Webhooks → Signatures](/docs/webhooks/signatures). 2. A server-side `GET /v3/session//decision/` or `GET /api/v1/applications//` read — pull, safe to call any time. ### Tenant scoping Every queryset on every endpoint is filtered by the tenant resolved from the API key that authenticated the request — never from an ID in the URL or body. Passing another tenant's session or application ID returns `404`, identical to an ID that doesn't exist at all; the API never reveals *that* a resource exists in another tenant, only that it doesn't exist in yours. ## Error format Errors carry the appropriate HTTP status. The JSON body shape currently depends on the surface: | Surface | Success | Error | | --- | --- | --- | | `/v3/*` (KYC) | bare object / array | `{ "detail": "code_or_message" }` | | `/api/v1/sessions/`, `/api/v1/applications/` | `{ "ok": true, … }` | `{ "ok": false, "error": "code", "detail": "…" }` | | `/api/v1/lists/*` | `{ "ok": true, … }` | `{ "error": "message" }` | | Status | Meaning | | --- | --- | | `400` | Validation error — malformed body, unknown enum value, bad date format. | | `401` | Missing / invalid API key, or an invalid/expired JWT on `/api/v1/sessions/`. | | `402` | `insufficient_credits` — the tenant's balance can't cover the debit. | | `403` | `feature_not_enabled` — the tenant isn't entitled to a gated tool/module. | | `404` | Not found — including cross-tenant IDs, indistinguishable from truly missing. | | `409` | Duplicate — e.g. a list name that already exists, or a race on a unique entry value. | | `415` | Unsupported image type on a `/v3/tools/*` upload (only JPEG/PNG are accepted). | | `422` | The input was well-formed but semantically unusable (no face detected, no signature ink found). | | `429` | Rate-limited — back off and retry. | | `500` / `502` / `503` | Internal error, upstream dependency unavailable, or a gated engine not configured for the tenant. | Branch on the HTTP status first, then treat stable string codes (e.g. `insufficient_credits`, `not_found`, `invalid_status`) as the machine signal. A unified `{ "error": { "code", "message" } }` envelope across all surfaces is planned — until then, do not string-match human-readable messages. See [Resources → Errors](/docs/resources/errors) for the full code catalog. ## Rate limits `/v3` endpoints are rate-limited per tenant, with separate buckets for reads, writes, session creation, and inference-heavy tools. Over-limit requests get HTTP `429`; back off and retry with jitter rather than hammering the endpoint. See [Resources → Rate limits](/docs/resources/rate-limits) for the current bucket sizes. ## Related --- ## Risk lists **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference/lists **Description:** Tenant-scoped phone / email / identifier block lists — full CRUD, entry upsert / bulk add / replace-all, normalization rules, and errors. Lists are tenant-scoped risk lists used by flows. A flow can match applicant data against a list and route matches to `manual_review` or `rejected`. All endpoints are on the partner surface (`/api/v1`) and authenticate with `Authorization: Bearer `. ## List types and normalization Every list has one immutable `type`, and every value is normalized on write. Duplicate detection and flow matching always use the **normalized** value, so formatting differences collapse to the same entry. | Type | Normalization | `"+977 9812-345678"` becomes | | --- | --- | --- | | `phone` | Digits only | `9779812345678` | | `email` | Trimmed + lowercased | — | | `identifier` | Trimmed (case preserved) | — | ## Lists ### List lists ```http GET /api/v1/lists/ ``` #### Errors | Status | Body | When | | --- | --- | --- | | `400` | `{ "error": "type must be phone, email, or identifier." }` | Unknown `type` filter. | | `401` | auth error | Missing or invalid bearer token. | ### Create a list ```http POST /api/v1/lists/ ``` #### Errors | Status | Body | When | | --- | --- | --- | | `400` | `{ "error": "name is required." }` | Empty `name`. | | `400` | `{ "error": "type must be phone, email, or identifier." }` | Invalid `type`. | | `409` | `{ "error": "A list named '…' already exists.", "id": "" }` | A list with that name already exists — the existing id is returned. | ### Update list metadata ```http PATCH /api/v1/lists// ``` Only `name`, `description`, and `is_active` are mutable. `type` is **not**. Response echoes the full list object (same shape as create), wrapped in `{ "ok": true, "list": { … } }`. #### Errors | Status | Body | When | | --- | --- | --- | | `400` | `{ "error": "name cannot be blank." }` | `name` present but empty. | | `404` | `{ "error": "Not found." }` | Unknown list, or it belongs to another tenant. | ### Delete a list ```http DELETE /api/v1/lists// ``` Deletes the list and all its entries. ```bash curl -X DELETE "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../" \ -H "Authorization: Bearer $OBSIDIAN_API_KEY" ``` ```json { "ok": true } ``` A `404 { "error": "Not found." }` is returned if the id doesn't resolve for the tenant. ## Entries ### List entries ```http GET /api/v1/lists//entries/ ``` ### Upsert one entry ```http POST /api/v1/lists//entries/ ``` Send a single `value` (+ optional `comment`). The value is normalized, then matched: `201` when created, `200` when it already existed and the comment was updated. A missing/blank value returns `400 { "error": "value is required." }`. ### Bulk add entries Send an `entries` array to the same `POST` endpoint. Idempotent — duplicate values (by normalized form) are skipped, so re-running a batch is safe. ### Replace all entries ```http PUT /api/v1/lists//entries/ ``` Atomically deletes every existing entry and inserts the supplied set — an exact mirror of the payload. If any row is invalid, **nothing is changed** and a `400` is returned. A payload with any invalid row returns `400 { "error": "invalid_entries", "invalid": , "errors": [ … ] }` (up to 50 diagnostics) and leaves the list untouched. ### Update one entry ```http PATCH /api/v1/lists//entries// ``` Response is `{ "ok": true, "entry": { … } }`. #### Errors | Status | Body | When | | --- | --- | --- | | `400` | `{ "error": "value cannot be blank." }` | `value` present but normalizes to empty. | | `404` | `{ "error": "Not found." }` | Unknown entry / list, or another tenant's. | | `409` | `{ "error": "entry value already exists." }` | The new value collides with an existing entry. | ### Delete one entry ```http DELETE /api/v1/lists//entries// ``` ```bash curl -X DELETE "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/4a4b1e25-.../" \ -H "Authorization: Bearer $OBSIDIAN_API_KEY" ``` ```json { "ok": true } ``` ## `POST` vs `PUT` for syncing | | Bulk `POST` | `PUT` (replace all) | | --- | --- | --- | | Existing entries | ✅ Kept | ❌ Deleted first | | Duplicates | ⚠️ Skipped (idempotent) | ⚠️ De-duplicated in payload | | Partial-invalid payload | ⚠️ Valid rows still added, invalid counted | ❌ All-or-nothing (`400`, no change) | | Best for | Incremental adds | Full mirror / nightly sync | ## Blocklist matches in webhooks When a flow matches an applicant against a list, the delivered webhook carries a `blocklist_match` object: ```json { "blocklist_id": "90f2c1a8-4f4e-4d1c-9b95-7dfda01965ba", "blocklist_name": "Sanctions", "block_type": "identifier", "value": "123456789", "action": "manual_review" } ``` The SDK runtime also exposes a minimal, non-revealing existence check — `POST /api/v1/lists/check` returns `{ "ok": true, "matched": , "action": "reject" | "manual_review" | null }` without disclosing which list matched. ## Common pitfalls ## FAQ ## Related --- ## Sessions **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference/sessions **Description:** Full /v3/session/ lifecycle — create, read, decision, status, tags, logs, checklist, PDF. ThirdFactor Verify is driven through `/v3/session/`. A session runs a KYC workflow's enabled modules in the hosted flow and produces a decision. All endpoints on this page are on the `/v3` surface and authenticate with `x-api-key`. ## When to use sessions directly Create a session with `POST /v3/session/` when you want a **standalone KYC check** — no multi-step form, no branching flow, just "verify this person's identity and tell me the outcome." If you already have (or want) a console-designed application flow with form fields and a review queue, use [Applications](/docs/api-reference/applications) instead; a flow's `sdk` step spawns a session under the hood and both surfaces stay readable. | | Sessions (`/v3`) | Applications (`/api/v1`) | | --- | --- | --- | | Best for | Standalone identity verification | Multi-step onboarding flows | | Auth on create | Raw API key | Partner-signed JWT (API key signs it) | | Decision contract | `decision` + `decision_v3` breakdown | Stable `decision` (5 values) | | Custom form fields | ❌ — KYC modules only | ✅ — flow-defined fields | | Risk list matching | ❌ | ✅ — flow blocklist steps | | Per-module results | ✅ — full snapshot | Via the spawned child session | ## Session lifecycle ```text not_started ──▶ in_progress ──▶ in_review ──▶ approved │ │ ├──▶ declined │ ├──▶ abandoned │ ├──▶ expired / kyc_expired │ └──▶ awaiting_user / resubmitted (loops back) ``` `status` is the full lifecycle state machine; `decision` is the compact signal you branch on. Possible `status` values: `not_started`, `in_progress`, `in_review`, `approved`, `declined`, `abandoned`, `expired`, `resubmitted`, `awaiting_user`, `kyc_expired`. ## Create a session ```http POST /v3/session/ x-api-key: Content-Type: application/json ``` Redirect the end user to `url`. The hosted flow runs the workflow's enabled modules and the server enforces the configured liveness method — it **cannot be downgraded client-side**. ### Idempotent creates Send an `Idempotency-Key` header to make retries safe. A repeat create with the same key returns the original session (`201`) and is never charged twice. ```http Idempotency-Key: ``` ### Errors | Status | Code | When | | --- | --- | --- | | `400` | validation error (field-specific message) | `expires_in_hours` outside `1..720`, malformed `contact_email`, etc. | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `402` | `insufficient_credits` | Tenant balance can't cover the `SESSION_CREATE` fee. | | `404` | `workflow_id not found.` | `workflow_id` doesn't exist for this tenant. | | `429` | rate-limited | Over the `kyc_v3_session_create` bucket. | | `500` | `internal` | The credit debit failed for a reason other than an insufficient balance. | ## List sessions ```http GET /v3/session/ # bare array, newest first (alias: GET /v3/sessions) ``` The response body stays a **bare JSON array**. Totals and paging ride in response headers: | Header | Meaning | | --- | --- | | `X-Total-Count` | Total matching sessions. | | `X-Page` | Current page. | | `X-Page-Size` | Page size. | ### Errors | Status | Code | When | | --- | --- | --- | | `400` | `invalid_status` | `status` query param isn't a known lifecycle status. | | `400` | `invalid_session_kind` | `session_kind` query param isn't a known kind. | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `429` | rate-limited | Over the `kyc_v3_read` bucket. | ## Read a session ```http GET /v3/session// # full session + module results ``` Returns the full session object, including a per-module results breakdown. Use this server-side call — never a browser redirect — as the authoritative record of a verification. ### Errors | Status | Code | When | | --- | --- | --- | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `404` | `Session not found.` | Unknown ID, or the ID belongs to another tenant. | | `429` | rate-limited | Over the `kyc_v3_read` bucket. | ## Decision summary ```http GET /v3/session//decision/ ``` A compact decision summary — the cheapest read when you only need the verdict, not the full evidence breakdown. ### Errors | Status | Code | When | | --- | --- | --- | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `404` | `Session not found.` | Unknown ID, or the ID belongs to another tenant. | | `429` | rate-limited | Over the `kyc_v3_read` bucket. | ## Manage a session | Endpoint | Purpose | | --- | --- | | `PATCH` \| `POST /v3/session//update-status/` | Override the decision. | | `DELETE` \| `POST /v3/session//delete/` | Soft-delete the session and its evidence. Returns `204`. | | `GET /v3/session//logs/` | Chronological event timeline. | | `GET /v3/session//checklist/` | Evidence checklist plus `stage`. | | `GET` \| `POST` \| `DELETE /v3/session//tags/` | List / apply / remove a trust-layer tag. | | `GET /v3/session//generate-pdf` | Verification report as `application/pdf` (no trailing slash). | ### Override status ```http PATCH /v3/session//update-status/ POST /v3/session//update-status/ ``` Response is the full updated session object (same shape as [Read a session](#read-a-session)). #### Errors | Status | Code | When | | --- | --- | --- | | `400` | `Invalid status.` | `status` isn't a valid `SessionStatus` value. | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `404` | `Session not found.` | Unknown ID, or the ID belongs to another tenant. | | `429` | rate-limited | Over the `kyc_v3_write` bucket. | ### Delete a session ```http DELETE /v3/session//delete/ POST /v3/session//delete/ ``` Soft-deletes the session and its evidence. Returns `204 No Content` (or `404` if the ID doesn't resolve for this tenant). ```bash curl -X DELETE "https://acme.thirdfactor.ai/v3/session/6f0e2b7a-.../delete/" \ -H "x-api-key: $OBSIDIAN_API_KEY" ``` ### Logs ```http GET /v3/session//logs/ ``` ### Checklist ```http GET /v3/session//checklist/ ``` Returns the evidence checklist plus the current `stage`, useful for rendering progress in your own dashboard without polling the full session object. ```json { "session_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b", "stage": "Case Created", "checklist": [ { "item": "identity_document", "status": "complete" }, { "item": "liveness", "status": "complete" }, { "item": "face_match", "status": "complete" }, { "item": "aml_screening", "status": "pending" } ] } ``` ### Tags ```http GET /v3/session//tags/ # list applied tags POST /v3/session//tags/ # apply a tag DELETE /v3/session//tags/ # remove a tag ``` `POST` and `DELETE` take a tag body: ```json { "tag": "high-risk" } ``` `POST` responds `{ "applied": true, "tag": "high_risk" }` (tags are lower-cased and space-normalized). `DELETE` responds `{ "removed": true }`. `GET` responds `{ "session_id": "...", "tags": ["high_risk"] }`. Tags are part of the trust layer and are visible in the console. #### Errors | Status | Code | When | | --- | --- | --- | | `400` | `tag_required` | `POST` with an empty/missing `tag`. | | `404` | `Session not found.` | Unknown ID, or the ID belongs to another tenant. | ### Generate PDF ```http GET /v3/session//generate-pdf ``` Returns the verification report as an `application/pdf` attachment (binary stream). Note there is **no trailing slash** on this endpoint. ## Webhook events Terminal KYC sessions emit the standard signed webhook envelope with these `event_type` values: - `identity.kyc.session.approved` - `identity.kyc.session.declined` - `identity.kyc.session.review` - `identity.kyc.session.expired` Flow-spawned sessions additionally advance the parent application, so the usual `identity.application.*` events still fire. See [Webhooks → Events](/docs/webhooks/events). ## Common pitfalls ## FAQ ## Related --- ## Standalone tools **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference/tools **Description:** One-shot /v3/tools/* utilities — AML, face search, signature verify/search/enroll, document extract — with full request/response and errors. The `/v3/tools/*` endpoints are one-shot utilities that mirror the console **Manual Tools**, tenant-scoped by the API key. They run a single check without creating a verification session — useful for ad-hoc screening, back-office review, dedup lookups, and enrichment. Images are passed as **base64-encoded strings** in the body (a `data:` URI prefix is accepted and stripped). ## Image inputs Every image argument funnels through one decode chokepoint. Only **JPEG and PNG** are accepted — HEIC (iPhone), WEBP, GIF, BMP, and TIFF are rejected with `415 unsupported_image_type` rather than failing confusingly downstream. Images must be ≤ 8 MB after base64 decoding. | Decode error | Status | Meaning | | --- | --- | --- | | `_required` (e.g. `image_required`, `reference_required`, `probe_required`) | `400` | The base64 field was empty or missing. | | `invalid_` (e.g. `invalid_image`) | `400` | Not decodable base64, or zero-length / over 8 MB. | | `unsupported_image_type` | `415` | Decoded bytes aren't JPEG or PNG. | ## Billing & entitlements The chargeable tools (`aml-check`, `face-search`, `signature-verify`, `signature-search`) carry the same entitlement gate and per-use billing as the console Manual Tools: - If the feature isn't enabled for the tenant → `403 { "detail": "feature_not_enabled", "features": [" A confirmed hit looks like this (abbreviated match object): ```json { "risk_level": "high", "matches": [ { "entity_id": "NK-abc123", "name": "Aarav Sharma", "schema": "Person", "score": 0.97, "match": true, "lists": ["sanctions"], "topics": ["sanction"], "datasets": ["us_ofac_sdn"], "birth_date": "1990-04-12", "countries": ["np"], "aliases": [], "positions": [], "url": "https://…/entity/NK-abc123" } ], "possible_matches": [], "informational_matches": [], "lists_checked": ["pep", "sanctions"], "provider": "watchlist" } ``` ### Errors | Status | Code | When | | --- | --- | --- | | `400` | `name_required` | No `full_name` and no `first_name`/`last_name`. | | `402` | `insufficient_credits` | Balance can't cover the `AML_CHECK` rate. | | `403` | `feature_not_enabled` | `AML_CHECK` not enabled for the tenant. | | `429` | rate-limited | Over the `kyc_v3_tools` bucket. | | `503` | `screening_unavailable` | The screening backend is unreachable. | ## Face search 1:N search over the tenant's enrolled identities — rank identities whose enrolled selfie best matches the probe photo. Read-only (nothing is flagged). ```http POST /v3/tools/face-search/ ``` ### Errors | Status | Code | When | | --- | --- | --- | | `400` / `415` | image decode error | See [Image inputs](#image-inputs). | | `402` | `insufficient_credits` | Balance can't cover the `FACE_SEARCH` rate. | | `403` | `feature_not_enabled` | `FACE_SEARCH` not enabled. | | `422` | `no_face_detected` | No usable face in the probe image. | | `429` | rate-limited | Over the `kyc_v3_tools` bucket. | ## Signature verify 1:1 comparison — compare a probe signature against a reference. Both inputs are first cropped to the signature ink, so a full ID photo or a video frame is matched on its signature, not the surrounding scene. ```http POST /v3/tools/signature-verify/ ``` ### Errors | Status | Code | When | | --- | --- | --- | | `400` / `415` | image decode error | See [Image inputs](#image-inputs). | | `402` | `insufficient_credits` | Balance can't cover the `SIGNATURE_VERIFY` rate. | | `403` | `feature_not_enabled` | `SIGNATURE_VERIFY` not enabled. | | `422` | `not_a_signature` / `no_signature_detected` | A human face was detected, or no signature ink was found. | | `429` | rate-limited | Over the `kyc_v3_tools` bucket. | ## Signature search Reverse (1:N) search over the tenant's enrolled signature specimens — a recall aid for operators, not a verdict. ```http POST /v3/tools/signature-search/ ``` ### Errors | Status | Code | When | | --- | --- | --- | | `400` / `415` | image decode error | See [Image inputs](#image-inputs). | | `402` | `insufficient_credits` | Balance can't cover the `SIGNATURE_SEARCH` rate. | | `403` | `feature_not_enabled` | `SIGNATURE_SEARCH` not enabled. | | `422` | `no_signature_detected` | No signature ink found in the probe. | | `429` | rate-limited | Over the `kyc_v3_tools` bucket. | ## Signature enroll Add a reference signature specimen to an identity's gallery, growing what reverse search can find. Returns `201`. Not gated or charged. ```http POST /v3/tools/signature-enroll/ ``` ### Errors | Status | Code | When | | --- | --- | --- | | `400` / `415` | image decode error | See [Image inputs](#image-inputs). | | `404` | `individual_not_found` | Neither `individual_id` nor `external_user_id` resolves for this tenant. | | `422` | `no_signature_detected` | No signature ink found in the image. | | `429` | rate-limited | Over the `kyc_v3_tools` bucket. | ## Document extract Run OCR / field extraction on a single identity-document image via the platform document-OCR service. Works on any account (independent of the tenant's Lens config). Not gated or charged. ```http POST /v3/tools/document-extract/ ``` ### Errors | Status | Code | When | | --- | --- | --- | | `400` / `415` | image decode error | See [Image inputs](#image-inputs). | | `422` | `ocr_no_fields` | No fields could be extracted (unsupported type or unreadable photo). | | `429` | rate-limited | Over the `kyc_v3_tools` bucket. | | `503` | `ocr_not_configured` | The platform document-OCR service isn't configured. | ## Tools vs. a session | | `/v3/tools/*` | [Session](/docs/api-reference/sessions) | | --- | --- | --- | | Runs a full pipeline | ❌ — one check | ✅ — document + liveness + face + AML | | Produces a decision | ❌ — raw result only | ✅ — `decision` + `decision_v3` | | Audit trail / webhook | ❌ | ✅ — logs, PDF, signed webhook | | Hosted UI for the applicant | ❌ — you supply images | ✅ — hosted flow | | Best for | Back-office / ad-hoc lookups | End-to-end identity verification | ## Common pitfalls ## FAQ ## Related --- ## Usage & credits **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference/usage **Description:** Per-workflow usage aggregates and the paginated credit ledger — reconcile spend and monitor the prepaid balance. Obsidian bills verification work against a prepaid credit balance. These two `/v3` read endpoints expose spend two ways: a **rolled-up view** grouped by workflow, and the **raw ledger** of every individual debit and top-up. Use the aggregate for dashboards and the ledger for line-by-line reconciliation. ## Workflow usage ```http GET /v3/usage/workflows/ x-api-key: ``` Aggregated credit spend and billed-entry counts per workflow. The server sums every metered debit (negative ledger rows tied to a session) and groups by the session's workflow **name**. Sessions not linked to a named workflow roll up under `"-"`. There are no query parameters — the response covers the tenant's full history. ### Errors | Status | Code | When | | --- | --- | --- | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `429` | rate-limited | Over the `kyc_v3_read` bucket. | ## Credit ledger ```http GET /v3/credits/ledger/ x-api-key: ``` The raw, paginated ledger of every credit movement — session debits, per-module debits, tool usage, refunds, top-ups, and manual adjustments — newest first. Each row in `results`: ### Errors | Status | Code | When | | --- | --- | --- | | `400` | `since must be YYYY-MM-DD.` / `until must be YYYY-MM-DD.` | Malformed date filter. | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `429` | rate-limited | Over the `kyc_v3_read` bucket. | ## Reconciliation example Page through the ledger for a month and sum debits by feature to reconcile against your own invoice or internal usage counters. ## Aggregate vs. ledger | | `GET /v3/usage/workflows/` | `GET /v3/credits/ledger/` | | --- | --- | --- | | Granularity | Rolled up per workflow | One row per movement | | Includes top-ups / refunds | ❌ — debits only | ✅ — all movements | | Date filtering | ❌ — full history | ✅ — `since` / `until` | | Pagination | ❌ — small, returned whole | ✅ — `page` / `page_size` | | Best for | Dashboards, spend-by-product | Line-by-line reconciliation, audits | ## Common pitfalls ## FAQ ## Related --- ## Workflows **URL:** https://docs.v3.thirdfactor.ai/docs/api-reference/workflows **Description:** List the tenant's KYC workflows and their enabled features, then route sessions to a specific workflow_id. KYC workflows are designed in **Console → Config → KYC Workflows**. Each workflow is an ordered graph of enabled modules — document capture, liveness, face match, AML screening, and more — that a session runs end to end. A tenant can keep several workflows side by side (a light "age-only" check, a full onboarding, a re-verification variant) and pick which one a given user runs at session-create time. Use `GET /v3/workflows/` to discover the workflow UUIDs you pass as `workflow_id` to [`POST /v3/session/`](/docs/api-reference/sessions). ## List workflows ```http GET /v3/workflows/ x-api-key: ``` Returns a **bare JSON array** of workflow objects for the API key's tenant. There are no query parameters — the list is small (workflows are authored by hand in the console) and returned whole. ## How features map to modules Each entry in `features` is a catalog key that maps one-to-one to a KYC module. The most common keys: | Feature key | Module | Docs | | --- | --- | --- | | `ID_VERIFICATION` | Document capture + OCR + authenticity | [Document](/docs/kyc/document) | | `NFC_VERIFICATION` | Chip read of an e-passport / e-ID | [NFC](/docs/kyc/nfc) | | `PASSIVE` · `PASSIVE_VIDEO` · `FLASHING` · `3D_ACTION` · `GESTURE` | Liveness method (one per workflow) | [Liveness](/docs/kyc/liveness) | | `LIVENESS` | Liveness step (method resolved from settings) | [Liveness](/docs/kyc/liveness) | | `FACE_MATCH` | Selfie ↔ document face comparison | [Face match](/docs/kyc/face-match) | | `AGE_ESTIMATION` | Age from selfie / document | [Overview](/docs/kyc/overview) | | `AML_SCREENING` | Sanctions / PEP / watchlist screening | [AML](/docs/kyc/aml) | | `PROOF_OF_ADDRESS` | Address document capture | [Proof of address](/docs/kyc/proof-of-address) | | `DATABASE_VALIDATION` | Government / authoritative DB cross-check | [Database validation](/docs/kyc/database-validation) | | `EMAIL_VERIFICATION` · `PHONE_VERIFICATION` | OTP contact verification | [Overview](/docs/kyc/overview) | | `IP_ANALYSIS` · `QUESTIONNAIRE` · `VIDEO_CALL` | Risk signal / form / vKYC steps | [Overview](/docs/kyc/overview) | ## Using a workflow_id on session create Pass the `id` you discovered here as `workflow_id`: ### Errors | Status | Code | When | | --- | --- | --- | | `401` | invalid API key | Missing or wrong `x-api-key`. | | `429` | rate-limited | Over the `kyc_v3_read` bucket. | A `workflow_id` that doesn't exist for the tenant is not caught here (this endpoint only lists) — it surfaces at session create as `404 workflow_id not found.` See [Sessions → Errors](/docs/api-reference/sessions). ## Common pitfalls ## FAQ ## Related --- ## Architecture **URL:** https://docs.v3.thirdfactor.ai/docs/concepts/architecture **Description:** How the hosted flow, KYC engine, modules, SDKs and webhooks fit together, and which surfaces you actually call. ThirdFactor Obsidian is one multi-tenant stack that serves three audiences at once: your **applicants** (the hosted verification flow), your **operators** (the console), and **you** (the partner APIs and webhooks). You integrate against a small, stable surface; almost everything else — workflows, branding, risk lists, credit rates — is configured by an operator in the console, not by API calls from your backend. This matters for how you plan an integration. You are not writing code against a KYC SDK that runs verification logic on your infrastructure. You are minting short-lived hosted sessions, redirecting or embedding a URL, and reading back a signed result. The verification logic, the document/liveness/AML models and the decision thresholds all run server-side inside Obsidian — nothing sensitive touches your servers except the outcome. ## The moving parts Each of those four pieces talks to exactly one thing underneath: the KYC engine's shared model layer. A session created through `/v3`, a session spawned by an `/api/v1` application, and a session opened through the Web SDK all run the same workflow engine, the same module scoring and the same credit ledger — the surface only changes how the session is *created* and *observed*, not how it is *scored*. ## Two API surfaces Obsidian exposes two REST surfaces on the tenant's own host. They authenticate with the **same tenant API key** but solve different jobs. | Surface | Base path | Unit of work | Use it for | | --- | --- | --- | --- | | **ThirdFactor Verify** | `/v3` | A [verification session](/docs/concepts/sessions) | Verifying a person: ID, liveness, face match, AML. Fewer moving parts, one webhook. | | **Partner / applications** | `/api/v1` | An [application](/docs/concepts/sessions#applications-api-v1) driven by a *flow* | A whole onboarding journey: forms, product selection, branching, operator review — with a KYC step inside. | An application's identity step can spawn a ThirdFactor Verify (`/v3`) session under the hood, so completing that KYC session automatically advances the parent application. One application may therefore own one or more `/v3` sessions. ### Which surface should you integrate against? Most integrations pick one surface and stay on it. Use this to decide, and see [Sessions](/docs/concepts/sessions) for the full lifecycle of each unit of work. | | ThirdFactor Verify (`/v3`) | Partner applications (`/api/v1`) | | --- | --- | --- | | What you're modelling | "Verify this person" | "Onboard this person" — forms, product choice, identity, decision | | Auth | `x-api-key` header | Short-lived signed JWT (bearer) | | Steps beyond KYC | None — pure verification | Yes — configured as a *flow* in the console | | Result shape | `decision` + `decision_v3` module arrays | `application.decision` + `application.status` | | Best for | Standalone identity checks bolted onto an existing product flow | Replacing a multi-step onboarding journey wholesale | | Typical caller | Backend job, KYC-only feature | Backend that owns the whole signup funnel | ## Request lifecycle ### The request lifecycle end to end ```text Your server Obsidian (tenant host) Applicant device ──────────── ───────────────────── ───────────────── POST /v3/session/ ───────▶ create session, debit credit resolve workflow_id → graph ◀──────────────────────── { id, url, decision: in_progress } redirect / embed url ┌──────────────────┐ │ hosted flow walks │ │ the workflow's │ │ enabled nodes │ └──────────────────┘ engine scores each module session → approved/declined/in_review ◀─────────────────────── signed webhook (terminal state) redirect to callback_url GET /v3/session//decision/ ──▶ ◀──────────────────────── authoritative decision ``` Two reads confirm the same outcome by design — the webhook is push, the decision endpoint is pull. Use the webhook to react in near real time and the decision endpoint to reconcile anything you might have missed (a webhook delivery failure, a restart mid-processing, a backfill job). ## What you talk to, and what you don't You only ever call two things directly, both over HTTPS on the tenant host: - `/v3/*` — the ThirdFactor Verify API. - `/api/v1/*` — the partner / applications API. Plus the hosted URLs you hand your users (`/verify/`, `/s/`) and the webhook deliveries you receive. Branding, workflows, flows, risk lists, SMTP and secrets are all managed by operators in the console — the APIs are deliberately thin over that configuration. | Component | Who touches it | How | | --- | --- | --- | | `/v3` and `/api/v1` APIs | Your backend | Tenant API key / signed JWT, server-side only | | Hosted flow (`/verify`, `/s`) | Your applicant | Browser redirect, iframe embed, or in-app SDK webview | | Console | Your operators | Username/password + optional 2FA, browser only | | Webhooks | Your backend | Inbound HTTPS POST, HMAC-signed | | KYC engine, workflows, risk lists, credit rates | Nobody directly | Configured by operators in the console; you consume the *effects* (which modules run, what a session costs) through the API | ## Multi-tenant isolation Obsidian resolves the tenant from the **host header**, and a tenant API key belongs to exactly one tenant. Authenticating *is* scoping: no partner endpoint takes a tenant identifier, and one tenant can never read or mutate another's sessions, workflows, identities, risk lists or credits. See [Tenancy](/docs/concepts/tenancy) for the full model. ## Edge cases worth designing for - **Webhook delivery fails or your endpoint is briefly down.** Obsidian retries signed deliveries; regardless, treat `GET /v3/session//decision/` as the fallback source of truth and poll it for any session you expect a webhook for but haven't received one on within your own timeout. - **Your server crashes between "create session" and "store the session ID."** Re-issuing the create call with the same `Idempotency-Key` (or the same `jti` on `/api/v1/sessions/`) returns the original session instead of minting — and charging for — a second one. See [Credits](/docs/concepts/credits#idempotent-retries-are-never-double-charged). - **The applicant never finishes.** The hosted URL simply expires at `expires_at`; the session moves to `expired`, a terminal state, and emits a webhook like any other terminal state. Mint a fresh session to let them retry. - **Two of your backend processes read the same session concurrently.** Reads are safe and side-effect-free; only `update-status/` mutates, and it's an explicit operator-grade action, not something concurrent readers trigger. ## Common pitfalls ## FAQ ## Related --- ## Credits **URL:** https://docs.v3.thirdfactor.ai/docs/concepts/credits **Description:** The credit model — the SESSION_CREATE fee, per-module rates, the 402 insufficient_credits response, and the usage and ledger endpoints. Obsidian bills in **credits**. A session debits a fixed session-creation fee plus the rate of each chargeable module it runs. Balances and rates are per-tenant; you watch them through two read endpoints. Think of credits as the one piece of "runtime cost" your integration needs to reason about explicitly. Everything else — which modules run, at what threshold, against which watchlists — is console configuration you consume passively. Credits are different: a `402` is a real failure mode your applicant hits mid-flow, so it's worth understanding the model well enough to alert *before* it happens, not just handle it when it does. ## What gets charged There are two kinds of debit: - **`SESSION_CREATE`** — a platform fee charged once when a hosted session is minted (via `POST /v3/session/` or `POST /api/v1/sessions/`). It is not gateable: hosted-session creation is core to the tenant. - **Per-module rates** — each chargeable module the workflow runs debits its own configured rate, keyed by `node_id` so a repeated module is charged per instance. Every chargeable feature costs **1 credit by default**, unless a different rate is configured globally or per-tenant. There is no separate "free tier" baked into the code — the effective price of each module is whatever rate your tenant has, and the console builder shows it as you assemble a workflow. ### Chargeable features Modules and tools that carry a rate, grouped by category: | Category | Features | | --- | --- | | KYC modules | `ID_VERIFICATION`, `NFC_VERIFICATION`, `LIVENESS`, `FACE_MATCH`, `PROOF_OF_ADDRESS`, `DOCUMENT_AI`, `AML_SCREENING`, `VIDEO_CALL`, `QUESTIONNAIRE`, `EMAIL_VERIFICATION`, `PHONE_VERIFICATION`, `DATABASE_VALIDATION`, `IP_ANALYSIS`, `AGE_ESTIMATION` | | Liveness methods | `PASSIVE`, `PASSIVE_VIDEO`, `FLASHING`, `3D_ACTION`, `GESTURE` — override the base `LIVENESS` rate | | Console / standalone tools | `FACE_SEARCH`, `FACE_VERIFY`, `SIGNATURE_VERIFY`, `SIGNATURE_SEARCH`, `AML_CHECK`, `IMAGE_FORENSICS`, `IP_CHECK` | | Platform | `SESSION_CREATE` | A specific liveness method (`FLASHING`, `3D_ACTION`, …) can carry its own rate that overrides the base `LIVENESS` key, the same way `ID_VERIFICATION_INTERNATIONAL` overrides `ID_VERIFICATION` for documents routed through the hosted international engine. ## Running out of credits If the balance can't cover a debit, the create call fails with **HTTP 402**: ```json { "detail": "insufficient_credits" } ``` The standalone [tools](/docs/kyc/tools) apply the same gate before running: a tool your tenant is not entitled to returns `403 feature_not_enabled`, and one you can't afford returns `402 insufficient_credits`. ### Idempotent retries are never double-charged Send an `Idempotency-Key` header on `POST /v3/session/` (or reuse the `jti` on `POST /api/v1/sessions/`). A repeat create with the same key returns the original session and is not charged again — safe for retries and at-least-once job queues. ```http Idempotency-Key: ``` ## Usage and ledger Two `/v3` reads let you monitor spend. Both are tenant-scoped by the API key. `GET /v3/usage/workflows/` aggregates debits per workflow: ```json { "usage": [ { "workflow": "Enhanced", "credits": 128.0, "entries": 640 }, { "workflow": "FreeKYC", "credits": 40.0, "entries": 200 } ] } ``` `GET /v3/credits/ledger/` returns the raw debit rows, newest first. It accepts `?since=` / `?until=` (`YYYY-MM-DD`, `400` on a bad date) and `?page=` / `?page_size=` (max `500`, default `100`): ```json { "count": 1240, "page": 1, "page_size": 100, "results": [ { "id": "…", "delta": "-1.0000", "reason": "session_create", "feature": "SESSION_CREATE", "session_id": "6f0e2b7a-…", "application_id": "", "created_at": "2026-07-03T08:00:00Z" } ] } ``` ## Usage vs ledger — which one to use | | `GET /v3/usage/workflows/` | `GET /v3/credits/ledger/` | | --- | --- | --- | | Shape | Aggregated totals per workflow | Raw, individually-attributable debit rows | | Best for | A dashboard widget, a monthly spend summary | Reconciling a specific session's or application's exact charge, auditing | | Filterable by date | No | Yes — `?since=` / `?until=` | | Paginated | No | Yes — `?page=` / `?page_size=` | | Cheapest way to answer | "How much did the Enhanced workflow cost us this month?" | "Exactly what was this $6f0e2b7a session billed for?" | ## Edge cases - **A session that never completes still debits `SESSION_CREATE`.** The platform fee is charged on create, not on completion — an abandoned or expired session still shows a `session_create` ledger row even though no module ever ran. - **Concurrent creates racing the same balance.** Debits are atomic per request; two concurrent creates against a balance that can only cover one will not both succeed — the loser gets `402`. Design your retry logic to treat a `402` as "stop," not "retry immediately." - **A `402` on a standalone tool vs on session create.** Both return `402 insufficient_credits`, but a tool call (`/docs/kyc/tools`) never produced a session at all — there's no partial session to clean up. A session, by contrast, only ever gets gated by `402` at *create* time (the `SESSION_CREATE` debit); once a session exists, per-module billing happens at completion and never blocks the applicant — see the next point. - **Credits run out mid-session, after create.** Per-module credits are debited when the session finalizes, one row per module that actually ran. If the tenant's balance can't cover a module's rate at that point, the applicant is never blocked or held for review because of it — the debit for that module is simply skipped (and logged), the session's billing stays incomplete, and a later balance top-up retro-bills the remaining modules. Don't assume "session completed" implies "session fully billed" if a tenant's balance was thin. - **Malformed date filters.** `?since=` / `?until=` on the ledger expect `YYYY-MM-DD`; anything else returns `400`, not an empty result set. ## FAQ ## Related --- ## Decisions **URL:** https://docs.v3.thirdfactor.ai/docs/concepts/decisions **Description:** The stable decision contract you branch on, the raw statuses you should not, and the decision_v3 per-module summary shape. Both API surfaces expose a **stable decision** that is deliberately coarser than the raw workflow status. Branch your integration on the decision, not on the status. Raw statuses exist for diagnostics and can change or gain new values; the decision set is the contract. ## Session decisions (`/v3`) For a ThirdFactor Verify session, `decision` collapses the raw [status](/docs/concepts/sessions#lifecycle-and-statuses) to one of five values: | Decision | Meaning | Underlying statuses | | --- | --- | --- | | `approved` | Verification passed. | `approved` | | `declined` | Verification failed. | `declined` | | `manual_review` | Held for an operator. | `in_review`, `awaiting_user` | | `in_progress` | No final decision yet. | `not_started`, `in_progress`, `resubmitted` | | `abandoned` | Closed / expired without completing. | `abandoned`, `expired`, `kyc_expired` | Read it with the compact decision endpoint: ```json { "session_id": "6f0e2b7a-…", "status": "approved", "decision": "approved", "decision_v3": { "id_verifications": [], "liveness_checks": [], "face_matches": [], "aml_screenings": [], "reviews": [] }, "reason": "" } ``` ### decision_v3 — the per-module breakdown `decision_v3` groups every module result the workflow produced into typed arrays, so you can see *why* a session landed where it did without parsing raw module output. Each entry in an array carries the module's own `node_id` (matters once a feature repeats — see [Workflows](/docs/concepts/workflows#workflows-are-node-graphs)), its `status`, a `score` where applicable, any `warnings`, and a module-specific `data` object: ```json { "decision_v3": { "id_verifications": [ { "node_id": "ID_VERIFICATION", "status": "approved", "score": 0.97, "warnings": [], "data": { "document_type": "passport", "country": "NPL" } } ], "liveness_checks": [ { "node_id": "LIVENESS", "status": "approved", "score": 0.99, "warnings": [], "data": { "method": "PASSIVE" } } ], "face_matches": [ { "node_id": "FACE_MATCH", "status": "approved", "score": 0.93, "warnings": [], "data": {} } ], "aml_screenings": [ { "node_id": "AML_SCREENING", "status": "approved", "score": 0.0, "warnings": [], "data": { "hits": 0 } } ], "reviews": [] } } ``` | Key | Populated by | | --- | --- | | `id_verifications` | Document verification (OCR / MRZ / authenticity). | | `liveness_checks` | Passive, colour-flash or 3D-action liveness. | | `face_matches` | Selfie ↔ document face match. | | `aml_screenings` | AML / PEP / sanctions screening. | | `reviews` | Operator review actions. | Additional keys such as `nfc_verifications`, `poa_verifications`, `phone_verifications`, `email_verifications`, `ip_analyses`, `database_validations` and `questionnaire_responses` appear when those modules run. Empty arrays mean the category was not part of this workflow — not that the module failed. ## Application decisions (`/api/v1`) For an [application](/docs/concepts/sessions#applications-api-v1), workflow statuses are more granular than the public contract. Prefer `application.decision`. | Decision | Meaning | | --- | --- | | `approved` | Application completed successfully. | | `rejected` | Rejected by identity / risk / operator logic. | | `manual_review` | Requires an operator. | | `in_progress` | No final decision yet. | | `abandoned` | Closed / abandoned. | Raw statuses such as `form_filled`, `sdk_redirected`, `completed` and `kyc_failed` may still appear for workflow diagnostics. Do not build long-term external branching on raw statuses unless it is agreed in your integration contract. ## Comparing the two decision contracts | | `/v3` session `decision` | `/api/v1` application `decision` | | --- | --- | --- | | Pass | `approved` | `approved` | | Fail | `declined` | `rejected` | | Held for a human | `manual_review` | `manual_review` | | Still running | `in_progress` | `in_progress` | | Closed without finishing | `abandoned` | `abandoned` | | Module-level detail | `decision_v3` arrays | Not exposed directly — read the child session's `decision_v3` | ## Why a session lands in review A session becomes `manual_review` / `in_review` rather than a clean pass or fail when: - A module scored inside its review band rather than clearly passing or failing (e.g. a face match just under threshold). - AML screening returned a hit, or **could not run** — screening fails closed. - A module with no real web implementation (currently NFC verification and database validation) is enabled by a legacy or imported workflow. Rather than fake a pass, the engine routes it to review so a human decides. - The applicant used "Continue anyway" after a failed check, where the module allows bypass. - An operator manually held it. An operator resolves it in **Console → Verifications**. Approving or declining there emits the terminal webhook and, for flow-spawned sessions, resumes the parent application. ## Edge cases - **A session with zero populated arrays.** A session that reached a terminal state without running any module (e.g. abandoned at the welcome screen) has every `decision_v3` array empty. That's a legitimate response, not a bug — check `decision` and `status` first, then use `decision_v3` to explain *why*. - **AML "could not run."** Because AML screening fails closed, a transient upstream outage on the screening provider routes the session to `manual_review` rather than silently skipping the check or passing it — don't treat a missing `aml_screenings` result as a pass. - **Reason string is empty on a clean approval.** `reason` is populated for reviews and declines; on a straightforward `approved` decision it is typically `""`. Don't treat an empty `reason` as an error. - **Retrying a read after an operator overrides the status.** `update-status/` changes the authoritative decision immediately; a decision read issued right after should reflect the override, but always re-read rather than caching a decision you fetched before the override. ## The golden rule ## FAQ ## Related --- ## Sessions and applications **URL:** https://docs.v3.thirdfactor.ai/docs/concepts/sessions **Description:** Verification sessions and applications — their lifecycle, statuses, session_kind, vendor_data, expiry, and the endpoints to list, read and manage them. Obsidian has two units of work. Knowing which one you hold saves a lot of confusion. - A **verification session** (`/v3`) is one pass of a KYC workflow by one person: capture an ID, prove liveness, match the face, screen against watchlists. - An **application** (`/api/v1`) is a whole onboarding journey — a form, a product choice, an identity step, a decision — that can spawn a verification session under the hood. ## Verification sessions (`/v3`) A session has a hosted URL, a status, per-module results, an evidence checklist, an event timeline and a PDF report. You create it server-side and redirect the applicant to its `url`. ```json { "id": "6f0e2b7a-…", "session_kind": "user", "status": "not_started", "url": "https://acme.example.com/verify/", "vendor_data": "your-user-reference-123", "decision": "in_progress", "expires_at": "2026-07-04T08:00:00Z", "created_at": "2026-07-03T08:00:00Z" } ``` ### Lifecycle and statuses A session moves from creation, through the applicant's flow, to a terminal state. The raw `status` is more granular than the stable [decision](/docs/concepts/decisions). | Status | Terminal? | Meaning | | --- | --- | --- | | `not_started` | no | Created; the applicant hasn't opened the URL. | | `in_progress` | no | The applicant is mid-flow. | | `awaiting_user` | no | Waiting on the applicant (e.g. a resubmission or a scheduled video call). | | `in_review` | no | Held for an operator decision. | | `resubmitted` | no | The applicant was asked to retry and did. | | `approved` | **yes** | Verification passed. | | `declined` | **yes** | Verification failed. | | `abandoned` | **yes** | Closed without completing. | | `expired` | **yes** | The hosted URL lapsed before completion. | | `kyc_expired` | **yes** | A previously verified identity aged out. | ```text not_started ─▶ in_progress ─┬─▶ approved ├─▶ declined ├─▶ in_review ─▶ approved / declined └─▶ abandoned not_started ─▶ expired (URL lapsed before completion) ``` ### session_kind `session_kind` is currently always `"user"` — a standalone verification of one person. The `GET /v3/session/` list endpoint accepts `?session_kind=user`; any other value returns `400`. Sessions that are spawned by an `/api/v1` application flow are linked to their parent application internally and advance it on completion. ### vendor_data `vendor_data` is your own external user reference. It rides through to the webhook and, importantly, becomes the linked identity's `external_user_id` — the key you use to correlate an Obsidian identity with your own user record. Set it on create; you can't rely on any other field to join back to your system. ### Expiry The hosted URL has a lifetime set by `expires_in_hours` on create (`1..720`, default `48`). After it lapses without completion the session goes to `expired`. Mint a fresh session to let the applicant retry — expiry is on the URL, not a resumable state. ## Reading and managing sessions ```javascript Node — list in-review sessions const res = await fetch( `${BASE}/v3/session/?status=in_review&page=1&page_size=50`, { headers: { "x-api-key": process.env.TF_API_KEY } } ); const sessions = await res.json(); // bare array const total = res.headers.get("X-Total-Count"); ``` ```python Python — list in-review sessions resp = requests.get( f"{BASE}/v3/session/", headers={"x-api-key": os.environ["TF_API_KEY"]}, params={"status": "in_review", "page": 1, "page_size": 50}, ) sessions = resp.json() # bare array total = resp.headers["X-Total-Count"] ``` `GET /v3/session/` accepts `?status=` and `?session_kind=` filters (an unknown value returns `400`) plus `?page=` / `?page_size=` (max `200`, default `50`). The body stays a bare JSON array; the total and page ride in the `X-Total-Count`, `X-Page` and `X-Page-Size` response headers. The full read (`GET /v3/session//`) returns the session plus its module results: ### Session management endpoints | Endpoint | Purpose | | --- | --- | | `PATCH \| POST /v3/session//update-status/` | Override the decision. Body `{ "status": "approved", "reason": "…" }`; `status` must be a valid session status (else `400`). | | `DELETE \| POST /v3/session//delete/` | Soft-delete the session and its evidence. Returns `204`. | | `GET /v3/session//logs/` | Chronological event timeline. | | `GET /v3/session//checklist/` | Evidence checklist plus `stage`. | | `GET \| POST \| DELETE /v3/session//tags/` | List / apply / remove a trust-layer tag. Body `{ "tag": "…" }`. | | `GET /v3/session//generate-pdf` | Verification report as a binary `application/pdf` attachment (no trailing slash). | ## Applications (`/api/v1`) An **application** is created by a hosted session (`POST /api/v1/sessions/`) with a partner-signed JWT that names a `flow_id`. It advances through the steps of that flow and carries per-step outputs. Read it with: ```http GET /api/v1/applications// ``` ```json { "ok": true, "application": { "id": "ge9vgmlmfbi9k5q", "external_ref": "core-banking-id-123", "tracking_id": "APP-2026-0001", "status": "manual_review", "decision": "manual_review", "session_expires_at": "2026-05-18T12:00:00Z", "session_expiry_state": "active", "phone": "9841000000", "email": "customer@example.com", "created_at": "2026-05-18T08:00:00Z", "updated_at": "2026-05-18T08:10:00Z" } } ``` `session_expiry_state` is one of `active`, `expired` or `never`. You can expire a live hosted URL immediately or reset its expiry with `PATCH`: Hosted application URLs default to the tenant's configured expiry (**Console → Settings → API keys → Hosted session expiry**, starting at 30 minutes). Existing URLs keep their stored expiry until you change it. See the [hosted flow](/docs/hosted-flow/create-session) docs for the full create call. ## Sessions vs applications at a glance | | Verification session (`/v3`) | Application (`/api/v1`) | | --- | --- | --- | | Created by | `POST /v3/session/` | `POST /api/v1/sessions/` (signed JWT) | | Represents | One KYC pass | A whole onboarding journey | | Can contain | Modules only | Forms, product selection, one or more KYC sessions, operator review | | Correlator | `vendor_data` → identity `external_user_id` | `external_ref` | | Stable outcome field | `decision` | `application.decision` | | Failed-outcome value | `declined` | `rejected` | | Read endpoint | `GET /v3/session//` | `GET /api/v1/applications//` | ## Identities Verified people are deduplicated into **identities** (Console → Identities). `vendor_data` becomes the identity's `external_user_id`. Identities are what a 1:N [face search](/docs/kyc/tools) searches and what the rolling AML re-screen sweep re-checks. ## Edge cases - **Partial data on abandonment.** An `abandoned` session may hold partial module results (e.g. a document was captured but liveness never ran). Check `decision_v3` per category rather than assuming an all-or-nothing result — an abandoned session is still `abandoned` as a whole, but the evidence checklist tells you exactly how far the applicant got. - **Retrying a create with the same idempotency key.** Returns the original session unchanged and is not charged again — see [Credits](/docs/concepts/credits#idempotent-retries-are-never-double-charged). This is the safe way to handle "did my POST actually go through" after a timeout. - **Reading a session immediately after creation.** `status` is `not_started` and `decision` is `in_progress` until the applicant opens the URL — there is no result to read yet, this is expected, not an error state. - **Malformed or unknown filter values.** `?status=` and `?session_kind=` with an unrecognized value return `400`, not an empty list — check for that before assuming zero matching sessions. ## FAQ ## Related --- ## Tenancy **URL:** https://docs.v3.thirdfactor.ai/docs/concepts/tenancy **Description:** Multi-tenant isolation — host-based tenant resolution, tenant-scoped API keys, per-tenant data and configuration. Obsidian is multi-tenant by construction. A tenant is resolved from the **host header**, and every API key belongs to exactly one tenant. You never send a tenant identifier: authenticating *is* scoping. This is a deliberate design choice, not an implementation detail you can route around. There is no `tenant_id` field anywhere on the partner API surface — not on session create, not as a query parameter, not in a JWT claim you set yourself. The host you call and the key you send are the only two things that determine whose data you're touching, which means tenant isolation can't be defeated by a client-side bug that forgets to filter. ## Host-based resolution Each tenant is served on its own hosts. | Host | Serves | | --- | --- | | `acme.example.com` | The customer-facing flow for tenant *acme* — onboarding flows, hosted KYC (`/verify/`, `/s/`), the `/v3` and `/api/v1` APIs. | | `acme-console.example.com` | The operator console for *acme*. | The request host determines the tenant, so the hosted URL you hand an applicant is always on the tenant's own domain, and the API base you call is on that host too. ```text Host header Resolves to ────────────── ─────────── acme.example.com ──────────────▶ tenant "acme" ├─ /v3/* (ThirdFactor Verify API) ├─ /api/v1/* (partner API) ├─ /verify/ (hosted ThirdFactor Verify) └─ /s/ (hosted application flow) acme-console.example.com ──────────────▶ tenant "acme" └─ operator console (Config, Verifications, Identities…) globex.example.com ──────────────▶ tenant "globex" (fully separate data, keys, config) ``` ## API keys are the tenant A tenant API key belongs to exactly one tenant. That is why no partner endpoint takes a tenant identifier — the key resolves it. Keys are created in **Console → Settings → API keys** and sent as: - `x-api-key: ` on the ThirdFactor Verify API (`/v3`). - `Authorization: Bearer ` on the partner API (`/api/v1`), or wrapped in a short-lived signed JWT for `POST /api/v1/sessions/`. See [Authentication](/docs/authentication) for the key and JWT details. ## Data isolation Resources are never shared across tenants. Sessions, applications, workflows, identities, enrolled faces and signatures, risk lists, webhooks, credits and config are all per-tenant. | Resource | Isolation | | --- | --- | | Sessions & applications | Never visible or mutable across tenants; a session created under one key cannot be read with another. | | Workflows | Each tenant configures and owns its own set; `workflow_id` values are meaningless outside the tenant that created them. | | Identities & enrolled faces | 1:N face search and AML re-screening only ever run against your tenant's own enrolled population. | | Risk lists | Per-tenant watchlists and allow/deny entries — one tenant's list never affects another's screening. | | Webhooks | Configured (URL + secret) per tenant; deliveries are scoped to that tenant's own session/application events. | | Credits | Balance and per-feature rates are per-tenant; spend on one tenant never draws down another's balance. | | Branding & flow copy | Logo, colours, languages and hosted-flow content are per-tenant — applicants of different tenants never see each other's branding. | Consequences worth designing around: - A `404` on another tenant's session is indistinguishable from a `404` on a session that doesn't exist. That is intentional — one tenant cannot probe another's data. - A 1:N [face search](/docs/kyc/tools) or the rolling AML re-screen only ever covers *your* enrolled population. - If you run several brands or legal entities on one deployment, they are separate tenants with separate keys, hosts and data. ## Per-tenant configuration Almost everything you might expect to configure by API is instead configured by an operator in the console, per tenant: The APIs are deliberately thin over this configuration. Your integration mints sessions and reads results; operators own the policy. ## Operating multiple tenants If your business runs more than one brand, region, or legal entity through Obsidian, each is a genuinely separate tenant — not a flag or a scoped sub-resource of one tenant. That means: - Each needs its own host, its own API key(s), and its own console login. - Workflows, branding, risk lists and credit balances are configured independently for each — nothing is inherited from a "parent" tenant. - Your backend integration code can be shared (the API shape is identical), but your configuration — which `BASE` you call, which key you send — must be looked up per tenant/brand rather than hard-coded once. ## Edge cases - **Reusing a key across environments.** A key issued for one tenant's host will simply fail authentication (not silently work) if sent to a different tenant's host — there's no implicit fallback or shared default tenant. - **A session ID that looks valid but returns 404.** This is the expected shape of "wrong tenant," not a corrupted ID — re-check which key/host you used to create the session in the first place. - **Webhook secrets are per-tenant.** If you operate multiple tenants, each has its own signing secret; verifying a payload from tenant B with tenant A's secret always fails signature verification — see [Webhooks](/docs/webhooks/signatures). ## FAQ ## Related --- ## Workflows **URL:** https://docs.v3.thirdfactor.ai/docs/concepts/workflows **Description:** KYC workflows as node graphs of feature modules, how enabled modules are server-enforced, and the GET /v3/workflows/ endpoint. A **KYC workflow** is a named, versioned set of verification modules with their configuration. It is built by an operator in **Console → Config → KYC Workflows**, referenced by `workflow_id` from the API, and run by the hosted flow when you create a [session](/docs/concepts/sessions). Every workflow starts from one of two mental models: a straight-line checklist of steps, or a graph of repeatable feature nodes wired together. Both compile down to the same runtime representation, so nothing about your integration changes based on which one an operator used to build the workflow — but understanding the graph model explains a few things you'll see in `decision_v3` and the module catalog that a flat step list can't. ## Workflows are node graphs Under the hood a workflow is a directed graph of **feature-instance nodes**. Each node is one module (document verification, liveness, face match, AML screening, …) with its own config, wired to the next node by typed edges. Because nodes are instances, the same feature can appear more than once in a single workflow — each instance carries its own `node_id`, and results, billing and decisions are keyed by `node_id`. For a simple linear workflow, `node_id == feature`, so the shape is unchanged from a flat list of modules — the graph model is backward-compatible. Nothing about a workflow authored as a flat step list changes behaviour; it's simply the graph's simplest possible shape. ```text ID_VERIFICATION ─▶ LIVENESS ─▶ FACE_MATCH ─▶ AML_SCREENING ``` ### Linear vs graph workflows The console builder offers both. Most workflows never need more than the linear model — reach for the graph editor specifically when you need a module to repeat, or when a step's input needs to come from a specific earlier node rather than "whatever ran most recently." | | Linear workflow | Graph workflow | | --- | --- | --- | | Setup speed | Fastest — add steps in order, done | Slower — place nodes, wire edges, configure ports | | Branching / conditional steps | No — one fixed sequence | Yes — a DAG can route around nodes | | Repeatable modules (two ID checks, two questionnaires) | No — one instance per feature | Yes — each instance gets its own `node_id` | | Face-match wiring | Auto-wired from the first document + first selfie | Explicit — you wire a specific document node's face output into a specific face-match node's `face-a` port | | Result keying | `node_id == feature` (results, billing, decisions all read the same as a flat list) | `node_id` per instance — `decision_v3` arrays hold one entry per node | | Best for | Most KYC workflows: one ID, one liveness, one face match, optional AML | Multi-document onboarding, split consumer/business identity checks, re-verification steps inside one workflow | | Console structure field | `SIMPLE` | `ADVANCED` | ## Node types The catalog below is what an operator can drop onto the graph canvas (or add as a step in the linear builder). A subset — document, liveness, face match, NFC, AML, proof of address, contact OTP, questionnaire, video call and confirm details — cover the vast majority of real workflows: | Node type | Feature key(s) | What it does | | --- | --- | --- | | Document | `ID_VERIFICATION` | Authenticate government IDs across 220+ countries and 14,000+ document types. | | Liveness | `LIVENESS` | Confirm a real, present human — defeating photos, masks and deepfakes. | | Face match | `FACE_MATCH` | 1:1 biometric comparison of the document portrait against the selfie. | | NFC | `NFC_VERIFICATION` | Read the chip in e-passports and biometric IDs for tamper-proof data. | | AML | `AML_SCREENING` | Screen against global sanctions, PEP and criminal watchlists (auto-updated). | | Proof of address | `PROOF_OF_ADDRESS` | Validate a utility bill or bank statement and extract the address. | | Contact OTP | `EMAIL_VERIFICATION`, `PHONE_VERIFICATION` | Confirm ownership of an email address or phone number with a one-time code (SMS or voice). | | Questionnaire | `QUESTIONNAIRE` | Collect structured answers with a form built in the console's questionnaire designer. | | Video call | `VIDEO_CALL` | Live agent-attended video verification with server-side recording — the applicant talks to a trained agent who confirms identity on camera. | | Confirm details | `CONFIRM_DETAILS` | Show the applicant the identity fields read from their ID and let them correct any OCR mistakes before continuing. | ### Repeatable nodes Only some features can appear more than once in the same workflow: `ID_VERIFICATION`, `DOCUMENT_AI`, `PROOF_OF_ADDRESS` and `QUESTIONNAIRE` are repeatable — useful for "front of ID + a second supporting document," or two different questionnaires gating different steps. `LIVENESS`, `AML_SCREENING`, `IP_ANALYSIS` and `AGE_ESTIMATION` each run **once per session** regardless of how many nodes reference them — the engine mirrors the single run's verdict onto any sibling nodes so the session never gets stranded waiting on a second run that was never going to happen. ### Typed face edges Two node types **produce** a face: `ID_VERIFICATION`, `NFC_VERIFICATION`, `LIVENESS` and `AGE_ESTIMATION`. `FACE_MATCH` **consumes** two faces, on two distinct typed input ports — `face-a` (the document-side face) and `face-b` (the selfie-side face). In the graph builder you wire a specific document node's face output into `face-a` and a specific liveness node's face output into `face-b`; in a linear workflow this wiring is synthesized automatically from the first document node and the first liveness node. A face-match node wired to a document node that was never uploaded withholds rather than matching against an unrelated sibling's document — it never silently cross-matches the wrong pair of nodes. ## How a graph walk resolves node_id-keyed results Every module result, billing debit and decision entry is keyed by the node's own `node_id`, not by its feature name. This is what lets `ID_VERIFICATION` appear twice in one workflow without one instance's result clobbering the other's: ```text Workflow graph (authored once, in the console builder) ──────────────────────────────────────────────────────────────── [doc_front]──face──┐ ID_VERIFICATION │ ├──▶ face-a ──┐ [doc_back] │ │ ID_VERIFICATION │ [fm_1] │ │ FACE_MATCH ──▶ [aml_1] AML_SCREENING [selfie]──face───────┘ face-b │ LIVENESS │ ▼ decision_v3 Hosted flow walk (one session) ──────────────────────────────────────────────────────────────── applicant screen 1 → capture doc_front → ModuleResult(session, node_id="doc_front") applicant screen 2 → capture doc_back → ModuleResult(session, node_id="doc_back") applicant screen 3 → selfie (liveness) → ModuleResult(session, node_id="selfie") engine resolves fm_1's wired inputs: face-a ← doc_front's face output face-b ← selfie's face output engine runs fm_1 → ModuleResult(session, node_id="fm_1") engine runs aml_1 → ModuleResult(session, node_id="aml_1") decision_v3 (grouped by category, each entry tagged with its node_id) ──────────────────────────────────────────────────────────────── id_verifications: [ {node_id: doc_front, …}, {node_id: doc_back, …} ] liveness_checks: [ {node_id: selfie, …} ] face_matches: [ {node_id: fm_1, …} ] aml_screenings: [ {node_id: aml_1, …} ] ``` The hosted flow walks the graph in topological order, rendering one screen per node — a repeated feature (two document captures) gets its own screen each time, and an `INFORMATION` node renders wherever it's placed in the order. Anything downstream that reads results — `decision_v3`, the evidence checklist, per-node credit billing, incomplete-module detection — keys off `node_id`, so two instances of the same feature never overwrite each other's outcome. See [Decisions](/docs/concepts/decisions#decision_v3--the-per-module-breakdown) for the shape each array entry takes. ## Templates The console builder offers pre-built starting points so operators aren't wiring a workflow from a blank canvas: | Template | Features | | --- | --- | | KYC Verification | `ID_VERIFICATION`, `LIVENESS`, `FACE_MATCH`, `IP_ANALYSIS` | | KYC + AML | `ID_VERIFICATION`, `LIVENESS`, `FACE_MATCH`, `AML_SCREENING`, `IP_ANALYSIS` | | Age Verification | `ID_VERIFICATION`, `LIVENESS`, `AGE_ESTIMATION` | | Proof of Address | `ID_VERIFICATION`, `PROOF_OF_ADDRESS` | | Full Onboarding | `ID_VERIFICATION`, `LIVENESS`, `FACE_MATCH`, `PROOF_OF_ADDRESS`, `AML_SCREENING`, `IP_ANALYSIS` | Every template is a linear starting point — an operator can still convert it to a graph later if they need repeatable nodes or custom face-match wiring. ## Listing workflows ```bash curl -H "x-api-key: $TF_API_KEY" "$BASE/v3/workflows/" ``` The response is a bare array — one object per workflow the API key's tenant owns: ```json [ { "id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f", "name": "FreeKYC", "status": "live", "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH"] }, { "id": "b1d0f8a2-…", "name": "Enhanced", "status": "live", "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "AML_SCREENING", "IP_ANALYSIS"] } ] ``` ## Which workflow runs? When you omit `workflow_id` on session create, resolution falls back, in order, to the tenant's configured default workflow, then the workflow flagged as default, then the most recent live one. A brand-new tenant with no workflow gets a default (ID + liveness + face match) provisioned automatically, so a session always resolves to a real workflow. ## Enabled modules are server-enforced The set of modules that run is decided **server-side**, from the workflow's enabled features — it is not something the client can add to, skip, or downgrade: - The hosted flow runs exactly the workflow's enabled modules. A required evidence-driven module (document, liveness, face match, contact, questionnaire) that never produces a terminal result leaves the session **incomplete** — it will not silently approve. A skipped selfie cannot pass as a verified identity. - The configured **liveness method** (passive / colour-flash / 3D-action) is enforced by the server and cannot be swapped for a weaker one from the client. - Modules that have no real web implementation — currently **NFC verification** and **database validation** — are hidden from the builder. If a legacy or imported workflow still enables one, the engine routes it to **manual review** rather than fabricating a pass. - The standalone [tools](/docs/kyc/tools) and priced modules are gated by tenant entitlement: a feature your tenant is not entitled to returns `403 feature_not_enabled`. ## Module catalog Common feature keys you will see in `features` and `decision_v3`: | Feature key | Module | | --- | --- | | `ID_VERIFICATION` | [Document verification](/docs/kyc/document) | | `LIVENESS` | [Liveness](/docs/kyc/liveness) | | `FACE_MATCH` | [Face match](/docs/kyc/face-match) | | `AML_SCREENING` | [AML / PEP / sanctions](/docs/kyc/aml) | | `NFC_VERIFICATION` | [NFC chip read](/docs/kyc/nfc) | | `PROOF_OF_ADDRESS` | [Proof of address](/docs/kyc/proof-of-address) | | `DATABASE_VALIDATION` | [Database validation](/docs/kyc/database-validation) | | `IP_ANALYSIS` | Device & IP analysis | See [KYC modules](/docs/kyc/overview) for the behaviour and configuration of each, and [Credits](/docs/concepts/credits) for what each one costs. ## Common pitfalls ## FAQ ## Related --- ## Go-live checklist **URL:** https://docs.v3.thirdfactor.ai/docs/guides/go-live **Description:** Keys, webhooks, idempotency, credits, error handling, and module choices before production traffic. Work through this before real users hit your integration. Each item maps to a failure mode we've seen in production. Where a check needs a worked example, it's linked to one below. ## Keys and secrets The `/v3` API takes the key as `x-api-key`; the partner `/api/v1` API accepts the same key as `Authorization: Bearer `. Keys are tenant-scoped — one tenant can never read another's data. ### Sandbox vs. production tenant | | Sandbox / staging tenant | Production tenant | | --- | --- | --- | | API keys | Separate keys, safe to share with CI | Restricted to prod secrets manager | | Credits | Test balance, safe to drain | Real balance — monitor with alerts | | Evidence (documents, selfies) | Real PII from test runs — still needs deletion hygiene | Real applicant PII, full retention policy applies | | Webhook endpoint | Points at a tunnel or a staging URL | Points at your production handler, TLS-terminated | | Workflow | Mirrors prod config, but safe to break | Change-controlled, reviewed before edits | ## Webhooks and signature verification See [verifying signatures](/docs/webhooks/signatures) for the reference code and [Verify a user, end to end](/docs/guides/verify-a-user) for a full worked handler in Node and Python. ## Correctness ## Idempotency and resilience A minimal backoff-and-retry wrapper, since `429`/`5xx` handling is easy to skip under deadline pressure: ## Credits and operations The `402` body is a stable code, not a message to string-match: ```json { "detail": "insufficient_credits" } ``` Check the balance proactively rather than waiting for a `402` in production: ```bash cURL curl -H "x-api-key: $TF_API_KEY" "$BASE/v3/credits/ledger/?page_size=1" ``` ## Choosing modules The workflow you pin with `workflow_id` decides which modules run. Design it in **Console → Config → KYC Workflows** and confirm: See [Common use cases](/docs/guides/use-cases) for typical module combinations by vertical, and [KYC modules](/docs/kyc/overview) for what each one does and costs. ## Compliance ## Troubleshooting ## FAQ ## Related --- ## Testing **URL:** https://docs.v3.thirdfactor.ai/docs/guides/testing **Description:** Test sessions, the update-status override, reading logs and the checklist, and exercising every outcome. Verification has a lot of branches — approved, declined, review, expired, cancelled, out of credits — and the ones that break in production are the ones nobody exercised. This page covers how to drive each of them, in every language you're likely to be scripting from. ## Overriding a session's decision The fastest way to test your downstream handling is to force a session to a given status with the update-status endpoint. It sets the decision directly and, for terminal statuses, fires the matching webhook. `status` must be a valid session status — `not_started`, `in_progress`, `in_review`, `approved`, `declined`, `abandoned`, or `expired` — otherwise you get `400` with the stable code `invalid_status`. Drive a session to `approved`, then `declined`, then `in_review`, and confirm each moves your system to the right state. ```json { "detail": "invalid_status" } ``` ## Reading logs and the checklist Two read endpoints tell you what actually happened inside a session: ```http GET /v3/session//logs/ # chronological event timeline GET /v3/session//checklist/ # evidence checklist + current stage ``` `logs` returns `{ "session_id", "logs": [{ event, source, message, detail, created_at }] }` — use it to see exactly which module ran, in what order, and why a session was declined or held: ```json { "session_id": "6f0e2b7a-…", "logs": [ { "event": "session.created", "source": "api", "message": "Session created", "detail": {}, "created_at": "2026-07-03T08:00:00Z" }, { "event": "module.id_verification.completed", "source": "engine", "message": "Document authenticated", "detail": { "status": "approved" }, "created_at": "2026-07-03T08:02:11Z" }, { "event": "module.aml_screening.hit", "source": "engine", "message": "Possible PEP match", "detail": { "risk_level": "medium" }, "created_at": "2026-07-03T08:02:40Z" }, { "event": "session.routed_to_review", "source": "engine", "message": "AML possible match", "detail": {}, "created_at": "2026-07-03T08:02:41Z" } ] } ``` `checklist` returns the evidence checklist plus a `stage` field, which is handy for showing progress or debugging a stuck session. You can also pull the full compact decision at any time: ```http GET /v3/session//decision/ ``` ```json { "session_id": "6f0e2b7a-…", "status": "in_review", "decision": "in_review", "decision_v3": { "id_verifications": [{ "status": "approved" }], "liveness_checks": [{ "status": "approved" }], "face_matches": [{ "status": "approved" }], "aml_screenings": [{ "status": "in_review", "risk_level": "medium" }], "reviews": [] }, "reason": "" } ``` ## Exercising the outcomes The paths worth deliberately testing, in rough order of how often they're skipped: ## update-status vs. a real test run: which to use | | `update-status/` override | A real hosted-flow test run | | --- | --- | --- | | Speed | Instant — one API call | Minutes — capture a document, selfie, etc. | | What it exercises | Your webhook handler and state machine | The full engine, including module logic and thresholds | | Costs a credit | No | Yes — `SESSION_CREATE` plus each module that runs | | Good for | CI, regression tests, every outcome branch | Pre-launch smoke test, verifying workflow configuration itself | | Fires the real webhook | Yes, for terminal statuses | Yes | ## Webhook development Point the console's webhook URL at a tunnel (for example `ngrok http 3000`) and use **Console → Developers → Webhooks → Test** to fire a delivery. The deliveries list shows every attempt, its response code, and a retry button — which is also the fastest way to test your dedupe logic. ## Postman The repo ships an importable partner-API collection and environment: ```text docs/postman/obsidian-partner-api.postman_collection.json docs/postman/obsidian-partner-api.postman_environment.json ``` Set `base_url` and `api_key` in the environment and the partner surface is one click away. ## Cleaning up Soft-delete test sessions and their evidence when you're done: ```http DELETE /v3/session//delete/ # returns 204 ``` ## Troubleshooting ## FAQ ## Related --- ## Common use cases **URL:** https://docs.v3.thirdfactor.ai/docs/guides/use-cases **Description:** Typical module combinations and integration patterns by vertical — fintech, banking, crypto, marketplaces, telecom, healthcare, and rental screening. The same engine — document verification, liveness, face match, AML/PEP screening, NFC, proof of address — serves very different risk profiles depending on what you're onboarding a user *for*. This page maps common verticals to the module combinations and delivery patterns teams in that space typically reach for. ## How to read this page Each vertical below lists three things: - **Typical regulatory driver** — the general category of obligation that usually motivates identity verification in that space (framed generally, not tied to a specific jurisdiction). - **Recommended modules** — a starting module combination. Add or remove modules per your own risk appetite; see [KYC modules](/docs/kyc/overview) for what each one does. - **Integration pattern** — whether a hosted-flow redirect, an embedded SDK, or a mix is the more natural fit, and why. ## Fintech / neobank onboarding A typical fintech onboarding flow verifies a new account holder before they can fund an account or move money, balancing conversion (every extra step drops completions) against the obligation to know who holds the account. | | | | --- | --- | | Typical regulatory driver | Customer due diligence obligations attached to opening a deposit or e-money account. | | Recommended modules | `ID_VERIFICATION` + `LIVENESS` (passive, to minimize friction) + `FACE_MATCH` + `AML_SCREENING` | | Integration pattern | Embedded SDK inside the signup flow, so verification feels like a native step rather than a redirect away from the app. | A typical pattern: the user completes basic account details, the app calls `POST /v3/session/` from its backend, and the SDK opens the flow inline. The account activates on the `identity.kyc.session.approved` webhook, with `AML_SCREENING` results routing borderline matches to manual review rather than blocking outright. See [Verify a user, end to end](/docs/guides/verify-a-user). ## Traditional banking KYC Bank account opening — especially for higher-value products (loans, business accounts) — generally carries a stricter identity assurance bar than a fintech wallet, often alongside proof-of-address and enhanced due diligence for higher-risk customers. | | | | --- | --- | | Typical regulatory driver | Formal customer identification programs and enhanced due diligence for higher-risk account types. | | Recommended modules | `ID_VERIFICATION` + `LIVENESS` + `FACE_MATCH` + `AML_SCREENING` + `PROOF_OF_ADDRESS`, with `NFC_VERIFICATION` added for the highest-assurance tier | | Integration pattern | Hosted flow via an emailed or branch-generated link works for branch-assisted onboarding; a mobile SDK is the better fit for full self-service digital account opening where NFC chip reads add meaningful assurance. | ## Crypto exchange (Travel Rule context) Exchanges generally need both to know their own customer and to be ready to exchange originator/beneficiary information with counterparty VASPs on qualifying transfers — which puts a premium on capturing clean, verified identity data up front rather than reconstructing it later. | | | | --- | --- | | Typical regulatory driver | Customer identification for account opening, plus counterparty data-sharing obligations on qualifying transfers between virtual asset service providers. | | Recommended modules | `ID_VERIFICATION` + `LIVENESS` (3D-action or colour-flash, for a higher anti-spoofing bar) + `FACE_MATCH` + `AML_SCREENING` (sanctions + PEP + adverse media) | | Integration pattern | Hosted flow or SDK both work; the distinguishing factor is usually the AML **rolling re-screen** — an approved customer who becomes sanctioned after onboarding should surface automatically, not just at signup. | ## Marketplace / gig-economy identity Two-sided marketplaces (delivery, rideshare, peer-to-peer rental, freelance) typically need to confirm that the person behind an account is a real, consistent individual — both to build trust between strangers transacting and to deter multi-accounting and fraud rings. | | | | --- | --- | | Typical regulatory driver | Platform trust-and-safety obligations rather than a single financial-services regime; often layered with sector-specific requirements (e.g. driver eligibility checks) depending on the marketplace. | | Recommended modules | `ID_VERIFICATION` + `LIVENESS` + `FACE_MATCH`; add `AML_SCREENING` if the marketplace moves funds directly between users, `DATABASE_VALIDATION` where an authoritative eligibility check is available | | Integration pattern | Embedded SDK inside the supply-side (driver/seller/host) onboarding app is the common pattern; demand-side (buyer/rider) verification, where used, is often a lighter hosted-flow step. | ## Telecom SIM registration Many telecom markets require a verified identity behind every active SIM, generally to curb SIM-swap fraud and to support lawful-intercept and anti-fraud obligations. Volume is typically much higher and per-verification cost tolerance lower than in financial services. | | | | --- | --- | | Typical regulatory driver | Mandatory subscriber identity registration tied to SIM/line activation. | | Recommended modules | `ID_VERIFICATION` + `LIVENESS` (passive, to keep unit cost and friction low at high volume); `FACE_MATCH` where the regime requires biometric binding to the ID | | Integration pattern | Hosted flow at a retail point-of-sale kiosk or an agent-assisted link is common; a lightweight embedded SDK fits self-service e-SIM activation in a carrier's own app. | ## Healthcare patient identity Confirming patient identity matters for both safety (matching the right person to the right record, especially for controlled substances or telehealth) and for preventing medical identity fraud — while handling correspondingly sensitive data. | | | | --- | --- | | Typical regulatory driver | Patient-matching accuracy expectations for medical records, and heightened data-protection obligations given the sensitivity of health data. | | Recommended modules | `ID_VERIFICATION` + `LIVENESS` + `FACE_MATCH`; `AML_SCREENING` is typically unnecessary outside specific payer/provider risk contexts | | Integration pattern | Embedded SDK inside a telehealth or patient-portal app, run once at registration and optionally re-confirmed at the start of a sensitive encounter (e.g. before an e-prescription for a controlled substance). | ## Rental / property tenant screening Tenant screening generally combines identity verification (this platform) with separate background/credit checks (typically a different, specialized provider) — the identity step exists to confirm the applicant is who the rest of the screening report is actually about. | | | | --- | --- | | Typical regulatory driver | Fair-housing and consumer-reporting rules generally govern the screening *decision*, not identity verification itself — but confirming the applicant's identity is a prerequisite most screening providers require before running a report. | | Recommended modules | `ID_VERIFICATION` + `LIVENESS` + `FACE_MATCH`; `PROOF_OF_ADDRESS` if verifying current residence matters for the application | | Integration pattern | Hosted flow via an emailed link is typical — tenant screening is usually a one-time, asynchronous step in a broader application, not something embedded in a daily-use app. | ## Module combinations at a glance | Vertical | ID | Liveness | Face match | AML | NFC | Proof of address | | --- | --- | --- | --- | --- | --- | --- | | Fintech / neobank | ✅ | ✅ | ✅ | ✅ | ⚠️ optional | — | | Traditional banking | ✅ | ✅ | ✅ | ✅ | ✅ recommended | ✅ | | Crypto exchange | ✅ | ✅ (strict) | ✅ | ✅ | ⚠️ optional | — | | Marketplace / gig | ✅ | ✅ | ✅ | ⚠️ if moving funds | — | — | | Telecom SIM | ✅ | ✅ (passive) | ⚠️ if required | — | — | — | | Healthcare | ✅ | ✅ | ✅ | — | — | — | | Rental screening | ✅ | ✅ | ✅ | — | — | ⚠️ optional | ## Hosted flow vs. SDK, by pattern | Pattern | Fits best when | | --- | --- | | Hosted flow (redirect or link) | One-time or low-frequency verification, agent-assisted flows, emailed/SMS/QR delivery, no NFC requirement. | | Embedded SDK | Verification is a step inside a broader app experience (signup, driver onboarding, patient portal) and should feel native rather than a navigation away. | | Mobile SDK specifically | NFC passport/eID chip reads are in scope — web has no NFC fallback other than routing to manual review. | See [Hosted flow overview](/docs/hosted-flow/overview) and [SDKs → Overview](/docs/sdks/overview) for the full comparison. ## FAQ ## Related --- ## Verify a user, end to end **URL:** https://docs.v3.thirdfactor.ai/docs/guides/verify-a-user **Description:** A complete integration — create a session, run the flow, handle the webhook, grant access. The whole integration is three moving parts: a create call on your server, a redirect (or SDK launch) for the user, and a signed webhook handler. Here it is in full — every payload, every language, the reconciliation sweep most integrations skip, and the edge cases that show up once you have real traffic. ## Architecture at a glance ## Redirect vs. SDK: which delivery mechanism Both paths create the identical session and run the identical server-side engine — this is a delivery decision, not a capability trade-off (except NFC). | | Full-page redirect | Embedded SDK | | --- | --- | --- | | Integration effort | Lowest — one `fetch`, one `window.location.href` | A few more lines: install the SDK, handle a lifecycle result | | Feels native inside your app | No — full navigation away | Yes — modal/embedded, no navigation | | Outcome delivery | `callback_url` redirect | Bridge event (`ready`/`completed`/`error`/`close`) *and* webhook | | NFC passport chip read | Not available | Available on iOS/Android/Flutter only | | Best for | Emailed/SMS links, QR codes, fastest MVP | In-app onboarding, mobile apps needing NFC | ## 1. Create the session The `201` response looks like this — store `id`, hand `url` to the client: ```json { "id": "6f0e2b7a-9c1a-4b1e-8f2a-1f0e2b7a9c1a", "session_kind": "user", "status": "not_started", "url": "https://v3.thirdfactor.ai/verify/", "vendor_data": "user-42", "decision": "in_progress", "expires_at": "2026-07-04T08:00:00Z", "created_at": "2026-07-03T08:00:00Z" } ``` ## 2. Send the user in ```tsx const { url } = await fetch("/api/kyc/start", { method: "POST" }).then((r) => r.json()); // Simplest — full-page redirect: window.location.href = url; // Or in-app, with the web SDK: const result = await ThirdFactor.verify({ url }); router.push(result.status === "approved" ? "/kyc/pending" : "/kyc/status"); ``` Note the destination on approval: `/kyc/pending`, not `/kyc/success`. The client says the applicant *finished*; it does not say you may *act*. Show "we're confirming your details" and let the webhook flip the state. ## 3. Handle the webhook KYC session events carry a **flat** payload — `decision`, `vendor_data`, and `session_id` are top-level, with no nested `session` object: ```json { "session_id": "6f0e2b7a-9c1a-4b1e-8f2a-1f0e2b7a9c1a", "status": "declined", "decision": "declined", "decision_reason": "document_check_failed", "vendor_data": "user-42", "workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f", "application_id": null, "created_at": "2026-07-03T08:00:00Z", "completed_at": "2026-07-03T08:06:12Z", "event_type": "identity.kyc.session.declined", "idempotency_key": "b2a1c3d4-…" } ``` The `verify` helper is on the [signatures](/docs/webhooks/signatures) page. ## 4. Reconcile (the part everyone skips) Webhooks are reliable, not infallible — your endpoint might have been down for an hour. Run a sweep that re-reads any session still `pending` after, say, 30 minutes: ```ts for (const row of await db.kycSessions.stalePending({ olderThanMinutes: 30 })) { const d = await fetch(`${BASE}/v3/session/${row.sessionId}/decision/`, { headers: { "x-api-key": KEY }, }).then((r) => r.json()); if (d.decision !== "in_progress") await applyDecision(row.userId, d); } ``` `GET /v3/session//decision/` returns: ```json { "session_id": "6f0e2b7a-9c1a-4b1e-8f2a-1f0e2b7a9c1a", "status": "approved", "decision": "approved", "decision_v3": { "id_verifications": [], "liveness_checks": [], "face_matches": [], "aml_screenings": [], "reviews": [] }, "reason": "" } ``` Sweep on a schedule, not in a tight poll loop — `/v3` reads are rate-limited per tenant. ## Handling review properly `in_review` is the state most integrations get wrong. It is neither a failure nor a success: an operator is looking at the session in **Console → Verifications**, and a terminal `.approved` or `.declined` event will follow, possibly hours later. Tell the user honestly ("we're reviewing your documents, we'll email you"), keep the account in a limbo state, and let the webhook resolve it. ## Edge cases worth handling deliberately ## Common mistakes ## FAQ ## Related --- ## Create a session **URL:** https://docs.v3.thirdfactor.ai/docs/hosted-flow/create-session **Description:** POST /v3/session/ in depth — every field, the 201 response, Idempotency-Key, and the 402 insufficient-credits path. A verification session is the unit of work: one applicant, one workflow, one hosted URL. You create it from your **server** (never a browser or mobile app — the API key is secret), then redirect the applicant to the returned `url`. ```http POST /v3/session/ x-api-key: Content-Type: application/json ``` ## Request ### Fields Every field is optional — an empty body creates a session against the tenant's default workflow. ### Every field, one payload The example above only sets the fields a typical integration needs. Here is a request that sets **every** documented field, including every `prefill` key, so you can see the full shape in one place: ```json { "workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f", "vendor_data": "user-42", "callback_url": "https://app.example.com/kyc/done", "contact_email": "aarav@example.com", "contact_phone": "9841000000", "expires_in_hours": 24, "locale": "en", "prefill": { "full_name": "Aarav Sharma", "first_name": "Aarav", "last_name": "Sharma", "display_name": "Aarav", "date_of_birth": "1990-04-12", "email": "aarav@example.com", "phone": "9841000000", "nationality": "NP", "address_line1": "House 12, Baneshwor", "address_line2": "Ward 10", "city": "Kathmandu", "state": "Bagmati", "country": "NP", "zip_code": "44600" } } ``` ## Response On success you get `201 Created`: ```json { "id": "6f0e2b7a-…", "session_kind": "user", "status": "not_started", "url": "https://v3.thirdfactor.ai/verify/", "vendor_data": "user-42", "decision": "in_progress", "expires_at": "2026-07-04T08:00:00Z", "created_at": "2026-07-03T08:00:00Z" } ``` ## Idempotency Session creation debits credits, so make retries safe with an `Idempotency-Key` header. A repeat create with the **same** key returns the original session (`201`) and is never charged twice — safe for at-least-once job queues and network-timeout retries. ```http Idempotency-Key: ``` ### Concurrent creates with the same key If two requests carrying the same `Idempotency-Key` race each other (for example, a double-click submitted twice, or a job retried while the first attempt is still in flight), you should still end up with exactly one session and exactly one debit — treat any `201` on a repeated key as "the session exists," not "a new one was made." If you see a genuinely conflicting resource error instead of a clean replay, re-read the existing session before creating again rather than blindly retrying with a new key; see [Errors → 409 duplicate](/docs/resources/errors). ## Insufficient credits (402) Creating a session debits the tenant's `SESSION_CREATE` credit rate. When the balance is exhausted the endpoint returns `402` with a stable code: ```json { "detail": "insufficient_credits" } ``` Branch on the **HTTP status** first, then the string code — don't string-match human-readable messages. Top up credits (or check the balance via [`GET /v3/credits/ledger/`](/docs/api-reference/usage)) and retry with the same `Idempotency-Key`. ## Edge cases ## Other status codes | Status | Meaning | | --- | --- | | `201` | Session created (or returned unchanged for a repeated `Idempotency-Key`). | | `400` | Validation error — e.g. `expires_in_hours` out of `1..720`, bad `workflow_id`. | | `401` | Missing or invalid API key. | | `402` | `insufficient_credits`. | | `429` | Rate-limited — back off and retry. | See [Resources → Errors](/docs/resources/errors) for the full error contract and [Resources → Rate limits](/docs/resources/rate-limits). ## Troubleshooting ## Related --- ## Customize the flow **URL:** https://docs.v3.thirdfactor.ai/docs/hosted-flow/customization **Description:** Branding, theme, locale, prefill and copy overrides for the hosted verification page. The hosted flow is white-labelled: it renders with your logo, colours, copy and theme so the applicant sees *your* brand, not ThirdFactor's. Most styling is a tenant/workflow-level property set in the console — it applies consistently across every channel you send an applicant through. Only a small set of knobs (`locale`, `prefill`) is set per session at create time. ## What is set where | Aspect | Where it's configured | Per session? | | --- | --- | --- | | Logo, colour, button shape, theme | Console **Config → KYC Workflows → Design** panel, and tenant defaults in KYC settings | No — tenant/workflow-level | | Applicant-facing copy (per-locale overrides) | Console **Settings → Translations** | No — tenant-level | | Terms & Conditions | Console **Settings → Terms** | No — tenant-level | | UI language | `locale` on `POST /v3/session/` | Yes | | Pre-populated identity fields | `prefill` on `POST /v3/session/` | Yes | ## Branding & theme The Design panel in the workflow builder controls the look of the hosted page. Workflow-level values override tenant defaults, so a designer sees exactly what they styled. The customization blob the flow consumes supports: | Key | Type | Effect | | --- | --- | --- | | `primary_color` / `color` | string | Accent colour for buttons and highlights. | | `logo_url` | string | Brand logo shown in the flow header (falls back to the tenant's `client_logo_url`). | | `button_radius` | `sm` \| `md` \| `full` | Corner rounding of primary buttons. | | `theme` | `light` \| `dark` | Forces the flow's colour scheme. | | `language` | `en` \| `ne` | Default flow language. | | `welcome_title` / `welcome_message` / `button_text` | string | Intro-screen copy (session vocabulary). | | `headline` / `body` / `cta_text` | string | Intro-screen copy (Design-panel vocabulary). | | `allow_upload` | boolean | Allow file upload for document capture (default `true`). | | `allow_desktop` | boolean | Allow completing on desktop (default `true`); when desktop is allowed you can also show a "continue on your phone" QR. | Put together, that's the shape the flow reads at render time — an operator never edits this JSON directly (the Design panel writes it), but it's useful to see the whole object when you're debugging why something isn't rendering as expected: ```json { "primary_color": "#0F62FE", "logo_url": "https://cdn.acme.example.com/logo.svg", "button_radius": "md", "theme": "dark", "language": "en", "welcome_title": "Verify your identity", "welcome_message": "This takes about 3 minutes. Have your ID ready.", "button_text": "Start verification", "allow_upload": true, "allow_desktop": true } ``` ## Dark mode Set `theme` to `dark` (or `light`) in the Design panel to force a colour scheme. The hosted flow resolves the theme **server-side from the session token** before the first frame, so a dark flow paints dark immediately — there is no light flash before hydration on the loading and gate screens. ## Locale `locale` on `POST /v3/session/` sets the UI language for that specific session (default `en`): **Request** ```json { "workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f", "locale": "ne" } ``` **Response** — `locale` itself isn't echoed back on the session object, but it takes effect immediately on the hosted page: ```json { "id": "6f0e2b7a-…", "session_kind": "user", "status": "not_started", "url": "https://acme.example.com/verify/", "vendor_data": null, "decision": "in_progress", "expires_at": "2026-07-13T08:00:00Z", "created_at": "2026-07-12T08:00:00Z" } ``` The flow ships English (`en`) and Nepali (`ne`) copy. Per-locale text can be overridden tenant-wide (see below), so `locale` selects which override set the applicant sees. ## Copy overrides (translations) Under **Settings → Translations** an operator can override any applicant-facing string per locale — for example the intro title, step instructions, or button labels. Overrides are keyed by the flow's i18n dictionary and validated against a curated catalog, so only real, applicant-safe strings can be set. They are merged into the flow at render time and honour the session `locale`. ```json { "en": { "kyc.intro.title": "Verify your identity with Acme Bank" }, "ne": { "kyc.intro.title": "एक्मे बैंकसँग आफ्नो पहिचान प्रमाणित गर्नुहोस्" } } ``` ## Terms & Conditions Under **Settings → Terms** you can attach versioned Terms & Conditions to the flow's start screen, optionally requiring explicit acceptance before the applicant can proceed. T&C carry a `version` and per-locale `title` / `body`; editing the content bumps the version and pushes the superseded copy onto its history, and an acceptance record is captured against the session. ## Prefill `prefill` on `POST /v3/session/` pre-populates identity fields so the applicant types less. Only a fixed set of keys is read (unknown keys are ignored): ```json { "prefill": { "full_name": "Aarav Sharma", "first_name": "Aarav", "last_name": "Sharma", "display_name": "Aarav", "date_of_birth": "1990-04-12", "email": "aarav@example.com", "phone": "9841000000", "nationality": "NP", "address_line1": "House 12, Baneshwor", "address_line2": "Ward 10", "city": "Kathmandu", "state": "Bagmati", "country": "NP", "zip_code": "44600" } } ``` See [Create a session](/docs/hosted-flow/create-session#fields) for the full field reference. ## Hosted flow vs. SDK customization Both delivery paths render the same Design-panel branding — the difference is what else you can control from the host application: | | Hosted flow (redirect) | SDK (embedded) | | --- | --- | --- | | Logo, colour, theme, button shape | ✅ tenant/workflow Design panel | ✅ same Design panel | | Copy per locale | ✅ Settings → Translations | ✅ same, via `locale` | | Container styling (size, position, host page around it) | ❌ full-page, you don't control the chrome | ✅ your app controls the embedding container | | Light/dark resolved before first paint | ✅ server-side from the session token | ✅ same | | Per-session prefill | ✅ `prefill` at create time | ✅ same field, same endpoint | ## Troubleshooting ## FAQ ## Related --- ## No-code links **URL:** https://docs.v3.thirdfactor.ai/docs/hosted-flow/no-code-links **Description:** Ways to get an applicant into the hosted flow without your own backend calling the KYC API — console-generated links and the partner-signed JWT link flow. Not every integration wants to call the API on every onboarding. ThirdFactor offers two link-generation paths that produce a hosted-flow URL you can email, text, or drop into a QR code — one for operators in the console, one for partners who prefer a signed-JWT handshake. ## Console (operator) links An operator can generate a session link straight from the console — no code, no API call. This is ideal for ad-hoc onboarding, branch-assisted verification, or sending a one-off link to a customer. ## API-created ThirdFactor Verify links When your backend calls [`POST /v3/session/`](/docs/hosted-flow/create-session), the `url` in the `201` response (`/verify/`) *is* the link — hand it to the applicant however you like. Its lifetime is `expires_in_hours` (`1..720`, default `48`). This is the recommended path when you have a server. ## Partner-signed JWT links The partner surface `POST /api/v1/sessions/` mints a hosted link from a short-lived HS256 JWT you sign with your tenant API key, rather than sending the raw key in the request body. It runs a **tenant flow** (the application/flow model) and returns a `/s/` link. ### Request ```http POST /api/v1/sessions/ X-API-Key: Content-Type: application/json ``` ```json { "jwt": " Full claim reference lives in [Authentication](/docs/authentication#partner-signed-jwt-hosted-sessions): | Claim | Required | Meaning | | --- | --- | --- | | `jti` | yes | Idempotency key **and** the application's `external_ref`. | | `iat` | yes | Issued-at (UNIX seconds). | | `exp` | yes | Expiry — **≤ 5 minutes** after `iat`. | | `flow_id` | yes | The tenant flow to run. | | `payload` | no | Initial applicant data (`phone`, `email`, `identifier`, `full_name`). | | `session_ttl_minutes` | no | Hosted URL lifetime. Omit for the tenant default, `0` for no expiry, `1..525600` for a custom lifetime. | ### Response ```json { "ok": true, "session_id": "ge9vgmlmfbi9k5q", "url": "https://acme.example.com/s/", "expires": true, "expires_at": "2026-05-18T12:00:00Z", "credits_remaining": 99 } ``` Send the applicant to `url`. The `jti` you signed becomes the application's `external_ref`, so you can correlate the result with your own record without storing Obsidian's id. ### Link lifetime The hosted URL's default lifetime comes from the tenant setting in **Console → Settings → API keys → Hosted session expiry** (starts at 30 minutes; operators can change it or disable expiry for new sessions). Override per session with the `session_ttl_minutes` claim. Existing application URLs keep their stored expiry until you explicitly change it — for example, kill a live URL immediately: **Expire a live link immediately** ```bash curl -X PATCH "$BASE/api/v1/applications/$APP_ID/" \ -H "X-API-Key: $TF_API_KEY" -H "Content-Type: application/json" \ -d '{"expire_now": true}' ``` **Set an explicit expiry** ```bash curl -X PATCH "$BASE/api/v1/applications/$APP_ID/" \ -H "X-API-Key: $TF_API_KEY" -H "Content-Type: application/json" \ -d '{"session_expires_at": "2026-05-18T12:00:00Z"}' ``` **Make a link non-expiring** ```bash curl -X PATCH "$BASE/api/v1/applications/$APP_ID/" \ -H "X-API-Key: $TF_API_KEY" -H "Content-Type: application/json" \ -d '{"session_expires_at": null}' ``` All three return the updated application. You can read the current expiry state at any time — `session_expiry_state` on `GET /api/v1/applications//` is one of `active`, `expired`, or `never`: ```json { "ok": true, "application": { "id": "ge9vgmlmfbi9k5q", "external_ref": "core-banking-id-123", "tracking_id": "APP-2026-0001", "status": "manual_review", "decision": "manual_review", "session_expires_at": "2026-05-18T12:00:00Z", "session_expiry_state": "active", "phone": "9841000000", "email": "customer@example.com", "created_at": "2026-05-18T08:00:00Z", "updated_at": "2026-05-18T08:10:00Z" } } ``` ## Which one should I use? | Path | Best for | Link shape | Default expiry | | --- | --- | --- | --- | | Console link | Operators, ad-hoc / assisted onboarding | `/s/` | 24 h | | ThirdFactor Verify `POST /v3/session/` | Backend-driven KYC onboarding | `/verify/` | 48 h (`1..720`) | | Partner JWT `POST /api/v1/sessions/` | Backend that prefers a signed handshake over the raw key; application/flow model | `/s/` | tenant default (from 30 min) | ## Troubleshooting ## FAQ ## Related --- ## Hosted flow overview **URL:** https://docs.v3.thirdfactor.ai/docs/hosted-flow/overview **Description:** The applicant-facing verification page ThirdFactor hosts for you at /verify/ — what it does, when to use it, and why the server drives it. The **hosted flow** is the verification experience ThirdFactor renders for your applicant. You create a session on your server, redirect the user to the returned `url`, and ThirdFactor handles document capture, liveness, selfie, AML and every other enabled module — including the retake guidance that gets a usable image the first time. You never build capture UI. Every verification runs through the same hosted flow, whichever way you start it: ## When to use the hosted flow vs. the SDKs Both delivery mechanisms drive the identical server-side engine, so the choice comes down to how completion is reported and whether you need NFC: | Capability | Hosted flow (redirect) | SDK (embedded) | | --- | --- | --- | | Integration effort | ✅ one API call + one redirect | ⚠️ install and initialize an SDK | | Runs inside your app's own UI chrome | ❌ full-page takeover | ✅ embedded view / modal | | Completion signal | Browser redirect to `callback_url` | Lifecycle events (`ready`, `completed`, `error`, `close`) | | NFC passport / eID chip read | ❌ not available | ✅ iOS, Android, Flutter only | | Branding (logo, colour, theme) | ✅ tenant/workflow-level | ✅ same, tenant/workflow-level | | Server-side module enforcement | ✅ | ✅ | | Shareable via email / SMS / QR | ✅ | ❌ requires your app to be installed | ## The applicant journey ## End-to-end example The three pieces below — create, redirect, webhook — are the whole integration. Field-by-field detail for the request lives in [Create a session](/docs/hosted-flow/create-session); the full webhook contract lives in [Webhooks → Events](/docs/webhooks/events). ## Server-enforced modules The applicant's client never chooses or weakens a step. The **workflow** — built in the console under **Config → KYC Workflows** — decides which modules run and how strict each one is, and the server enforces that: - The configured **liveness method** (passive, colour-flash, or 3D-action) cannot be downgraded client-side. - The applicant cannot skip a required module, reorder the flow, or mark their own result as passed. Applicant-facing results are deliberately shallow (pass/fail per step); scores, warnings and engine detail stay server-side and are only visible to operators in the console. - Evidence (documents, selfies, liveness stills) is sensitive PII. It is only readable through the authenticated, tenant-ownership-checked console view — it is never served from the public media namespace. Which workflow a session runs is resolved at create time — pin it explicitly with `workflow_id`. See [Create a session](/docs/hosted-flow/create-session) and [Concepts → Workflows](/docs/concepts/workflows). ## Session lifecycle & expiry A hosted URL is single-purpose and time-boxed. Its lifetime is set at create time (`expires_in_hours`, `1..720`, default `48` for ThirdFactor Verify (`/v3`) sessions). Once it expires the applicant sees an expiry screen, the session settles as `expired`, and `identity.kyc.session.expired` fires. Terminal sessions emit the standard signed webhook: | Event | When | | --- | --- | | `identity.kyc.session.approved` | Verification passed. | | `identity.kyc.session.declined` | Verification failed. | | `identity.kyc.session.review` | Held for operator review. | | `identity.kyc.session.expired` | The hosted URL lapsed before completion. | See [Webhooks → Events](/docs/webhooks/events) and [Concepts → Sessions](/docs/concepts/sessions) for the full status/decision mapping. ## Troubleshooting ### The session URL 404s This is almost always a mismatch between the URL you're opening and the one ThirdFactor returned: - **You rebuilt the URL instead of using the `url` field verbatim.** The token is opaque; don't concatenate `id` onto a path yourself. Always redirect to the exact string returned by the create call. - **You mixed up link shapes.** ThirdFactor Verify `POST /v3/session/` returns `/verify/`; the partner `POST /api/v1/sessions/` and console-generated links return `/s/`. Opening a `/s/` token against `/verify/` (or vice versa) 404s. See [No-code links](/docs/hosted-flow/no-code-links) for which path each surface produces. - **The session was deleted.** `DELETE /v3/session//delete/` soft-deletes the session and its evidence; a URL for a deleted session is gone. - **The link was truncated in transit.** Long tokens can get clipped by SMS templates, email link-shortening, or a wrapping line in a copy/paste. Log the full `url` your server received and diff it against what was actually sent. ### The applicant is stuck on a step Pull the session's server-side view first — it usually tells you exactly where it stalled: ```bash curl -H "x-api-key: $TF_API_KEY" "$TF_BASE_URL/v3/session//logs/" curl -H "x-api-key: $TF_API_KEY" "$TF_BASE_URL/v3/session//checklist/" ``` Common causes: - **NFC step opened outside a mobile SDK.** If the workflow requires NFC chip reading, the applicant can't complete it in a plain browser hosted flow — NFC only runs inside the iOS, Android, and Flutter SDKs. Route these applicants through a mobile SDK, not the redirect link. - **OTP required but no contact info was supplied.** If the workflow uses email or SMS OTP and you didn't pass `contact_email` / `contact_phone` on create, the applicant has to enter contact details themselves mid-flow — slower, and easy to abandon. Pass them at create time whenever you already have them. - **Terms & Conditions blocking silently.** If T&C acceptance is required and the tenant hasn't set copy for the session's `locale`, the accept screen may render with fallback text an applicant doesn't trust enough to continue. Check **Settings → Terms** and **Settings → Translations** for that locale. ### The callback never fires "Callback" usually means one of two different things — make sure you're debugging the right one: The **webhook** is the reliable channel. If it truly never arrives: - Confirm an endpoint URL and signing secret are set under **Console → Developers → Webhooks** — an unconfigured endpoint has nothing to deliver to. - Confirm your handler returns `2xx` quickly. Delivery is at-least-once with backoff; a timeout, a `4xx`/`5xx`, or a slow handler is treated as failed and retried, not dropped — but it also means "never fires" can actually mean "kept failing silently on your side." - Check the delivery log on that same console screen — it lists every attempt with its response code and lets you replay one on demand. - Confirm the session actually reached a terminal state. A session sitting in `in_review` hasn't fired a terminal event yet; that's expected, not broken. ## FAQ ## Related --- ## Event catalog **URL:** https://docs.v3.thirdfactor.ai/docs/integrations/events **Description:** Every Connect event type, when it fires, and what its payload carries. This is the catalog of events a Connect endpoint (or Zapier trigger / n8n trigger node) can subscribe to. The machine-readable version — including a full static sample payload per event — is served by `GET /v3/automation/events/`. Subscriptions accept exact keys, trailing wildcards (`session.*`, `case.*`), or `["*"]` for everything. All payloads use the [Connect envelope](/docs/integrations/overview#the-envelope); the tables below describe `data`. ## Session lifecycle `data.session` carries the standard session envelope (id, status, workflow, `vendor_data`, contacts, metadata, risk score/level, hosted `url`, timestamps, and the full `decision` object once terminal). | Event | Fires when | | --- | --- | | `session.created` | A session is created — by API, console, or a hosted flow step. | | `session.started` | The applicant opens the flow and begins verifying. | | `session.approved` | Final APPROVED decision (automatic or via review). | | `session.declined` | Final DECLINED decision. | | `session.completed` | Any final outcome — fires **alongside** `.approved`/`.declined` so one subscription covers both; branch on `data.session.status`. | | `session.in_review` | The session routed to manual review (a case opens). | | `session.reviewed` | An operator resolved an in-review session; `data.review` carries the decision and reviewer. | | `session.abandoned` | Started but never finished; TTL elapsed mid-flow. | | `session.expired` | The link expired before the applicant started. | | `session.resubmission_requested` | An operator asked the applicant to redo part of the verification. | ## Modules The granular heart of Connect: one event per module outcome, **before** the session finishes. | Event | Fires when | | --- | --- | | `module.completed` | Any single module produced its result. `data.module` = `{feature, node_id, status, score, warnings, data}` — e.g. the OCR'd document fields for `ID_VERIFICATION`, the screening summary for `AML_SCREENING`. | Use the endpoint's `features` filter to scope this to specific modules: ```json { "events": ["module.completed"], "features": ["ID_VERIFICATION", "AML_SCREENING"] } ``` Module keys: `ID_VERIFICATION`, `NFC_VERIFICATION`, `LIVENESS`, `FACE_MATCH`, `AML_SCREENING`, `IP_ANALYSIS`, `AGE_ESTIMATION`, `EMAIL_VERIFICATION`, `PHONE_VERIFICATION`, `INFORMATION`, `CONFIRM_DETAILS`, `QUESTIONNAIRE`, `DOCUMENT_AI`, `PROOF_OF_ADDRESS`, `BUSINESS_DOCUMENT`, `DATABASE_VALIDATION`, `BRANCH_VISIT`, `VIDEO_CALL`, `PRODUCT_SELECTION`. ## Risk & compliance | Event | Fires when | | --- | --- | | `aml.hit` | AML screening confirmed one or more watchlist matches. `data.aml` = `{risk_level, matches, possible_matches, matched_entities[], lists_checked}`. | | `blocklist.hit` | The applicant matched a tenant block list entry. `data.detail.kind` is `"value"` or `"face"`. | | `risk.scored` | Risk rules produced a score + level for the session. | | `duplicate.detected` | The same face/identity was seen on a different profile. | ## Review cases `data.case` carries `{id, status, case_type, priority, recommendation, resolution, resolution_reason, assigned_to, due_at, created_at}`, alongside `data.session`. | Event | Fires when | | --- | --- | | `case.created` | A manual-review case opens for a session. | | `case.approved` | A reviewer approved the case (final). | | `case.declined` | A reviewer declined the case (final). | | `case.sent_back` | A checker returned the case to the maker stage. | | `case.overdue` | The case blew past its SLA and was escalated. | ## Applicant progress | Event | Fires when | | --- | --- | | `contact.verified` | The applicant proved ownership of an email or phone (OTP). | | `document.uploaded` | The applicant submitted an identity document image. | ## Account Session-less events (no `data.session`); never filtered out by `workflow_ids`. | Event | Fires when | | --- | --- | | `credits.added` | Credits were added; `data.credits` = `{delta, balance, reason}`. | | `credits.low` | Balance crossed below the low-water threshold. | | `credits.exhausted` | Balance reached zero — verifications will be blocked. | ## Getting a sample payload Three ways, most-real first: 1. `GET /v3/automation/samples//` — up to three **recent real payloads** delivered to your endpoints, falling back to the static sample. 2. The console **Connect** page's test-fire button — sends the sample through the real signed pipeline to your endpoint. 3. `GET /v3/automation/events/` — the static sample inline in the catalog. ## Related --- ## n8n **URL:** https://docs.v3.thirdfactor.ai/docs/integrations/n8n **Description:** The community node package — a webhook trigger with dynamic event dropdowns and signature verification, plus an action node for sessions and tools. The `n8n-nodes-thirdfactor-obsidian` community package (repo: `sdks/n8n/`) adds two nodes to n8n — a good fit when you self-host your automation next to an on-prem or private-cloud deployment. ## Install In n8n: **Settings → Community Nodes → Install** → `n8n-nodes-thirdfactor-obsidian` (once published to npm). From source: ```bash cd sdks/n8n npm install && npm run build # then link/copy into your n8n custom-nodes directory, e.g. # ~/.n8n/custom/ → npm install /path/to/thirdfactor-obsidian/sdks/n8n ``` ## Credentials One credential type, **ThirdFactor Obsidian API**: | Field | Value | | --- | --- | | **Base URL** | Your deployment's API origin, e.g. `https://acme.thirdfactor.ai` | | **API key** | A tenant API key (`sk_…`) from **Console → Settings → API keys** | The credential test calls `GET /v3/automation/me/`. All requests send the key as `X-API-Key`. ## Trigger node — *ThirdFactor Obsidian Trigger* A webhook trigger that manages its own [Connect subscription](/docs/integrations/overview) (created with `source: "n8n"`, so it shows up on the console Connect page): - **Events** — multi-select, loaded live from `GET /v3/automation/events/` and grouped by category; pick one event or twenty. - **Workflow filter** — optional multi-select of your workflows. - **Module filter** — optional, for `module.completed` / `aml.hit` granularity. - **Verify signature** (default **on**) — checks the delivery's `X-Webhook-Signature` HMAC against the subscription secret (stored in the workflow's static data when the subscription is created), rejects tampered or stale (>5 min) deliveries with HTTP 401. Activating the workflow subscribes; deactivating unsubscribes. If the subscription vanished server-side (deleted from the console), the node detects it on activation and re-subscribes cleanly. Each delivery becomes one item whose JSON is the [Connect envelope](/docs/integrations/overview#the-envelope) — route on `{{ $json.event }}` and read `{{ $json.data.session.vendor_data }}`, `{{ $json.data.module.status }}`, etc. ## Action node — *ThirdFactor Obsidian* | Resource | Operations | | --- | --- | | **Session** | Create (workflow dropdown, vendor data, contacts, expiry → returns the hosted `url`) · Get · Get Decision · Get Many · Add Tag | | **Tools** | AML Check (name, DOB, nationality → matches) | ## Example workflows - **Trigger (`session.in_review`) → Slack → Wait → HTTP Request**: alert compliance, then poll `GET /v3/session/{{id}}/decision/` for the final outcome. - **Cron → Postgres → Session: Create → SMTP**: nightly batch — create verification sessions for new signups and email each applicant their link. - **Trigger (`aml.hit`) → IF (risk_level = high) → PagerDuty**: page on high-risk watchlist matches only. - **Trigger (`module.completed`, filter `ID_VERIFICATION`) → Code → Google Sheets**: extract OCR'd document fields into a review sheet in real time. ## Related --- ## Integrations overview **URL:** https://docs.v3.thirdfactor.ai/docs/integrations/overview **Description:** Granular event subscriptions for Zapier, n8n, and any webhook receiver — subscribe to exactly the events, workflows, and modules you care about. The **Connect** layer lets you pipe fine-grained platform events into Zapier, n8n, or your own systems, and drive the platform (create verification sessions, run AML checks, tag sessions) from those tools. Where the classic tenant webhook (see [Webhooks](/docs/webhooks/overview)) is one URL that receives terminal decisions, Connect gives you **many endpoints, each with its own subscription filter and signing secret**: Endpoints created by Zapier or n8n appear automatically on the console **Connect** page (**Console → Developers → Connect**) with a source badge, so an operator can see, test-fire, pause, or debug a Zap's subscription without leaving the console. ## The envelope Every Connect delivery POSTs one event in a stable envelope: ```json { "payload_version": 1, "event": "session.approved", "id": "11111111-2222-3333-4444-555555555555", "created_at": "2026-07-16T10:04:12Z", "data": { "session": { "id": "6a1f6f7e-…", "status": "approved", "workflow": { "id": "1f2e3d4c-…", "name": "Standard KYC" }, "vendor_data": "user-42", "risk_score": 12, "risk_level": "low", "url": "https://acme.verify.example.com/verify/tok_…", "created_at": "2026-07-16T10:00:00Z", "completed_at": "2026-07-16T10:04:12Z" } } } ``` - `event` — the event key; route on this. - `id` — unique per event; **dedupe on this** (delivery is at-least-once). - `data` — event-specific: `session`, plus `module`, `case`, `aml`, `detail`, or `credits` depending on the event. See the [event catalog](/docs/integrations/events) for a sample of every event. Payloads are sanitized before shipping — evidence images, base64 blobs, internal media paths and secrets are stripped, long strings truncated. ## Managing endpoints Two equivalent surfaces manage the same endpoints: ## Delivery semantics Connect deliveries differ from the classic webhook in one way: the **first attempt is dispatched asynchronously** (a background worker), so the request that produced the event never waits on your sink. Everything else matches the classic pipeline — at-least-once delivery, the same backoff schedule, no redirects followed, HTTPS-only targets, and per-delivery visibility in the console. Design your receiver exactly as the [idempotent-handler guidance](/docs/webhooks/overview#designing-an-idempotent-webhook-handler) describes, deduping on the envelope's `id`. ## Next --- ## Zapier **URL:** https://docs.v3.thirdfactor.ai/docs/integrations/zapier **Description:** Trigger Zaps on verification outcomes, AML hits, review cases, or any platform event — and create sessions, tags, and AML checks from other apps. The Zapier app connects the platform to 7,000+ apps with no code: post to Slack when a verification needs review, add approved users to your CRM, open a ticket on an AML hit, or kick off a verification session when a row lands in a spreadsheet. The app lives in the repo at `sdks/zapier/` (Zapier Platform CLI). Until it's published to the public Zapier directory, push it to your Zapier workspace as a private app: ```bash cd sdks/zapier npm install npx zapier login npx zapier register "ThirdFactor Obsidian" # once npx zapier push ``` ## Connecting your account The connection asks for two fields: | Field | Value | | --- | --- | | **Base URL** | Your deployment's API origin, e.g. `https://acme.thirdfactor.ai` | | **API key** | A tenant API key (`sk_…`) from **Console → Settings → API keys** | Zapier validates the pair against `GET /v3/automation/me/` and labels the connection with your tenant name. ## Triggers All triggers are **instant** (REST hooks): turning a Zap on creates a [Connect subscription](/docs/integrations/overview) with `source: "zapier"` (visible and debuggable on the console **Connect** page); turning it off deletes the subscription. Ready-made triggers for the highest-value events: | Trigger | Fires on | | --- | --- | | **Verification Completed** | `session.completed` — any final outcome; branch on `data.session.status` in later steps | | **Verification Approved** | `session.approved` | | **Verification Declined** | `session.declined` | | **Verification Needs Review** | `session.in_review` | | **Review Case Opened** | `case.created` | | **AML Watchlist Hit** | `aml.hit` | | **Module Completed** | `module.completed` — with an optional module dropdown (ID check, liveness, AML, …) | Plus a generic **Platform Event** trigger with a dynamic dropdown of the full [event catalog](/docs/integrations/events) — anything in the catalog (`session.started`, `case.overdue`, `credits.low`, …) can drive a Zap. Most triggers accept an optional **Workflow** filter (a dynamic dropdown of your workflows), so a Zap can react only to, say, your "High-value onboarding" workflow. Sample data in the Zap editor comes from `GET /v3/automation/samples//` — your most recent **real** payloads when any exist, a realistic static sample otherwise. ## Actions | Action | Does | | --- | --- | | **Create Verification Session** | `POST /v3/session/` — pick a workflow from a dropdown, pass your user reference (`vendor_data`), contacts, expiry; returns the hosted verification `url` to send to the applicant | | **Add Tag to Session** | `POST /v3/session//tags/` | | **Run AML Check** | `POST /v3/tools/aml-check/` — standalone sanctions/PEP screen by name, DOB, country | ## Search **Find Session** — by session ID, or by your `vendor_data` reference (the vendor-data variant scans the newest 200 sessions, which is fine for automation-scale lookups; use the API directly for bulk reconciliation). ## Example Zaps - *Verification Approved* → **Salesforce**: update the lead to "KYC passed", attach the risk score. - *Verification Needs Review* → **Slack**: post to `#compliance` with the hosted console link. - *AML Watchlist Hit* → **Jira**: open a ticket carrying `data.aml.matched_entities`. - **Google Sheets** new row → *Create Verification Session* → **Gmail**: email the applicant their verification link. - *Module Completed (ID_VERIFICATION)* → **Airtable**: log the OCR'd document fields the moment the document step finishes, before the session even completes. ## Related --- ## AML screening **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/aml **Description:** Sanctions, PEP, adverse-media and POI screening against OpenSanctions plus your own custom lists — thresholds, hit routing, and ongoing monitoring. The `AML_SCREENING` module screens an applicant's identity against global watchlists — sanctions, politically-exposed persons (PEP), adverse media and persons-of-interest (POI). It runs **server-side** from the identity data already on the session (no applicant capture) and is also available as the stateless [`/v3/tools/aml-check/`](#standalone-aml-check) tool. Screening runs against an OpenSanctions-derived dataset served by a bundled matching engine (yente-style, queried over its `/match` endpoint). The service re-indexes the sanctions / PEP / adverse-media data on its own schedule, so every screen runs against current data with no manual refresh step. The name, date of birth, nationality, country and document number screened are taken from the identity that [document verification](/docs/kyc/document) hydrated. ## How a query is built The engine assembles a FollowTheMoney `Person` query from whatever identity data is available. A **name is the minimum** — with no name to match on, the module goes to review (`aml_insufficient_data`) rather than screening an empty query. Every other field simply narrows the candidate set: | Field | Maps to (FtM property) | | --- | --- | | Full name (or first + last) | `name` | | Date of birth | `birthDate` | | Nationality (ISO-2) | `nationality` | | Country (ISO-2) | `country` | | Document number | `idNumber` | | Email / phone | `email` / `phone` | ## Lists (topics) The workflow step's `lists` config selects which topics count. Each candidate's watchlist topics map onto these lists; a candidate on **no enabled list never flags the session**: | List | Covers | Topics folded in | | --- | --- | --- | | `sanctions` | Sanctions-list entities. | `sanction*`, `debarment`, `export.control` | | `pep` | Politically-exposed persons and their close associates (RCA). | `role.pep`, `role.rca` | | `adverse_media` | Adverse-media / criminal reporting. | `crime*`, `wanted`, `reg.action`, `reg.warn` | | `poi` | Persons of interest (informational unless the source dataset makes it blocking). | `poi` | When `lists` is omitted, all four are screened. POI-only matches from *informational* datasets (e.g. unelected local-election candidates) surface as informational notices; POI entities from any other dataset (bribe-takers, war-sanctions POIs) stay blocking. An unknown or empty source dataset fails safe to blocking. ## Match scoring and the three tiers The engine splits candidates into three tiers using the engine's `threshold` and a lower `cutoff`: - **Confirmed hit** — the engine returned `match: true` (score at or above the `threshold`, default `0.7`). Lands in `matches`. - **Possible match** — scored at or above the `cutoff` (default `0.5`) but below the threshold, or a demoted confirmed hit (see below). Lands in `possible_matches`. - **Informational** — the candidate's only relevant list is informational (`poi`) and every source dataset is informational. Lands in `informational_matches`. ## Result shape and risk levels The module produces a `risk_level` plus the three tiers of matches: ```json { "risk_level": "high", "matches": [ { "entity_id": "NK-abc123", "name": "AARAV SHARMA", "schema": "Person", "score": 0.94, "match": true, "lists": ["sanctions"], "topics": ["sanction"], "datasets": ["us_ofac_sdn"], "birth_date": "1990-04-12", "countries": ["np"], "aliases": ["A. Sharma"], "nationalities": ["np"], "positions": [], "gender": "male", "addresses": [], "id_numbers": ["PA1234567"], "url": "" } ], "possible_matches": [], "informational_matches": [], "lists_checked": ["adverse_media", "pep", "poi", "sanctions"], "provider": "watchlist", "dataset": "default", "algorithm": "logic-v1", "threshold": 0.7 } ``` | `risk_level` | Meaning | | --- | --- | | `high` | At least one confirmed hit on a blocking list. | | `medium` | Only possible (sub-threshold) matches. | | `low` | Only informational notices (e.g. POI). | | `clear` | Nothing relevant found. | Each match carries the score, the lists it appeared on, and the country / DOB / alias / position metadata an operator needs to adjudicate. Read the result from `GET /v3/session//` or `decision_v3.aml_screenings` on the [decision endpoint](/docs/kyc/overview#reading-module-results). ## How a hit routes: review vs decline A confirmed hit doesn't automatically decline — the outcome depends on the **per-list action** configured on the step. Each screened list carries one of three actions, and the **strongest action among the lists actually hit wins** (a legacy step with no `list_actions` defaults every list to `review`): | Action | Module status | Effect | | --- | --- | --- | | `flag` | `approved` ✅ | Recorded and risk-scored, but non-blocking — the `watchlist_match` warning still feeds the risk engine. | | `review` ⚠️ | `in_review` | Routes to manual review and opens a case. Default for a missing action. | | `decline` ❌ | `declined` | Blocks the session outright. | Possible (sub-threshold) matches only route to review when the step has `review_possible_matches` enabled — otherwise they're carried for the reviewer's context without blocking. Informational notices are always `approved` with a `watchlist_notice` warning. ## Fails closed and common outcomes | Condition | Module status | Warning / reason | | --- | --- | --- | | Confirmed hit on a `decline`-action list | `declined` | `watchlist_match`, `_match` | | Confirmed hit on a `review`-action list | `in_review` | `watchlist_match`, `_match` | | Confirmed hit on a `flag`-action list | `approved` | `watchlist_match` (scored, non-blocking) | | Possible match, `review_possible_matches` on | `in_review` | `possible_watchlist_match` | | Only informational (POI) notices | `approved` | `watchlist_notice` | | No identity data to screen | `in_review` | `aml_insufficient_data` | | Screening service unreachable | `in_review` | `aml_screening_unavailable` | | Nothing relevant found | `approved` | — | ## Ongoing vs one-time screening The session screen is one-time, but an AML-screened workflow **enrols the applicant in ongoing monitoring**. On completion the workflow's AML config (lists, threshold, `review_possible_matches`, `ongoing_monitoring`) is copied onto the identity and a `next_rescreen_at` is scheduled. A daily sweep re-screens every identity whose `next_rescreen_at` has come due, against the same lists and threshold the tenant configured. A **new hit flips the identity to `flagged` with `aml_status: high`** so they surface in the console identities list — this is how a customer who becomes sanctioned *after* onboarding still gets caught. Clears simply refresh the screening timestamps. Turning `ongoing_monitoring` off de-enrols the identity on the next sweep. If the screening service is down during a sweep, the sweep aborts without advancing `next_rescreen_at`, so the same identities are retried on the next run. ## Standalone AML check Screen a name ad hoc with the same engine, no session attached: The response is the same shape as the module result above. An unreachable engine returns `503 screening_unavailable`. See [Standalone tools](/docs/kyc/tools#aml-check) for the full tools surface and rate limits. ## Custom lists Beyond the global datasets, tenants maintain their own [risk lists](/docs/api-reference/lists) via the Partner API (`/api/v1/lists/`) — `phone`, `email` and `identifier` lists that flows screen against, with per-list actions (route to review, open a case, or block). Values are normalised on write (phone → digits only, email → lowercased, identifier → trimmed). The document number extracted by ID verification is screened against `identifier`-type lists independently of the OpenSanctions screen. ## Common pitfalls ## FAQ ## Related --- ## Database validation **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/database-validation **Description:** Cross-check identity data against an external authoritative database — requires a provider integration, and fails closed to review until one is wired. The `DATABASE_VALIDATION` module is designed to cross-check an applicant's extracted identity data (name, document number, date of birth) against an **external authoritative database** — for example a government identity registry or a credit-bureau identity file. It confirms that the person the document describes actually exists in a system of record, complementing the document-authenticity checks that verify the *document* itself. ## Current behaviour Because it has no real check to perform, the module is **hidden from the workflow builder**. It can still appear in a legacy or imported workflow, which is exactly why the engine fails safe: rather than wedging the session or faking a pass, it hands the decision to a human operator so nothing silently slips through. This is the same fail-safe treatment applied to [`NFC_VERIFICATION`](/docs/kyc/nfc) on a non-native host — both are members of the engine's `UNIMPLEMENTED_MODULES` set. At session-completion time the engine sweeps any still-pending result for these features and finalises it to review with a clear reason code instead of a silent approval. ## Fail-closed vs fail-open | Approach | What it does | Used here? | | --- | --- | --- | | **Fail closed (review)** | No provider → route to a human operator with a clear reason code. | ✅ This is the behaviour. | | **Fail open (approve)** | No provider → silently pass the step. | ❌ Never — that's a fabricated success. | | **Hard error (block)** | No provider → error the session. | ❌ Never — it would strand legitimate applicants. | ## What you get When the module runs it produces an `in_review` result: ```json { "feature": "DATABASE_VALIDATION", "status": "in_review", "score": null, "warnings": ["database_validation_not_available"], "data": { "reason": "database_validation_not_available", "engine": "unavailable" } } ``` Alongside it, a timeline entry is logged (`module.unavailable`, "DATABASE_VALIDATION has no automated check — held for review"). The session's overall status therefore lands at review whenever this module is present and unresolved. An operator adjudicates it in **Console → Verifications**. Read the result the same way as any module: ## When you'd enable it Turn this module on only once an external provider is integrated for your deployment — a government identity registry, a national ID verification API, or a bureau identity file appropriate to your market. Until that wiring exists, enabling the step only adds a guaranteed review to every session, so it's off (and hidden) by default. ## FAQ ## Related --- ## Document verification **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/document **Description:** OCR, MRZ and barcode extraction, authenticity and tamper checks, and the country/type document matrix. The `ID_VERIFICATION` module authenticates a government-issued identity document, extracts its fields, and outputs the document portrait so [face match](/docs/kyc/face-match) can compare it to the selfie. It runs inside the [hosted flow](/docs/hosted-flow): the applicant photographs the document (front, and back when required), and the engine does OCR, machine-readable-zone parsing, and authenticity analysis server-side. ## Supported document families The engine treats these families as first-class types, each with its own classifier label and field template: | Type | Notes | | --- | --- | | `passport` | Photo page is read via the MRZ (TD3) plus visual OCR. | | `id_card` / national ID | Generic card family. Legacy `id_card` accepts any card-family document. | | `citizenship` | Nepali citizenship certificate. | | `voter_id` | Voter ID card. | | `pan` (PAN card) | Recovered by post-classification refinement when needed. | | `drivers_license` | Often single-sided in some jurisdictions (see `require_back_side`). | | `residence_permit` | Card family. | | `disability` | No trained template — runs a **generic** extraction (auto-detect fields + best-effort OCR text, clearly flagged). | ## What the engine extracts Fields are read from the MRZ where present and from visual OCR otherwise: - Full name (and first / last where the document splits them) - Document number - Date of birth, date of expiry - Nationality / issuing country - The document portrait (for face match) - On non-Latin documents, a romanised / English read of the same fields (`translated_fields`) MRZ parsing (passports and other TD1/TD3 documents) cross-checks the printed data against the MRZ check digits. Barcode / PDF417 data, where a document carries it, is decoded for the same fields. ## Authenticity and tamper checks Beyond reading the document, the engine runs forgery / tamper analysis (EXIF-rule checks plus TruFor tamper localisation on the hosted forensics engine). Two config keys control the outcome: - `forgery_check` — enable tamper analysis. - `forgery_action` — `hold` (default) routes a suspected forgery to **manual review** and gates the applicant at the document step (they see a "couldn't verify this document" screen and, if `allow_bypass` is on, a Continue-anyway option). `flag` does **not** gate the applicant — the document step can still approve so they proceed normally — but silently force-holds the whole *session* for review (`session.metadata.background_review`) so an operator sees the finding without tipping off a fraudster mid-flow. ## Restricting which documents are accepted ## How scoring and decisioning works `ID_VERIFICATION` never outputs a single numeric "pass score" the way a credit-risk model might — the terminal `status` is derived from a set of boolean checks plus a `quality_score`, evaluated in order: ## Common decline / review reasons | Reason code | Outcome | What triggers it | | --- | --- | --- | | `ID_DOCUMENT_IN_BLOCKLIST` | Declined | The document number matches a `block`-action entry on a typed risk list | | Type/structural mismatch | Declined | The captured image doesn't match the declared document type or fails basic structural checks | | Expired (with `allow_expired: false`) | Declined | Expiry date has passed and the workflow doesn't accept expired documents | | `document_forgery_suspected` / `exif_metadata_suspicious` | Review (or session-held, under `flag`) | Tamper analysis (TruFor / EXIF rules) flagged the capture | | `mrz_checksum_failed` / `no_mrz_detected` / `mrz_ocr_mismatch` | Review | MRZ check digits don't reconcile, or the MRZ is missing/unreadable, or MRZ and visual OCR disagree | | `no_portrait_detected` | Review | No usable face crop found on the document — blocks downstream face match | | `back_side_missing` | Review | A two-sided document type was captured front-only | | `document_type_not_allowed` | Review | The presented type isn't in `allowed_document_types` / the country matrix | | `watchlist_hit` | Review | The document number matched a `review`-action typed-list entry | | `document_unreadable` | Review | No identity fields and no MRZ recovered at all | | Quality floor not met | Review | Capture quality too low to trust the read either way | ## Risk-list screening The extracted document number is screened against the tenant's [risk lists](/docs/kyc/aml#custom-lists) (`identifier` type). A matching list entry can route the session to review, open a case, or block it depending on the list's configured action. ## Result shape `ID_VERIFICATION` returns the extracted `fields`, the `detected_type` and its `confidence`, an `is_document_valid` flag, and whether a portrait was found. Retrieve it from `GET /v3/session//` (full result) or in summary form from `decision_v3.id_verifications` on the [decision endpoint](/docs/kyc/overview#reading-module-results). ```json { "feature": "ID_VERIFICATION", "status": "approved", "score": 91.0, "warnings": [], "data": { "fields": { "full_name": "AARAV SHARMA", "document_number": "PA1234567", "date_of_birth": "1990-04-12", "expiry_date": "2032-01-10", "nationality": "NP", "issuing_state": "NP" }, "translated_fields": {}, "detected_type": "passport", "confidence": 0.97, "is_document_valid": true, "has_portrait": true } } ``` Fetch it directly: ## Edge cases ## Related --- ## Face match **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/face-match **Description:** 1:1 selfie-to-document biometric comparison, plus 1:N face search across your enrolled identities. The `FACE_MATCH` module performs a **1:1 biometric comparison** of the document portrait (from [ID verification](/docs/kyc/document) or the [NFC chip](/docs/kyc/nfc)) against the live selfie captured during [liveness](/docs/kyc/liveness). It answers "is this the same person as the document?" — liveness answers "is this a live person?"; a real workflow runs both. ## How the decision is made The engine compares face embeddings and returns a `matched` flag together with a calibrated `similarity` percentage: - The model's own **cosine decision threshold** is authoritative for `matched`. Genuine-match cosines sit well below 100% (AdaFace ~0.3–0.7), so the engine gates on the model's calibrated threshold rather than a naive similarity floor — a hard percentage floor would wrongly reject true matches. - `matched` true → `approved`. `matched` false → `declined`. - A **capture problem** (quality gate tripped, or no face found in the selfie) is retryable — the applicant is asked to retake. Only after exhausted retries does a capture problem escalate to **review** rather than a decline. ## Review bands (`min_similarity`) A tenant can set a `min_similarity` floor. It is a **review band, not a decline**: the biometric model decides same-person vs impostor; the floor only escalates a weak-but-genuine match to an operator instead of auto-approving it (`similarity_below_minimum`). Precedence: 1. A **per-document-type** rule — `ID_VERIFICATION` config `document_rules[].min_similarity` — wins, taken from the document *this* node matched. Licence portraits and passport photo pages warrant different bands. 2. Otherwise the **workflow-wide** `min_similarity` on the face-match step applies. In a repeated-ID graph, the per-type band comes from the document wired to *this* face-match node, not the globally-latest upload — so a sibling document's band never leaks across. ## How scoring and decisioning works ## Common decline / review reasons | Reason code | Outcome | What triggers it | | --- | --- | --- | | `face_mismatch` | Declined | Cosine similarity falls below the model's calibrated match threshold — the selfie doesn't match the document portrait | | `similarity_below_minimum` | Review | A genuine match (`matched: true`) whose similarity is still below the tenant's `min_similarity` floor for this document type | | `no_face_detected` (selfie) | Retryable → review after exhausted attempts | No usable face found in the selfie | | Quality-gate rejection | Retryable → review after exhausted attempts | Blur, poor lighting, extreme angle, or multiple faces in the selfie | | Blocklist face warning | Adds a warning, doesn't change `matched` | The selfie or document face matches a known-fraud entry in the tenant's face blocklist | ## 1:N face search (duplicate detection) Beyond the 1:1 module, the engine can rank *your tenant's* enrolled identities whose selfie matches a probe photo — the same embedding engine, used for duplicate-account detection. It runs automatically during a session (a duplicate face can flag the session for review) and is also exposed as the stateless [`/v3/tools/face-search/`](/docs/kyc/tools#face-search) tool: `threshold` clamps to 0.2–0.95 (default 0.35), `top_k` ≤ 50 (default 12). It returns ranked `matches` with each identity's `individual_id`, `external_user_id`, `status`, `score` and thumbnail. Use it to run a new applicant's selfie against the existing population before approving them. ## Result shape `FACE_MATCH` returns `matched`, the `similarity` score, `source` (`DOCUMENT`) and `target` (`SELFIE`), and any `warnings` (e.g. `face_mismatch`, `similarity_below_minimum`). Read it from `GET /v3/session//` or `decision_v3.face_matches` on the [decision endpoint](/docs/kyc/overview#reading-module-results). ```json { "feature": "FACE_MATCH", "status": "approved", "score": 78.4, "warnings": [], "data": { "matched": true, "similarity": 78.4, "source": "DOCUMENT", "target": "SELFIE" } } ``` ## FAQ ## Related --- ## Liveness **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/liveness **Description:** Passive, colour-flash and 3D-action liveness — the server enforces the configured method and it cannot be downgraded client-side. The `LIVENESS` module proves there is a **real, present human** in front of the camera — defeating printed photos, screen replays, masks and deepfakes. It is a single module with a selectable **method**, configured on the workflow step (`config.method`). Liveness confirms *a* live human; it does not confirm *the right* human — that is [Face Match](/docs/kyc/face-match), which nearly every real workflow runs alongside it. ## Methods | Method (`config.method`) | Style | How it works | | --- | --- | --- | | `PASSIVE` | No user action | Analyses facial attributes on a single frame (or a short burst with a static-injection motion check when the client supplies extra frames). | | `PASSIVE_VIDEO` | No user action | Records a short selfie clip scored on the hosted video liveness engine; depth fusion defeats photos and replays. | | `FLASHING` | Colour flash | Projects a random sequence of colours (red / green / blue / white) and checks the reflections off a real face. | | `3D_ACTION` | Challenge–response | The applicant performs a random sequence of actions (turn left, turn right, look up, look down, smile). | | `GESTURE` | Challenge–response | The applicant performs random hand gestures on camera. | Passive methods run through the standard capture step. The active methods (`FLASHING`, `3D_ACTION`, `GESTURE`) issue a **server-generated, single-use challenge** — the applicant's client fetches the random sequence and submits the captured response to a dedicated verify endpoint. Because the sequence is random and server-issued, a pre-recorded replay of an earlier session fails. ## Server enforcement The configured method is enforced on the server — it **cannot be downgraded or skipped from the client**: - The passive handler rejects a submission whose configured method is an active one (`active_liveness_required`) — active liveness has to arrive through the challenge/verify path, not as a passive frame. - If a workflow requests a method the tenant is not entitled to, the engine **falls back to `PASSIVE`** rather than dropping liveness entirely — a downgrade to no-liveness is never the outcome. - A required liveness module that never produced a terminal result forces the session to **review**, not approval — a skipped selfie can't pass as verified. ## Choosing a method | Method | Applicant friction | Defeats printed photo | Defeats screen replay | Defeats injection/virtual-camera attacks | Best for | | --- | --- | --- | --- | --- | --- | | `PASSIVE` | None | ✅ | ⚠️ Partial | ⚠️ Partial (burst mode) | Highest-conversion onboarding, low fraud pressure | | `PASSIVE_VIDEO` | Very low (short clip) | ✅ | ✅ | ⚠️ Partial | Slightly stronger passive option with near-zero UX cost | | `FLASHING` | Low (few seconds, no action) | ✅ | ✅ | ✅ | Middle ground — strong against printed photos and screens with minimal cognitive load | | `3D_ACTION` | Medium (follow prompts) | ✅ | ✅ | ✅ | Account takeover, high-value accounts, injection-attack risk | | `GESTURE` | Medium (follow prompts) | ✅ | ✅ | ✅ | Same as `3D_ACTION`; alternative UX for accessibility or camera-angle constraints | - **`PASSIVE` / `PASSIVE_VIDEO`** for the highest conversion — the applicant barely notices it happened. - **`3D_ACTION` / `GESTURE`** when fraud pressure is real: account takeover, high-value accounts, injection-attack risk. The random challenge defeats replays. - **`FLASHING`** as a middle ground — strong against printed photos and screen replays with almost no cognitive load. ## How scoring and decisioning works `PASSIVE` liveness produces a numeric `score` (0–100) that is compared against a per-workflow `min_score` (default **50** if the workflow doesn't set one). The terminal status is layered on top of that comparison, not just the raw number: ## Common decline / review reasons | Reason code | Outcome | What triggers it | | --- | --- | --- | | `active_liveness_required` | Rejected | A passive frame was submitted for a workflow configured with an active method | | `spoof_detected` / `screen_replay_suspected` | Declined | Active-method challenge detected a photo, mask, or screen replay — terminal, not retryable | | `sequence_altered` / `timing_anomaly` | Declined | The challenge response doesn't match the server-issued random sequence, or timing is inconsistent with a live capture | | `static_capture_suspected` | Review | Burst frames show near-zero motion between them (possible virtual-camera injection) | | `liveness_below_threshold` | Declined | Passive score fell below `min_score` on a clean, in-quality capture | | Capture-quality issues (blur, lighting, no face, multiple faces) | Retryable → review after exhausted attempts | Selfie couldn't be scored reliably | | Liveness engine unreachable | Review | Fail-closed — never silently approved | ## Sunglasses gate By default liveness disallows sunglasses (`config.disallow_sunglasses: true`) — an ONNX classifier flags them so the applicant is asked to remove them, keeping the selfie usable for face match. ## Camera requirements Liveness needs camera access. In the [web SDK](/docs/sdks/web), modal (iframe) mode requires the `camera; microphone` permissions policy (the SDK sets it and the flow serves it). On mobile browsers, camera in a cross-origin iframe is unreliable, which is why `mode: "auto"` opens a popup there. Behind a strict CSP, allow your Obsidian flow origin in `frame-src`, or use `popup` / `redirect`. ## Result shape `LIVENESS` returns a `status` and the `method` that ran, plus engine metadata. The raw liveness score threshold is an internal detail; the caller-facing signal is the terminal `status`. Read it from `GET /v3/session//` or `decision_v3.liveness_checks` on the [decision endpoint](/docs/kyc/overview#reading-module-results). ```json { "feature": "LIVENESS", "status": "approved", "score": 87.5, "warnings": [], "data": { "method": "PASSIVE", "engine": "thirdfactor-hosted", "age_gender": { "gender": "male" } } } ``` ## FAQ ## Related --- ## NFC verification **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/nfc **Description:** Read the ePassport / eID chip on the device, validate it on the server — BAC/PACE, DG1/DG2/SOD and passive authentication. The `NFC_VERIFICATION` module reads the contactless chip embedded in an e-passport or biometric ID and validates its cryptographically-signed contents. Chip data is signed by the issuing authority, so a successful read is far stronger evidence than an OCR'd photo — and it yields a high-quality chip portrait for [face match](/docs/kyc/face-match). Reading the chip needs the **device NFC radio**, which a WebView cannot reach. So NFC is a **native-SDK-only** capability: the [iOS, Android and Flutter SDKs](/docs/sdks/nfc) read the chip, and the Obsidian backend does the cryptography. The pattern is **read on device, validate on server** — the SDK returns raw data groups, the backend performs passive authentication. ## Why NFC beats OCR OCR reads *printed* ink and a *photographed* portrait — both of which a forger can alter on a fake booklet. NFC reads the chip the issuing authority personalised, whose contents are protected by a signature (the SOD) that a tamper would break: | | OCR (document capture) | NFC (chip read) | | --- | --- | --- | | Source of truth | Printed page + photo | Issuer-signed chip | | Portrait quality | Photo of a photo | Full-resolution DG2 image ✅ | | Tamper evidence | Forensics heuristics ⚠️ | Cryptographic hash check ✅ | | Proof of issuer | None | CSCA trust chain (when configured) ✅ | | Works in a WebView | ✅ | ❌ (needs native radio) | NFC doesn't replace document capture — the OCR'd MRZ is what **derives the chip key** and what the chip is cross-checked against. They run in sequence. ## End-to-end flow The SDKs also expose a **standalone** `readNfc(...)` API for reading a chip outside the hosted flow. Per-SDK setup (iOS entitlements, Android/Flutter permissions, deps) is in the [NFC SDK guide](/docs/sdks/nfc). Under the hood: iOS **NFCPassportReader**, Android **JMRTD + BouncyCastle**, Flutter **dmrtd**. ## What the SDK sends the server The native read returns three base64 blobs, which the client posts unchanged: ```json { "sod_b64": " ## Decision mapping | Condition | `NFC_VERIFICATION` result | | --- | --- | | A data-group hash doesn't match the SOD (tamper) | `declined` ❌ | | SOD unparseable / no data groups to check | `in_review` ⚠️ | | MRZ read-back mismatch (chip vs scanned document) | `in_review` ⚠️ | | Integrity OK, no CSCA masterlist configured | `in_review` ⚠️ | | Integrity OK **and** CSCA chain verified | `approved` ✅ | | Host has no NFC capability (web fallback) | `in_review` (`nfc_not_available`) ⚠️ | ## Common failure reasons If the native read fails, the SDK surfaces a stable code the flow can act on — the read never silently succeeds: | Code | What happened | What to do | | --- | --- | --- | | `wrong_key` | The BAC/PACE key derived from the MRZ (or CAN) didn't open the chip — usually a mistyped or mis-OCR'd document number, DOB or expiry. | Re-confirm the MRZ fields and retry; offer manual entry. | | `tag_lost` | The phone lost contact with the chip mid-read (moved too soon). | Ask the applicant to hold the phone still against the document. | | `nfc_disabled` | NFC is off (or unavailable) on the device. | Prompt to enable NFC, or fall back to document-only. | | `user_cancelled` | The applicant dismissed the native scan sheet. | Re-prompt or let them skip to review. | | `timeout` | No chip detected within the read window. | Reposition the phone over the chip (passports: rear cover; ID cards: centre) and retry. | | Unsupported document | The document has no readable chip (a non-biometric ID or an unsupported booklet). | Fall through to the document-only path; the module reviews rather than blocks. | A `wrong_key` after a good OCR read most often means the **MRZ was misread** — the chip key is derived from exactly those three fields, so a single wrong digit fails key derivation. Let the applicant correct the prefilled MRZ before retrying. ## Device support NFC reading depends on the device having an NFC radio and the OS exposing it to apps: modern iPhones (via Core NFC / NFCPassportReader) and most Android phones with NFC hardware. Web browsers expose no passport-chip API, which is why the hosted flow only offers the NFC step on native hosts that advertise the `nfc` capability and otherwise falls through to the server-side review. ## FAQ ## Related --- ## KYC modules **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/overview **Description:** The module catalog for ThirdFactor Verify — what each module does, where it runs, and how to pick the right set for your risk tier. Obsidian ships **ThirdFactor Verify**, a built-in, standards-based KYC engine: document verification, passive / colour-flash / 3D-action / gesture liveness, 1:1 face match, AML screening, proof of address, document AI, NFC chip reads, and agent video calls. No external verification SDK and no third-party verification credentials are involved — heavy inference runs on your own deployment's inference service. Modules are the building blocks of a **workflow** (built in **Console → Config → KYC Workflows**). A [session](/docs/hosted-flow) runs one workflow's enabled modules once. Each module has its own configuration, its own result on the session, and its own credit rate (set per tenant/module — there is no fixed public price). ## Where each module runs Not every module is applicant-facing. Three execution surfaces exist: - **Hosted flow** — the applicant captures evidence inside the hosted WebView / iframe (document photos, selfie, OTP). Every client SDK supports these identically. - **Server-side** — no applicant capture; the engine runs the check in the background from data already on the session. - **Native-only** — needs a device radio the WebView cannot reach (NFC). Present in the iOS / Android / Flutter SDKs; on web it falls back to a server-side review. ## Capability comparison Every module maps to a `ModuleResult` with a `status` of `approved`, `declined`, `in_review`, or `not_started`. The table below adds what each module typically declines or reviews on, so you can see at a glance which checks carry real fraud-detection weight versus which ones just capture data. | Module (feature key) | What it checks | Runs | Typical outcomes | Common failure reasons | | --- | --- | --- | --- | --- | | `ID_VERIFICATION` | Document authenticity, OCR/MRZ/barcode field extraction, tamper analysis | Hosted flow | approved / declined / review | Forgery suspected, MRZ↔visual mismatch, unreadable capture, expired (if disallowed), document type not permitted | | `NFC_VERIFICATION` | Cryptographic chip integrity (BAC/PACE, DG1/DG2/SOD, passive authentication) | **Native-only** (web → review) | approved / review / declined | No CSCA masterlist configured (caps at review), chip data tampered (decline), host has no NFC radio | | `LIVENESS` | Real, present human vs. photo/replay/mask/deepfake | Hosted flow | approved / declined / review | Spoof/replay detected (active methods), capture-quality retries exhausted, static-capture (injection) suspected | | `FACE_MATCH` | 1:1 biometric comparison, document portrait vs. selfie | Hosted flow | approved / declined / review | Impostor (cosine below model threshold), no face detected after retries, similarity below tenant's review floor | | `AGE_ESTIMATION` | Biometric age estimate from the selfie | Server-side | approved / review / declined | Estimated age under the configured minimum (decline or review depending on `decline_underage`) | | `AML_SCREENING` | Sanctions / PEP / adverse-media / POI watchlist screening | Server-side | approved / review / declined | Confirmed hit on a `decline`-action list, hit on a `review`-action list, insufficient identity data to screen | | `PROOF_OF_ADDRESS` | Address-document classification, freshness, address extraction | Hosted flow | approved / review | Stale document (older than `max_age_days`), unclassifiable document, no address found | | `DOCUMENT_AI` | Generic OCR/classification of an arbitrary custom document | Hosted flow | approved / review | Unreadable capture, configured fields not found | | `IP_ANALYSIS` | VPN / proxy / Tor / datacenter and abuse signals | Server-side | approved / review / declined | Tor exit node (decline), VPN detected (decline), elevated abuse score (review) | | `DATABASE_VALIDATION` | Cross-check against an external authoritative database | Needs an external provider → review | review only | No provider wired up — always routes to review | | `EMAIL_VERIFICATION` | One-time-code ownership check | Hosted flow | approved / in-progress / review | Watchlisted email address held for review | | `PHONE_VERIFICATION` | SMS/voice OTP ownership check | Hosted flow | approved / in-progress / review | Watchlisted phone number held for review | | `QUESTIONNAIRE` | Structured answers via a console-built form | Hosted flow | approved | — (informational capture, doesn't gate) | | `CONFIRM_DETAILS` | Applicant corrects OCR'd fields before continuing | Hosted flow | approved | — | | `INFORMATION` | Plain instruction/consent screen | Hosted flow | approved | — | | `PRODUCT_SELECTION` | Applicant picks one of your products | Hosted flow | approved | — | | `VIDEO_CALL` | Agent-attended live video verification, server-recorded | Hosted flow (agent-attended) | approved / declined / review | Agent-driven; depends on the call outcome the operator records | ## Which modules do I need? There is no universal answer — it depends on your regulatory obligations and fraud risk — but most integrations converge on one of three tiers. Use this as a starting point, then tune per jurisdiction with your compliance team. ## Reading module results Per-module outcomes come back on `GET /v3/session//` and, in summary form, in `decision_v3` on `GET /v3/session//decision/`. Each result carries a `status` (`approved` / `declined` / `in_review` / `not_started`), an optional `score`, `warnings`, and a `data` blob. The evidence checklist (`/checklist/`) tells you what was captured; the timeline (`/logs/`) tells you what happened and when. ```json { "session_id": "6f0e2b7a-…", "status": "in_review", "decision": "review", "decision_v3": { "id_verifications": [{ "node_id": "ID_VERIFICATION", "status": "approved", "score": 92.0, "warnings": [], "data": { "detected_type": "passport" } }], "liveness_checks": [{ "node_id": "LIVENESS", "status": "approved", "score": 87.5, "warnings": [], "data": { "method": "PASSIVE" } }], "face_matches": [{ "node_id": "FACE_MATCH", "status": "in_review", "score": 61.2, "warnings": ["similarity_below_minimum"], "data": {} }], "aml_screenings": [{ "node_id": "AML_SCREENING", "status": "approved", "score": 100, "warnings": [], "data": { "risk_level": "clear" } }], "nfc_verifications": [], "poa_verifications": [], "reviews": [] }, "reason": "similarity_below_minimum" } ``` Every `decision_v3` array key maps 1:1 to a feature key: `id_verifications`, `liveness_checks`, `face_matches`, `aml_screenings`, `nfc_verifications`, `poa_verifications`, `phone_verifications`, `email_verifications`, `ip_analyses`, `database_validations`, `questionnaire_responses`. Each entry carries `node_id` (so you can tell repeated instances of the same module apart in a graph workflow), `status`, `score`, `warnings`, and `data`. ## Standalone tools Several module engines are also exposed as stateless one-shot utilities under `/v3/tools/*` — AML check, face search, signature verify/search/enroll, document extract. They create no session and carry no audit trail. See [Standalone tools](/docs/kyc/tools). ## Comparing execution modes | | Hosted-flow module | Server-side module | Standalone tool | | --- | --- | --- | --- | | Applicant capture | ✅ Yes | ❌ No | ❌ No | | Creates a session | ✅ Yes | ✅ Yes | ❌ No | | Audit trail / evidence checklist | ✅ Yes | ✅ Yes | ❌ No | | Contributes to `decision_v3` | ✅ Yes | ✅ Yes | ❌ No (returns its own response) | | Good for a compliance decision | ✅ Yes | ✅ Yes | ⚠️ Only as enrichment/triage input | | Typical use | Onboarding a new applicant | Background checks during onboarding | Ad-hoc lookups, back-office tooling | ## FAQ ## Related --- ## Proof of address **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/proof-of-address **Description:** Capture an address document, classify it, check its freshness, and extract the applicant's address onto the identity. The `PROOF_OF_ADDRESS` module captures a document that evidences where the applicant lives — a utility bill, bank statement, tax letter and similar — classifies it, checks it is recent enough, and extracts the address onto the applicant's identity. It runs in the [hosted flow](/docs/hosted-flow) as a document-capture step and analyses the document's **OCR text** (not its identity structure), so it works on free-form letters and bills rather than templated ID documents. ## What it does ## Accepted document kinds Classification keys off keywords in the OCR text. Restrict which kinds count with the `accepted_documents` config; leave it unset to accept all of them: | `document_kind` | Recognised from (examples) | | --- | --- | | `utility_bill` | Electricity, water, gas, internet/broadband, telecom bills (incl. Nepali NEA / KUKL issuers). | | `bank_statement` | Bank statements, account summaries, IBAN / balance / transaction text. | | `tax_letter` | Tax / revenue authority assessments (HMRC, IRS, Inland Revenue). | | `lease` | Tenancy / rental agreements, landlord–tenant documents. | | `government_letter` | Ministry, municipality, council or other official letters. | | `other` | No recognised kind — routes to review (`unrecognised_document_kind`). | ## Configuration ## Nepali utility bills Two Nepali utility bills are read natively via hosted OCR into structured fields: | Bill | Issuer | | --- | --- | | NEA electricity bill | Nepal Electricity Authority (NEA) | | KUKL water bill | Kathmandu Upatyaka Khanepani Limited (KUKL) | A recognised bill is authoritative for the issuer and address, and its Bikram-Sambat bill date is converted to AD for the freshness check (a date the generic date parser can't read on its own). This enrichment is **fail-soft**: an unreachable service or an unrecognised document simply falls back to the generic document heuristics — nothing breaks. ## Decisioning and outcomes The status is derived from the classification, the parsed date and the extracted address — `PROOF_OF_ADDRESS` never silently approves: | Outcome | When | Notes | | --- | --- | --- | | **Retry** | OCR yielded almost nothing (bad capture). | Applicant is asked to retake; exhausted retries escalate to review (`retries_exhausted`). | | **Review** ⚠️ | Readable but unverifiable — unrecognised kind, low classification confidence, no parseable date, a stale date, or no address found. | Held for an operator. | | **Approved** ✅ | Recognised, accepted kind with a fresh document date and an extracted address. | Address is written to the identity. | The result carries the classified `document_kind`, `confidence`, the parsed `document_date` and `age_days`, the configured `max_age_days`, the extracted `address`, and any `warnings`: ```json { "feature": "PROOF_OF_ADDRESS", "status": "approved", "score": 80.0, "warnings": [], "data": { "document_id": "b1f2…", "engine": "poa", "document_kind": "utility_bill", "confidence": 0.8, "document_date": "2026-06-02", "age_days": 40, "max_age_days": 90, "address": "12 Ward, Baneshwor Marg, Kathmandu", "warnings": [] } } ``` Read it from `GET /v3/session//`: ## Warning reference | Warning | Meaning | | --- | --- | | `unreadable_document` | OCR yielded almost nothing — retryable. | | `retries_exhausted` | Retake budget spent; escalated to review. | | `unrecognised_document_kind` | Couldn't confidently classify the kind (or it isn't in `accepted_documents`). | | `no_document_date` | No plausible document date parsed. | | `document_too_old` | The document date exceeds `max_age_days`. | | `no_address_found` | No address block could be extracted. | ## Common pitfalls ## FAQ ## Related --- ## Standalone tools **URL:** https://docs.v3.thirdfactor.ai/docs/kyc/tools **Description:** One-shot utilities — AML, face search, signature verify/search/enroll, document extract — with full request/response JSON and no session attached. The `/v3/tools/*` endpoints mirror the console's Manual Tools. They are **stateless**: none of them create or mutate a [verification session](/docs/hosted-flow). Each is tenant-scoped by your API key (`x-api-key`), and image arguments are base64 strings — raw or a `data:` URI. ## Conventions - **Auth**: `x-api-key: ` (or `X-API-Key`). See [Authentication](/docs/authentication). - **Images**: base64 (optionally a `data:` URI), **JPEG or PNG only**, **max 8 MB**. Other formats (HEIC, WEBP, GIF, …) return `415 unsupported_image_type`; a missing/undecodable image returns `400 _required` / `400 invalid_`. - **Rate limit**: the tools share a per-tenant bucket of **120 requests/minute** (`kyc_v3_tools`). Over-limit requests get `429`. - **Entitlement + billing**: each chargeable tool is gated by your plan. A disabled feature returns `403 feature_not_enabled` (with a `features` array); an empty balance returns `402 insufficient_credits`. A successful call debits one use. - **Errors**: `/v3` tool errors return `{ "detail": "" }` with the HTTP status carrying the machine signal. Branch on status + the stable code, not the message. | Endpoint | Purpose | Key body fields | Chargeable | | --- | --- | --- | --- | | `POST /v3/tools/aml-check/` | Sanctions/PEP/adverse-media screening | `full_name \| first_name+last_name`, `date_of_birth?`, `nationality?`, `country?`, `document_number?`, `lists?`, `match_threshold?` | ✅ | | `POST /v3/tools/face-search/` | 1:N face search over enrolled identities | `image_base64`, `threshold?`, `top_k?` | ✅ | | `POST /v3/tools/signature-verify/` | 1:1 signature comparison | `reference_base64`, `probe_base64`, `threshold?` | ✅ | | `POST /v3/tools/signature-search/` | 1:N signature search (beta) | `image_base64`, `threshold?`, `top_k?` | ✅ | | `POST /v3/tools/signature-enroll/` | Add a signature specimen to an identity | `individual_id \| external_user_id`, `image_base64`, `label?` | — | | `POST /v3/tools/document-extract/` | Identity-document OCR | `image_base64`, `document_type?` | — | ## AML check Ad-hoc watchlist screening for a name — the same engine as the [`AML_SCREENING`](/docs/kyc/aml) module. A name is the minimum useful selector; every other field narrows the candidate set. ```json Response { "risk_level": "medium", "matches": [], "possible_matches": [ { "entity_id": "NK-9x2", "name": "Aarav Sharma", "schema": "Person", "score": 0.63, "match": false, "lists": ["pep"], "topics": ["role.pep"], "datasets": ["everypolitician"], "birth_date": "", "countries": ["np"], "aliases": [], "nationalities": ["np"], "positions": ["Member of Parliament"], "gender": "male", "addresses": [], "id_numbers": [], "url": "" } ], "informational_matches": [], "lists_checked": ["pep", "sanctions"], "provider": "watchlist", "dataset": "default", "algorithm": "logic-v1", "threshold": 0.7 } ``` `risk_level` is `high` / `medium` / `low` / `clear`. A query with no name returns `400 name_required`; an unreachable engine returns `503 screening_unavailable`. Full field reference, list topics, scoring tiers and the corroboration rule: [AML screening](/docs/kyc/aml#standalone-aml-check). ## Face search Ranks *your tenant's* enrolled identities whose selfie matches a probe photo — duplicate-account detection. Read-only (nothing is flagged); it mirrors the automatic duplicate check. ```json Response { "matches": [ { "individual_id": "9f3a…", "name": "Aarav Sharma", "external_user_id": "user-123", "status": "active", "kyc_status": "clear", "score": 0.71, "thumb": "https://…/face.jpg" } ], "threshold": 0.35 } ``` `422 no_face_detected` if there's no usable face in the probe. ## Signature verify Compares a probe signature to a reference — structural similarity of stroke geometry (not pen dynamics). Both inputs are cropped to just the signature ink first, so a full document photo or a video frame is matched on its ink, not the surroundings. ```json Response { "score": 0.68, "verdict": "match", "matched": true, "threshold": 0.62 } ``` The `verdict` is derived against the requested threshold: | `verdict` | Condition | | --- | --- | | `match` | score ≥ threshold | | `review` | score ≥ 0.75 × threshold | | `mismatch` | below the review floor | ## Signature search (beta) Ranks your tenant's **enrolled** signature specimens against a probe — a recall aid for operators, not a verdict. The probe is cropped to the signature ink first, the same treatment enrollment applies, so both sides of the search are built from the same kind of crop. ```json Response { "matches": [ { "individual_id": "9f3a…", "name": "Aarav Sharma", "external_user_id": "user-123", "status": "active", "score": 0.64, "thumb": "https://…/signature.png" } ], "threshold": 0.6, "beta": true } ``` `422 no_signature_detected` if the probe has no readable signature. Confirm any hit with the 1:1 [signature verify](#signature-verify). ## Signature enroll Attaches a reference signature to an identity so it becomes searchable by signature search. The input is cropped to the signature ink first, so a full video frame / document photo enrolls a clean specimen. ```json Response { "sample_id": "c2b8…", "individual_id": "9f3a…" } ``` On success returns `201`. An unknown person returns `404 individual_not_found`; an image with no readable signature returns `422 no_signature_detected`. ## Document extract Identity-document OCR — the same read as [`ID_VERIFICATION`](/docs/kyc/document), run standalone. It uses the platform document-OCR service, so it works on any account regardless of the tenant's Lens config. ```json Response { "fields": { "full_name": "AARAV SHARMA", "document_number": "PA1234567", "date_of_birth": "1990-04-12", "expiry_date": "2032-01-10", "nationality": "NP" }, "translated_fields": {}, "detected_type": "passport", "confidence": 0.97, "is_document_valid": true, "has_portrait": true } ``` Template-less documents (e.g. disability cards) run a **generic** extraction — auto-detect fields plus best-effort local OCR text, flagged with `generic_extraction: true` and a `full_text` string. Aadhaar cards, which the classifiers can only call "national id", are relabelled to `aadhaar` from OCR-text signals (display-level only). `503 ocr_not_configured` if the OCR service isn't available; `422 ocr_no_fields` if nothing readable was found. ## Error codes | Status | `detail` code | Meaning | | --- | --- | --- | | 400 | `_required` / `invalid_` | Missing or undecodable image / name. | | 402 | `insufficient_credits` | Tenant balance below the tool's rate. | | 403 | `feature_not_enabled` | The tool isn't on your plan (see `features`). | | 404 | `individual_not_found` | Signature enroll target doesn't exist. | | 415 | `unsupported_image_type` | Not JPEG/PNG (HEIC, WEBP, GIF, …). | | 422 | `no_face_detected` / `no_signature_detected` / `not_a_signature` / `ocr_no_fields` | Nothing usable in the input. | | 429 | — | Over the 120 req/min tools bucket. | | 503 | `screening_unavailable` / `ocr_not_configured` | The backing engine isn't reachable/configured. | ## FAQ ## Related --- ## AI context (llms.txt) **URL:** https://docs.v3.thirdfactor.ai/docs/resources/ai-context **Description:** Feed these docs to ChatGPT, Claude, or any LLM using the machine-readable llms.txt endpoints. These docs are designed to be ingested by large language models. Two endpoints expose the whole site in the [llmstxt.org](https://llmstxt.org) format, so you can point an AI assistant at ThirdFactor Obsidian and get accurate, source-grounded answers instead of guesses. ## Why this matters Generic model knowledge about "a KYC API" is a poor substitute for this product's actual contract — its exact field names, its stable-decision vs. raw-status split, its flat KYC-session webhook shape vs. the nested application envelope. An assistant that hasn't seen these docs will confidently invent plausible-looking endpoints and fields that don't exist. Grounding it in `llms-full.txt` turns "plausible" into "correct." ## The two endpoints Both live at the site root: ```text https:///llms.txt https:///llms-full.txt ``` Every documentation page is authored in MDX with a `title` and `description` in its frontmatter, which is exactly what the index endpoint emits — so the two stay in sync automatically as pages are added. ## Using them with an assistant ## Worked example Here's a full round trip: fetching the corpus, asking a realistic integration question, and what a well-grounded answer looks like. ## Guardrails for AI-generated integration code An assistant grounded in these docs is a fast way to scaffold an integration, but the safety-critical rules still apply — check any generated code against them: - API keys belong on the server only — never in browser or mobile code. - Webhook signatures must be verified over the **raw** request body with a constant-time compare, rejecting stale timestamps. - KYC session webhook payloads are **flat** (`event.vendor_data`, `event.decision`), not nested under `event.session`. - Branch on stable `decision` values, not raw statuses. If an assistant produces code that violates any of these, treat it as a bug — the [go-live checklist](/docs/guides/go-live) is the authoritative list. ## FAQ ## Related --- ## Changelog **URL:** https://docs.v3.thirdfactor.ai/docs/resources/changelog **Description:** Notable changes to the ThirdFactor Obsidian API, SDKs, and hosted flows. Dated, most-recent-first. Breaking changes are called out explicitly; when in doubt, pin `workflow_id` and branch on stable decisions rather than raw statuses. ## How to read this changelog ## 2026-07-11 — ThirdFactor Verify SDKs and NFC The big one: ThirdFactor Verify — a built-in KYC engine — and a set of native client SDKs, so no external verification SDK is required. **ThirdFactor Verify engine (`/v3`).** Document verification, passive / colour-flash / 3D-action liveness, and face match run inside Obsidian. Design workflows in **Console → Config → KYC Workflows**; manage sessions and identities in **Console → Verifications** and **Console → Identities**. - `POST /v3/session/` creates a hosted verification session and returns a `url` on the tenant flow domain. The server enforces the configured liveness method — it cannot be downgraded client-side. - `Idempotency-Key` on session create makes retries safe and never double-charges credits. - Read and manage sessions: `GET /v3/session/`, `GET /v3/session//`, `GET /v3/session//decision/`, plus `update-status`, `delete`, `logs`, `checklist`, `tags`, and `generate-pdf`. - Standalone tools mirror the console Manual Tools: `aml-check`, `face-search`, `signature-verify`, `signature-search` (beta), `signature-enroll`, and `document-extract`. **Client SDKs.** Native SDKs that open the flow and return an advisory result: **NFC.** Native NFC reader modules on iOS, Android, and Flutter read the document chip; a bidirectional bridge exposes capability/command negotiation, and the backend accepts chip data via an NFC flow step. See the [NFC deep-dive](/docs/sdks/nfc). **KYC webhooks.** Terminal sessions emit signed webhook events — `identity.kyc.session.approved`, `.declined`, `.review`, `.expired` — through the same signed delivery pipeline as application events. **Hosted flows.** A hosted session whose flow contains an `sdk` step runs ThirdFactor Verify inline as a single continuous flow — no external verification vendor and no hand-off out of the flow. ## Versioning and stability Obsidian's public contract is layered so that additive changes never break a well-written integration: - **Stable decisions** (`approved`, `rejected` / `declined`, `manual_review` / `in_review`, `in_progress`, `abandoned`) are the long-term contract. New raw statuses can appear for workflow diagnostics without changing this set — see [decisions](/docs/concepts/decisions). - **`workflow_id`** pins which modules a session runs. Leaving it unset uses the tenant default, which an operator can change in the console at any time — pin it explicitly if your integration depends on a specific module set. - **Webhook envelopes** are additive-only: new fields can appear, but existing fields keep their meaning. The `payload_version` on application events exists specifically so a schema change, if one is ever needed, is detectable rather than silent. - **Unknown values are treated conservatively.** An unrecognised terminal status should be handled the same way the SDKs handle it internally: as `in_review`, not as a pass. That one rule keeps forward-compatible code working across additive releases without a docs update on your side. ## FAQ ## Related --- ## Errors **URL:** https://docs.v3.thirdfactor.ai/docs/resources/errors **Description:** HTTP status codes, stable error codes, and the per-surface response body shapes. Every error carries the appropriate HTTP status. **Branch on the HTTP status first**, then treat the stable string code as the machine signal. Do not string-match human-readable messages — they can change without notice. ## HTTP status codes | Status | Meaning | | --- | --- | | `400` | Validation error — a field is missing, malformed, or has an unknown value. | | `401` | Authentication failed — missing or invalid API key. | | `402` | Insufficient credits — the tenant balance is exhausted. | | `403` | Feature not enabled — the tenant is not entitled to the module or tool you called. | | `404` | Not found — the resource does not exist or is not visible to this tenant. | | `409` | Duplicate — the resource already exists (for example, a conflicting idempotent create). | | `429` | Rate limited — too many requests; back off and retry. See [rate limits](/docs/resources/rate-limits). | ## Stable error codes These string codes are stable machine signals. Match on them rather than on message text, since message copy can be reworded at any time: | Code | Typical status | Meaning | | --- | --- | --- | | `insufficient_credits` | `402` | The tenant has no credits left to create a session or run a tool. | | `feature_not_enabled` | `403` | The tenant's plan/config doesn't include this module or standalone tool. | | `not_found` | `404` | The requested resource does not exist for this tenant. | | `invalid_status` | `400` | An `update-status` call passed a value that is not a valid session status. | | `type is immutable` | `400` | A Lists API `PATCH` tried to change a list's `type` after creation. | ## Per-surface body shapes The JSON body currently differs by surface. ## Handling patterns ## Edge cases worth handling explicitly ## Example: robust create handling ## FAQ ## Related --- ## FAQ **URL:** https://docs.v3.thirdfactor.ai/docs/resources/faq **Description:** Answers to the questions integrators ask most — accounts, sessions, credits, privacy, SDKs, NFC, webhooks, testing, and data-subject requests. Straight answers to the questions that come up most while integrating ThirdFactor Obsidian. For deep dives, each answer links to the page that covers it in full; for symptom-first debugging, see [troubleshooting](/docs/resources/troubleshooting). ## Account and API keys ## Sessions and applications ## Pricing and credits ## Data retention and privacy ## SDKs and NFC ## Webhooks ## Sandbox, testing, and going live ## Related --- ## Rate limits **URL:** https://docs.v3.thirdfactor.ai/docs/resources/rate-limits **Description:** Per-tenant /v3 buckets, 429 responses, and how to back off. The ThirdFactor Verify API (`/v3`) is rate-limited **per tenant**. Over-limit requests receive HTTP `429`; back off and retry. Rate limiting exists to keep one tenant's traffic spike — a bulk import, a runaway retry loop, a load test pointed at the wrong environment — from degrading the service for everyone else on the platform. ## Why this matters when you integrate If you've only worked against APIs with a single global limit, `/v3`'s bucket model changes how you should design retries and monitoring: - A burst of `POST /v3/session/` calls during a marketing push won't throttle your webhook-driven reconciliation reads, because they're different buckets. - A batch job hammering `GET /v3/session/` to poll status won't block your ability to create new sessions for other users. - Each call site in your integration should own its own backoff/retry logic sized to the bucket it hits, rather than sharing one global rate limiter. ## The buckets `/v3` traffic is split into independent buckets so heavy work in one area does not starve another: | Bucket | Covers | | --- | --- | | Reads | `GET` endpoints — listing and reading sessions, decisions, logs, checklists, workflows, usage, credits. | | Session creation | `POST /v3/session/`. | | Inference tools | The heavy standalone tools — AML check, face search, signature verify/search, document extract. | Because the buckets are independent, a throttle on session creation does not throttle your reads, and vice versa. Size your retry logic per call site rather than assuming one global limit. ## Handling 429 When you receive `429`, back off with exponential delay and jitter, then retry the same request. Jitter matters as much as the exponential curve: without it, many clients that got throttled at the same moment retry in lockstep and re-trigger the limit together. ## Staying under the limit - **Batch reads.** `GET /v3/session/` returns a paginated array with `?page=` / `?page_size=` (max `200`, default `50`); totals ride in the `X-Total-Count`, `X-Page`, and `X-Page-Size` response headers. Page through it instead of fetching sessions one at a time. - **Cache decisions.** Once a session is terminal (`approved`, `declined`, `expired`, `abandoned`, `kyc_expired`), its decision won't change without an operator action — store it instead of re-reading. - **Reconcile, don't poll.** Sweep only sessions still pending after N minutes, as in the [verify-a-user guide](/docs/guides/verify-a-user). - **Filter server-side.** Use `?status=in_review` (or another filter) to pull only the sessions you actually need to act on, instead of listing everything and filtering client-side. - **Batch list operations.** For risk lists, prefer the [bulk add / replace endpoints](/docs/api-reference/lists) over one `POST` per entry when syncing a nightly export. ## Idempotency and retries Retrying is safe when your request is idempotent: - `POST /v3/session/` — send an `Idempotency-Key` header. A retry with the same key returns the original session and is never charged twice. - `POST /api/v1/sessions/` — the partner JWT's `jti` is the idempotency key. That means a `429`-triggered retry of a create never double-charges credits as long as you keep the key stable across attempts. ## Concurrent requests Firing many requests for different users at once (e.g. a batch import minting one session per row) is normal and expected — the bucket limit is about *rate*, not concurrency. If you're importing at scale: - Pace the loop (a fixed delay or a concurrency limiter) rather than firing every request simultaneously, so a transient burst doesn't trip the session-creation bucket for the whole batch. - Give every row a stable `Idempotency-Key` derived from its source ID, so a crashed import can safely resume from where it left off without double-creating or double-charging already-processed rows. - Watch `credits_remaining` in each create response (or poll `GET /v3/credits/ledger/`) so a large batch doesn't run the tenant dry mid-import and start returning `402` partway through. ## Monitoring Treat repeated `429`s as an operational signal, not just a per-request retry target: - Log the bucket (infer it from the endpoint) alongside each `429` so you can tell whether it's your read loop or your create path that's hot. - Alert if your backoff is regularly exhausting its `max_attempts` — that usually means sustained overuse rather than a one-off burst, and the fix is to reduce call volume (batch, cache, reconcile) rather than to retry harder. ## FAQ ## Related --- ## Troubleshooting **URL:** https://docs.v3.thirdfactor.ai/docs/resources/troubleshooting **Description:** Symptom-first fixes for the problems integrators actually hit — stuck sessions, missing webhooks, signature failures, 401/402 errors, camera and NFC issues, expiry, redirects, and duplicates. This page is organized by **symptom**: find the thing you're seeing, read the likely causes, and work the fix steps. For the reference material behind these fixes, each section links to the page that covers it in full. If you're asking "how does X work?" rather than "why is X broken?", start at [FAQ](/docs/resources/faq); for the raw status/code reference, see [Errors](/docs/resources/errors). ## Session stuck in `not_started` A session was created (`201`) but never progresses — `GET /v3/session//` keeps returning `status: "not_started"`. | Cause | How to confirm | Likelihood | | --- | --- | --- | | The applicant never opened the hosted `url` | The session `logs` have no page-open event | ✅ Most common | | You redirected to the wrong URL (built one yourself instead of using the returned `url`) | Your redirect target isn't the `.../verify/` URL from the create response | ⚠️ Common | | The applicant opened the link but bounced before the first step (blocked pop-up, closed tab) | A page-open event but nothing after it | ⚠️ Common | | You're polling a *different* session than the one the applicant is using (duplicate create) | Two sessions with the same `vendor_data`, one progressing | ⚠️ See [duplicates](#duplicate-sessions-created) | **Fix steps** ## Webhook never arrives A session reached a terminal status (`approved` / `declined`) in the console, but your endpoint never received the event. | Cause | How to confirm | Fix | | --- | --- | --- | | No webhook URL configured for the tenant | Console → Developers → Webhooks shows no destination | Add the URL | | Endpoint not publicly reachable (localhost, private IP, VPN-only) | Delivery attempt logged with a connection error | Expose a public HTTPS URL / use a tunnel | | SSRF guard rejected the destination (loopback / private / link-local) | Delivery marked rejected before any attempt | Use a public, non-private address | | Endpoint returned non-`2xx`, exhausted retries | Delivery log shows repeated `4xx`/`5xx` | Fix the handler, then resend | | Only a non-terminal status was reached (e.g. `in_review`) | Session sits in `in_review` | Wait for operator resolution | **Fix steps** ## Webhook signature verification fails Deliveries arrive, but your `X-Webhook-Signature` check rejects every one. | Cause | How to confirm | Likelihood | | --- | --- | --- | | Verifying re-serialized JSON instead of the raw bytes | You parse the body (global JSON middleware) then re-stringify to verify | ✅ By far the most common | | Wrong secret (mismatched tenant, or rotated since deploy) | Signature never matches even on a known-good raw body | ⚠️ Common | | No secret saved for the tenant — deliveries sent unsigned | The header is **absent**, not wrong | ⚠️ Common | | Server clock skew > 5 minutes | Fresh deliveries fail the staleness check | ⚠️ Occasional | | Non-constant-time compare masking a real match/mismatch | — | ❌ Rare but a security bug regardless | **Fix steps** ## `402 insufficient_credits` on every request Every `POST /v3/session/` (or standalone tool call) returns `402`. | Cause | Fix | | --- | --- | | Tenant credit balance is genuinely exhausted | Top up credits | | Retrying in a loop against an empty balance | Stop retrying `402`; alert your team instead | | Testing on your production tenant and draining it | Move test traffic to a non-production tenant | **Fix steps** ## `401` on `POST /v3/session/` (wrong auth header / surface) The key is valid but the request is rejected with `401` — often only on one surface. | Cause | How to confirm | Fix | | --- | --- | --- | | Wrong header for the surface | `401` on `/v3` but the same key works on `/api/v1` (or vice versa) | Match the header to the surface | | Key rotated / revoked in the console | `401` on **both** surfaces | Issue and deploy a new key | | Key sent from a browser/mobile client (leaked or blocked) | Request originates client-side | Move key to your backend | | Malformed header (extra `Bearer`, stray whitespace, wrong case) | — | Fix the header value | **Fix steps** ## Camera doesn't open in the hosted flow The applicant reaches document capture or liveness and the camera never activates. | Cause | How to confirm | Fix | | --- | --- | --- | | Page not served over HTTPS (or a mixed-content wrapper) | Browsers block `getUserMedia` on insecure origins | Serve the flow over HTTPS | | Camera permission denied / dismissed by the user | Browser shows the permission as blocked | Re-prompt or guide the user to reset it | | Embedded in an iframe/WebView without camera permission delegated | Works in a full tab, fails when embedded | Grant `allow="camera; microphone"` on the iframe / native WebView | | Another app or tab already holds the camera | OS-level camera-in-use error | Close the other consumer | **Fix steps** ## NFC read times out / chip not found An `NFC_VERIFICATION` step is running on a native SDK and the scan fails. | `res.error.code` | Meaning | What to do | | --- | --- | --- | | `timeout` | Chip not held long enough / poor antenna contact | Ask the user to hold the phone still against the document and retry | | `tag_lost` | Contact broke mid-read | Retry; keep the phone flat on the document | | `wrong_key` | BAC/PACE key derivation failed (bad MRZ/CAN inputs) | Re-check the OCR'd `documentNumber` + `dateOfBirth` + `dateOfExpiry` | | `nfc_disabled` | NFC radio is off (Android) | Prompt the user to enable NFC | | `user_cancelled` | User dismissed the scan sheet | Re-offer the step | **Fix steps** ## Applicant sees "session expired" immediately The applicant opens the link and is told the session has already expired. | Cause | How to confirm | Fix | | --- | --- | --- | | The link is genuinely old (created long before it was opened) | `expires_at` is in the past | Mint a fresh session | | Very short `expires_in_hours` / `session_ttl_minutes` at create | Create call used a tiny TTL | Use a longer lifetime | | The URL was already opened and completed once (single-use) | Session is already terminal | Create a new session to retry | | Server/client clock skew makes a valid link look expired | Rare; check `expires_at` vs. real time | Sync clocks | **Fix steps** ## `callback_url` redirect lands on an error page The applicant finishes the flow and is redirected to your `callback_url`, but your page errors. | Cause | How to confirm | Fix | | --- | --- | --- | | Your handler reads the decision from redirect query params | The redirect carries no authoritative decision to trust | Read the decision server-side instead | | `callback_url` route doesn't exist / 404s | Direct-hitting the URL 404s | Deploy the route | | Handler assumes a terminal decision that isn't in yet (`in_review`) | Redirect happens before an operator resolves review | Don't gate the page on a terminal decision | | Relative or malformed `callback_url` | It isn't an absolute HTTPS URL | Pass an absolute URL | **Fix steps** ## Duplicate sessions created Two (or more) sessions show up for what should have been one applicant, and credits were debited more than once. | Cause | How to confirm | Fix | | --- | --- | --- | | Retried `POST /v3/session/` after a timeout without an `Idempotency-Key` | Two `201`s, two `id`s, same `vendor_data` | Send an `Idempotency-Key` | | Idempotency key derived from mutable data (timestamp per retry) | Keys differ across retries | Use a stable key | | Client double-submit (double click, double effect) racing two creates | Two near-simultaneous creates | Debounce + idempotency | | Reusing the same `jti` across retries not respected | `/api/v1` retries with a changing `jti` | Keep `jti` stable per logical session | **Fix steps** ## FAQ ## Related --- ## Android SDK **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/android **Description:** com.thirdfactor.kyc — open the hosted flow in a WebView via the Activity Result contract, coroutines, or Compose, with built-in ePassport NFC. `com.thirdfactor.kyc` opens the hosted verification flow in a full-screen `WebView`, bridges its lifecycle events, and resolves **one** normalized `VerificationResult`. Native NFC ePassport / eID chip reading is built in. ## Requirements | | | | --- | --- | | Package | `com.thirdfactor.kyc` | | minSdk / compileSdk | **24** / **34** | | Language / toolchain | Kotlin 1.9.22, coroutines, JVM target 17, AndroidX | | Compose (optional) | Compose Compiler 1.5.8 (only for the `rememberThirdFactorLauncher()` helper) | | NFC | JMRTD + SCUBA + BouncyCastle (bundled); a device with an NFC reader for chip steps | ## Installation Delivered as a Gradle **module** (source), not yet a published artifact. Include it from `settings.gradle.kts`: ```kotlin // settings.gradle.kts (host app) include(":thirdfactor-kyc") project(":thirdfactor-kyc").projectDir = file("path/to/thirdfactor-obsidian/sdks/android") ``` ```kotlin // app/build.gradle.kts dependencies { implementation(project(":thirdfactor-kyc")) } ``` The module pulls `androidx.core`, `androidx.activity`, `kotlinx-coroutines`, the Compose runtime, `androidx.webkit`, and the NFC stack (`org.jmrtd:jmrtd:0.7.42`, `net.sf.scuba:scuba-sc-android:0.0.23`, `org.bouncycastle:bcprov-jdk15to18:1.77`). No manifest entries for the SDK's `Activity` / `FileProvider` are needed in your app — they ship in the library manifest. ## Permissions The library manifest declares what it needs; they merge into your app: | Permission | Why | Runtime prompt? | | --- | --- | --- | | `INTERNET` | Load the hosted flow. | No | | `CAMERA` | Document + selfie / liveness capture. | **Yes** — requested the first time the flow loads. | | `NFC` | ePassport / eID chip for the `NFC_VERIFICATION` step and the standalone `ThirdFactorNfc` API. | No — the install-time permission is enough. | The library also declares `` (and the same for `camera`) so devices without the hardware can still install and run the rest of the flow — NFC then falls back to server-side review, and the SDK does not advertise the `nfc` capability. ## Quick start ```kotlin class CheckoutActivity : ComponentActivity() { private val verify = registerForActivityResult(ThirdFactor.Contract()) { result -> if (result.status == VerificationStatus.APPROVED) onApproved() // confirm server-side! } private fun startKyc(sessionUrl: String) = verify.launch(VerifyRequest(sessionUrl)) } ``` ## Integration methods All three launchers resolve the same `VerificationResult`. ## Configuration `VerifyRequest(url, options, requestId)` wraps the hosted `url` and options (`requestId` is generated for you). `VerifyOptions` is `Parcelable`: | Field | Type | Default | Notes | | --- | --- | --- | --- | | `title` | `String` | `"Identity verification"` | Accessibility label for the verification screen. | | `expectedOrigin` | `String?` | origin of `url` | WebView is locked to this origin; only override behind a proxy. | | `autoClose` | `Boolean` | `true` | `false` keeps the flow's own result screen until the user taps Done. | ## Result handling ```kotlin data class VerificationResult( val status: VerificationStatus, // APPROVED | DECLINED | IN_REVIEW | CANCELLED | ERROR val sessionId: String? = null, // echoed by the flow when available val rawStatus: String? = null, // raw server status, e.g. "abandoned" val error: ThirdFactorError? = null, // set only when status == ERROR ) ``` | `status` | When | | --- | --- | | `APPROVED` | `completed` with server status `approved`. | | `DECLINED` | `completed` with `declined`. | | `IN_REVIEW` | `completed` with `in_review` / `review` / `manual_review` / `pending`, or any **unknown** terminal status (conservative). | | `CANCELLED` | `completed` with `abandoned`, **or** the user dismissed (back / ✕ / task removed) before a terminal event. | | `ERROR` | An `error` bridge event or a load failure. Carries `error = { code, message }`. | Branch on `error.code`, never on `error.message`. Known codes: `invalid_session`, `expired`, `missing_url`, `invalid_url`, `load_error`. ```kotlin private val verify = registerForActivityResult( ThirdFactor.Contract(onEvent = { e -> Log.d("kyc", e.toString()) }) ) { result -> when (result.status) { VerificationStatus.APPROVED -> proceed() // confirm server-side first VerificationStatus.DECLINED -> showFailure() VerificationStatus.IN_REVIEW -> showPending() VerificationStatus.CANCELLED -> { /* let them retry */ } VerificationStatus.ERROR -> if (result.error?.code == "expired") reMintSession() else showError(result.error) } } ``` ## NFC verification The hosted flow runs in a WebView, which **cannot** reach the NFC radio. When the flow reaches an `NFC_VERIFICATION` step it hands the chip read to this SDK over the bridge's native-capability channel: This is fully automatic — if you launch the flow with `ThirdFactor.verify` / the contract on an NFC device, the chip step just works. ### Standalone NFC API Read a chip **directly**, independent of the hosted flow (custom UI, offline enrolment, re-verification): ```kotlin lifecycleScope.launch { val result = ThirdFactorNfc.read( activity = this@MyActivity, accessKey = NfcAccessKey.Mrz( documentNumber = "L898902C3", dateOfBirth = "740812", // YYMMDD dateOfExpiry = "301001", // YYMMDD ), dataGroups = listOf(NfcDataGroup.DG1, NfcDataGroup.DG2, NfcDataGroup.SOD), overallTimeoutMs = 60_000, // give up if no chip is presented in 60 s ) when (result) { is NfcReadResult.Success -> { val sod = result.dataGroups[NfcDataGroup.SOD] // raw ByteArray // base64 + POST to your backend, which runs passive authentication. } is NfcReadResult.Failure -> showError(result.code) // NfcErrorCode.* } } ``` Public surface: ```kotlin object ThirdFactorNfc { fun isAvailable(context: Context): Boolean suspend fun read( activity: Activity, accessKey: NfcAccessKey, // .Mrz(doc, dob, expiry) | .Can(can) dataGroups: List ### Packaging notes The BouncyCastle jars ship signature / OSGi metadata that collide on packaging; the module's `packaging { resources { excludes … } }` block drops them, and R8 keep rules for the JMRTD/SCUBA/BC classes ship in `consumer-rules.pro`. For the full NFC architecture and passive-auth decision mapping, see the [NFC deep-dive](/docs/sdks/nfc). ## Complete API reference Every public symbol in `com.thirdfactor.kyc`, verified against `sdks/android/src/main/java/com/thirdfactor/kyc/`: ### `ThirdFactor` | Member | Signature | Notes | | --- | --- | --- | | `ThirdFactor.Contract` | `class Contract(onEvent: ((VerificationEvent) -> Unit)? = null) : ActivityResultContract ## FAQ ## How it works `verify` / the contract start `ThirdFactorActivity`, which appends `embed=1&sdk=android` to your `url`, loads it in a `WebView` (`javaScriptEnabled`, `domStorageEnabled`, `mediaPlaybackRequiresUserGesture=false`), injects the bridge under the name **`ThirdFactorAndroid`** with `@JavascriptInterface fun postMessage(String)`, grants `getUserMedia` after ensuring runtime `CAMERA`, keeps navigation on the flow origin, and returns exactly one result (completed-then-close and back-button races are guarded). See the [bridge protocol](/docs/sdks/bridge-protocol) for the full contract. ## Related --- ## Bridge protocol **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/bridge-protocol **Description:** The transport-agnostic flow → host event contract and the bidirectional native-capability channel. Protocol version 1. Every ThirdFactor client SDK (web, iOS, Android, Flutter) is a thin shell around the **same hosted verification flow** (`/verify/`). The shell opens the flow and listens for lifecycle events over a small, transport-agnostic bridge. This page is the single source of truth for that bridge — read it if you're writing a host for a platform we don't ship, or debugging an existing SDK. The reference implementation lives in the flow's `frontend/components/verify/sdk-bridge.ts` (flow side) and `sdks/web` (host side). ## 1. Turning on embed mode The SDK appends query params when it opens the flow URL: ``` https:///verify/?embed=1&sdk= ``` - `embed=1` — required. Puts the flow in embed mode: it reports lifecycle to the host instead of redirecting the browser to the session's `callback_url`. - `sdk=` — advisory: `web` | `ios` | `android` | `flutter` | `rn`. Any non-empty `sdk` value alone also enables embed mode. ## 2. Message envelope Every event the flow emits is a JSON object with this envelope: ```jsonc { "source": "thirdfactor", // constant discriminator — filter on this "version": 1, // protocol version "sdk": "ios", // echo of the ?sdk= platform (may be null) "type": "...", // see event types below // ...event-specific fields } ``` ## 3. Event types (flow → host) | `type` | Fields | Meaning | Host action | | --- | --- | --- | --- | | `ready` | — | Flow mounted and the session resolved OK. | Reveal the surface / hide your spinner. | | `completed` | `status`, `sessionId?` | Terminal decision reached. | Resolve the result; dismiss (unless `autoClose` off). | | `error` | `code`, `message?` | Unrecoverable: invalid or expired token, load failure. | Resolve an error result; dismiss. | | `close` | — | User acknowledged the result screen (tapped Done). | Dismiss. If a `completed` already arrived, that is the result. | - `status` on `completed` is the **raw server status**: `approved` · `declined` · `in_review` · `abandoned`. - `code` on `error` is a stable machine code: `invalid_session` · `expired` (extendable). **Branch on `code`, never on `message`.** The exact wire payload for each event, as emitted by the flow: ## 4. Transports The flow writes **every** event to all channels at once (fire-and-forget); whichever host is listening picks it up. An SDK only needs to wire the transport for its platform: | Platform | Transport | How the flow emits | How the SDK receives | | --- | --- | --- | --- | | Web (iframe) | `postMessage` | `window.parent.postMessage(msg, "*")` | `window.addEventListener("message", …)` | | Web (popup) | `postMessage` | `window.opener.postMessage(msg, "*")` | listener on the opener window | | Android | JS interface | `window.ThirdFactorAndroid.postMessage(json)` | `webView.addJavascriptInterface(obj, "ThirdFactorAndroid")`, `@JavascriptInterface fun postMessage(String)` | | iOS | WKScriptMessageHandler | `window.webkit.messageHandlers.thirdfactor.postMessage(msg)` | `userContentController.add(self, name: "thirdfactor")` | | Flutter | JS channel | `window.ThirdFactorFlutter.postMessage(json)` | `controller.addJavaScriptChannel("ThirdFactorFlutter", …)` — one name, both platforms | | React Native | `react-native-webview` | `window.ReactNativeWebView.postMessage(json)` | `onMessage` prop | Android and React Native receive a **JSON string** — parse it. iOS receives a structured object (a `[String: Any]` dictionary). Web receives a structured clone (an object). Flutter receives a JSON string on the channel's `onMessageReceived` and decodes it. ## 5. Normalized result (host → app) Each SDK maps the raw `status`/`code` onto one identical, small result type: | SDK status | From | | --- | --- | | `approved` | `completed` status `approved` | | `declined` | `completed` status `declined` | | `in_review` | `completed` status `in_review` / `review` / `manual_review` / `pending`, or any **unknown** terminal status (conservative) | | `cancelled` | `completed` status `abandoned`; **or** the user dismissed (back button, ✕, Esc, closed tab) before a terminal event | | `error` | an `error` event, or a transport/load failure. Carries `{ code, message }`. | The result also carries `sessionId` (when present) and `rawStatus` (the original server string) so callers can distinguish e.g. `abandoned` from an explicit cancel. ## 6. Cancellation If the user dismisses the surface (Android back, iOS swipe/close, web ✕ / Esc / closed popup) before a `completed`/`error` arrives, the SDK resolves `cancelled`. A `close` event received *after* a `completed` is just the dismiss signal — the earlier `completed` remains the result. ## 7. Security - postMessage is sent with `targetOrigin: "*"` because the flow cannot know the parent origin. **The host validates the sender**: web SDKs check `event.origin === `; native WebViews only load the trusted flow origin and refuse to navigate elsewhere. - Events carry **no secrets** — only `status`, a public `sessionId`, and error codes. The applicant's documents and selfie never cross the bridge. - The client result is advisory. Authoritative decisions come from the HMAC-signed webhook or `GET /v3/session//decision/`. ## 8. Native capability channel (bidirectional) Some verification steps need native hardware the WebView cannot reach — reading an **ePassport / eID chip over NFC** is the canonical case (`NFC_VERIFICATION`). The flow delegates these to the native SDK over a small request/response extension of the bridge. Web iframe/popup hosts advertise no capabilities, so the flow transparently keeps its server-side fallback (NFC → manual review). ### 8.1 Handshake (host → flow) Before the flow loads — or, for WebViews, on first page-finish — the native SDK injects a global describing itself: ```js window.__thirdfactorHost = { platform: "flutter", // ios | android | flutter | rn sdkVersion: "1.1.0", capabilities: ["nfc"], // capability ids the SDK can fulfil }; ``` The flow reads it via `hostCapabilities()` / `hostHasCapability("nfc")`. A host that cannot fulfil a capability (NFC off, no radio, web) simply advertises an empty `capabilities` array — the flow then keeps its server-side fallback for any step that would have needed it. ### 8.2 Command (flow → host) When the flow reaches a step it wants the host to fulfil, it sends a `command` envelope over the **same transports** as lifecycle events: ```jsonc { "source": "thirdfactor", "version": 1, "sdk": "flutter", "type": "command", "command": "nfc.scan", "requestId": "cmd_1", "params": { /* command-specific */ } } ``` `requestId` is opaque — the host must echo it back verbatim so the flow can pair the reply to the request. ### 8.3 Result (host → flow) The SDK runs the capability natively, then evaluates JS in the WebView to resolve the matching request: ```js window.ThirdFactorNative.resolveCommand("cmd_1", { ok: true, data: { /* command-specific */ } }); // or: { ok: false, error: { code: "user_cancelled", message: "…" } } ``` Hosts may pass the second argument either as an object or as a JSON **string** (the Flutter SDK double-encodes to escape the payload safely); `resolveCommand` accepts and parses both. The flow pairs the result to its command by `requestId`, with a default **180 s** timeout — a request that isn't resolved in time is auto-failed with `{ ok:false, error:{ code:"timeout" } }`. ### 8.4 `nfc.scan` contract **params** (BAC/PACE access is derived from the MRZ the flow already OCR'd): | field | req | meaning | | --- | --- | --- | | `documentNumber` | yes* | passport / ID number from the MRZ | | `dateOfBirth` | yes* | `YYMMDD` | | `dateOfExpiry` | yes* | `YYMMDD` | | `mrzKey` | — | precomputed BAC key (alternative to the three fields above) | | `canNumber` | — | 6-digit Card Access Number for PACE-only eIDs | | `dataGroups` | — | which DGs to read; default `["DG1","DG2","SOD"]` | \* Either the three MRZ fields **or** `mrzKey` (or `canNumber` for PACE). Not every SDK supports `mrzKey` — the Flutter and Android SDKs derive the key from the raw MRZ fields and reject a `mrzKey`-only command with `unsupported`. The full request and both reply shapes on the wire: **result `data`** — raw chip bytes, base64. The SDK does **not** do passive auth; it reads and forwards, matching how the platform's engine validates server-side (parse SOD, hash DGs, verify the signer chain against the CSCA masterlist, compare DG1↔OCR and DG2↔selfie): | field | meaning | | --- | --- | | `sod_b64` | Document Security Object (EF.SOD) — the signed hash set | | `dg1_b64` | DG1 (MRZ) | | `dg2_b64` | DG2 (face image) | | `dg11_b64`, `dg12_b64`, … | optional extra DGs when requested | | `com_b64` | EF.COM (present DG list) | | `aa` | `{ performed, success }` — Active Authentication result if the chip supports it | | `chipAuth` | `{ performed, success }` — Chip Authentication (EAC) if performed | The flow uploads `data` to `POST /api/verify//nfc/`, which runs passive authentication and resolves the `NFC_VERIFICATION` module. Error `code`s: `user_cancelled`, `tag_lost`, `wrong_key` (MRZ/CAN mismatch), `unsupported`, `nfc_disabled`, `timeout`. See the [NFC deep-dive](/docs/sdks/nfc) for the full architecture. ## 9. End-to-end sequence The full lifecycle of an embedded session, including the optional NFC delegation: ``` App / Backend Native SDK (host) Hosted flow (WebView) Backend API │ │ │ │ │ POST /v3/session/ │ │ │ ├────────────────────────────────────────────────────────────────────────►│ │ { id, url } │ │ │◄────────────────────────────────────────────────────────────────────────┤ │ verify(url) │ │ │ ├────────────────────►│ load url?embed=1&sdk=… │ │ │ ├──────────────────────────►│ │ │ │ inject __thirdfactorHost │ │ │ ├──────────────────────────►│ (§8.1 handshake) │ │ │◄──── ready ───────────────┤ │ │ │ (capture, liveness, face match run in flow) │ │ │◄──── command nfc.scan ────┤ (§8.2, if capability) │ │ [read chip] │ │ │ │ ├──── resolveCommand ──────►│ (§8.3) │ │ │ │ POST /verify//nfc/ │ │ │ ├────────────────────────►│ │ │◄──── completed(status) ───┤ │ │◄─── result ─────────┤ │ │ │ │◄──── close (Done) ────────┤ (dismiss) │ │ │ │ │ │ CONFIRM server-side: webhook identity.kyc.session.* │ │ or GET /v3/session//decision/ │ ├────────────────────────────────────────────────────────────────────────►│ ``` The `nfc.scan` exchange is skipped entirely when the host advertises no `nfc` capability — the flow resolves that step server-side (manual review) instead. ## 10. Versioning `version` is currently `1`. New event types, commands, capabilities, or fields are **additive** — hosts must ignore unknown `type`/`command`/`source` values and unknown fields. A breaking change bumps `version` and is negotiated via the `?sdk=` / `embed` params. ## Rolling your own host If there's no packaged SDK for your platform, hosting the flow yourself is a small job: open the WebView, wire the transport for §4, filter on `source === "thirdfactor"`, map the statuses per §5, and treat cancellation per §6. The complete contract is on this page. For NFC support, add the §8 capability channel; without it, NFC steps fall through to the server-side review path automatically. ## FAQ ## Related --- ## .NET server SDK **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/dotnet **Description:** ThirdFactor.SDK — create verification sessions, select workflows, retrieve decisions, and verify signed webhooks from .NET. The official .NET SDK is a **server-only** client for the ThirdFactor API. The NuGet package is `ThirdFactor.SDK`, and its namespace is `ThirdFactor`. ## Requirements - .NET 6 or newer; .NET 10 LTS is recommended for new production services - No runtime dependencies outside the .NET base class library - A tenant API key from **Console → Settings → API keys** ## Installation ```bash dotnet add package ThirdFactor.SDK --version 0.1.0 ``` ## Create a session and select a workflow ```csharp using ThirdFactor; var thirdFactor = new ThirdFactorClient( Environment.GetEnvironmentVariable("THIRDFACTOR_API_KEY")!, "https://your-tenant.thirdfactor.ai" ); using var session = await thirdFactor.CreateSessionAsync( new Dictionary { ["vendor_data"] = "user-42", ["workflow_id"] = "4c615aec-e173-450d-8089-30d4edc835f3", ["callback_url"] = "https://app.example.com/kyc/done", ["contact_email"] = "user@example.com", ["metadata"] = new Dictionary { ["source"] = "dotnet-sdk" } }, idempotencyKey: "kyc-user-42" ); var sessionId = session.RootElement.GetProperty("id").GetString(); var hostedUrl = session.RootElement.GetProperty("url").GetString(); ``` Pass the desired Console workflow UUID as `workflow_id`. Omit it to use the tenant's default workflow. Persist the session ID and send only the hosted URL to the applicant. Use a stable idempotency key for each logical attempt so a retry returns the original session. ## Retrieve a session or decision ```csharp using var current = await thirdFactor.RetrieveSessionAsync(sessionId!); using var decision = await thirdFactor.SessionDecisionAsync(sessionId!); if (decision.RootElement.GetProperty("decision").GetString() == "approved") { GrantAccess(); } ``` The hosted UI result is advisory. Grant access, move money, or provision an account only after a signed webhook or this authenticated decision lookup. ## Verify webhooks Pass the untouched HTTP request bytes and the `X-Webhook-Signature` header before deserializing JSON: ```csharp using ThirdFactor; var secret = Environment.GetEnvironmentVariable("THIRDFACTOR_WEBHOOK_SECRET")!; using var webhookEvent = ThirdFactorWebhooks.ConstructEvent( rawRequestBody, signatureHeader, secret ); ``` The verifier uses constant-time HMAC comparison and rejects signatures outside the five-minute replay window by default. `ConstructEvent` throws an `ArgumentException` when the signature is invalid, stale, or the body is not valid JSON. ## Handle API errors ```csharp try { using var decision = await thirdFactor.SessionDecisionAsync(sessionId!); } catch (ThirdFactorException error) { Console.Error.WriteLine(error.Status); Console.Error.WriteLine(error.ResponseBody); Console.Error.WriteLine(error.RequestId); } ``` ## Resources - [NuGet: `ThirdFactor.SDK`](https://www.nuget.org/packages/ThirdFactor.SDK) - [Source: `thirdfactorai/thirdfactor-dotnet`](https://github.com/thirdfactorai/thirdfactor-dotnet) - [Sessions API](/docs/api-reference/sessions) - [Workflows API](/docs/api-reference/workflows) - [Webhook events](/docs/webhooks/events) --- ## Flutter SDK **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/flutter **Description:** thirdfactor_kyc — open the hosted flow in a full-screen WebView with one verify(context, url:) call, plus native ePassport NFC. `thirdfactor_kyc` opens the hosted verification flow in a full-screen WebView, reads ePassport / eID NFC chips natively, and resolves a single typed `VerificationResult`. It speaks the same shared bridge protocol as the web, iOS, and Android SDKs — a thin shell around the same hosted flow, with one channel name (`ThirdFactorFlutter`) wired for both platforms. ## Requirements | | | | --- | --- | | Flutter | `>=3.24.0` (for `PopScope.onPopInvokedWithResult`). Dart `>=3.0.0 <4.0.0`. | | WebView | `webview_flutter ^4.7` (+ `webview_flutter_android` / `webview_flutter_wkwebview`). | | NFC | [`dmrtd`](https://github.com/ZeroPass/dmrtd) `v2.0.0` (ICAO-9303 BAC/PACE, secure messaging, EF/DG parsing), which bundles [`flutter_nfc_kit`](https://pub.dev/packages/flutter_nfc_kit) `^3.6.x`. Android `minSdkVersion 23`; iOS 13+ (15+ recommended), real device only. | | SDK version | `1.1.0` (the value reported in the `window.__thirdfactorHost` handshake). | ## Installation The package is **not published to pub.dev** (`publish_to: none`); vendor it as a git or path dependency: ```yaml # pubspec.yaml dependencies: thirdfactor_kyc: git: url: https://github.com/thirdfactor/obsidian.git path: sdks/flutter # — or, if vendored locally — # thirdfactor_kyc: # path: ../thirdfactor-obsidian/sdks/flutter ``` `dmrtd` is itself pinned to a GitHub tag inside the SDK, so no extra NFC dependency is needed in your app. The single import gives you the entire public surface: ```dart ``` ## Platform setup The flow captures a live selfie / document photos, so the **host app** must declare camera (and, for liveness, microphone) permissions. Granting them *inside* the WebView is handled for you; declaring them at the OS level is not. NFC needs a further set of permissions/entitlements on top (see [NFC verification](#nfc-verification)). ## Quick start ```dart Future runKyc(BuildContext context, String sessionUrl) async { final result = await ThirdFactor.verify(context, url: sessionUrl); if (result.status == VerificationStatus.approved) { showSuccess(); // UX only — confirm server-side before granting access } } ``` `verify()` **never throws** — it always resolves a `VerificationResult`. Call it from a `Navigator`-capable `BuildContext`; it pushes a full-screen `MaterialPageRoute` (`fullscreenDialog: true`) and completes when that route pops. ## Integration methods ## Complete API reference Every public symbol exported from `package:thirdfactor_kyc/thirdfactor_kyc.dart`, verified against `sdks/flutter/lib/`. ### `ThirdFactor` | Member | Signature | Notes | | --- | --- | --- | | `verify` | `static Future ## NFC verification Some documents carry an ICAO-9303 contactless chip. When the hosted flow reaches an **NFC step**, the WebView cannot reach the radio, so this SDK reads the chip **natively** over the [capability bridge](/docs/sdks/bridge-protocol#8-native-capability-channel-bidirectional) and forwards the raw chip bytes for your **backend** to authenticate. This is fully automatic — no code beyond `ThirdFactor.verify` is required. If the device has no NFC, the SDK advertises nothing and the flow keeps its server-side fallback (NFC → manual review). Chip reading uses `dmrtd` (`v2.0.0`) for BAC/PACE key derivation, secure messaging, and EF/DG parsing, over its bundled `flutter_nfc_kit`. ### Setup — platform configuration NFC needs extra configuration in the **host app** (the SDK is a pure Dart package and cannot declare these for you). ### Standalone read Read a chip yourself, independent of the hosted flow — for a pre-fill step, offline enrolment, or your own KYC UI. The BAC key is derived from the MRZ your app already OCR'd (document number + the two dates), or from a 6-digit CAN for PACE-only eIDs: ```dart if (await ThirdFactorNfc.isAvailable()) { try { final chip = await ThirdFactorNfc.read( documentNumber: 'X1234567', dateOfBirth: '900101', // YYMMDD dateOfExpiry: '300101', // YYMMDD dataGroups: const [NfcDataGroup.dg1, NfcDataGroup.dg2, NfcDataGroup.sod], // canNumber: '123456', // instead of the MRZ fields, for PACE-only eIDs ); // Raw, base64 chip bytes — send to YOUR backend for passive authentication. final payload = chip.toBridgeData(); // { sod_b64, dg1_b64, dg2_b64, aa, chipAuth } await sendToBackend(payload); } on NfcException catch (e) { switch (e.code) { // e.code is an NfcErrorCode enum case NfcErrorCode.userCancelled: break; case NfcErrorCode.wrongKey: showRetryMrz(); break; // MRZ/CAN mismatch case NfcErrorCode.tagLost: showHoldStill(); break; case NfcErrorCode.nfcDisabled: showEnableNfc(); break; case NfcErrorCode.timeout: showTryAgain(); break; case NfcErrorCode.unsupported: showManualPath();break; } } } ``` Signature (verified against `lib/src/nfc/nfc_reader.dart`): ```dart static Future For the full NFC architecture and passive-auth decision mapping, see the [NFC deep-dive](/docs/sdks/nfc). ## Full end-to-end example A complete integration: a server call that mints the session (any backend, shown as cURL), and a Flutter screen that opens it and reports the result. A runnable version lives in `sdks/flutter/example/` — paste a hosted session URL and tap **Start verification**, or tap **Read passport NFC (standalone)** to exercise `ThirdFactorNfc.read` on a device. ## Troubleshooting ## FAQ ## How it works `verify()` opens the flow with `?embed=1&sdk=flutter` appended. In embed mode the flow reports lifecycle over the `ThirdFactorFlutter` JavaScript channel (one channel name on both iOS and Android) instead of redirecting. The SDK validates that every message is a `thirdfactor` bridge event on the expected origin, maps the raw status via `StatusMapper` (PROTOCOL §5), and resolves exactly one result. On page load it injects the `window.__thirdfactorHost` handshake (PROTOCOL §8.1) so the flow can delegate native-only steps — currently `nfc.scan` — to the SDK over the same channel. See the [bridge protocol](/docs/sdks/bridge-protocol) for the full contract. ## Related --- ## iOS SDK **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/ios **Description:** ThirdFactorKYC (SwiftPM) — open the hosted flow in a WKWebView from UIKit or SwiftUI, with optional native ePassport NFC. `ThirdFactorKYC` opens the hosted verification flow in a `WKWebView`, listens for lifecycle events over the shared bridge, and resolves **one** typed `VerificationResult`. An optional NFC product (`ThirdFactorKYCNFC`) reads ePassport / eID chips natively. ## Requirements | | | | --- | --- | | iOS | 15+ (the SwiftPM package floor). | | Core (`ThirdFactorKYC`) | Pure Swift, **no third-party dependencies** (WKWebView only). UIKit + SwiftUI entry points. | | NFC (`ThirdFactorKYCNFC`, optional) | iOS 15+, a device with NFC (iPhone 7+). Depends on MIT-licensed [`NFCPassportReader`](https://github.com/AndyQ/NFCPassportReader) `2.1.0+`. The Simulator has no NFC. | | Concurrency | `async`/`await` and completion-handler APIs; the verify API is `@MainActor`. | ## Installation Swift Package Manager. In Xcode: **File → Add Package Dependencies…** and point at the repo, or add it to `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/your-org/thirdfactor-obsidian.git", from: "1.0.0"), ], targets: [ .target(name: "YourApp", dependencies: [ .product(name: "ThirdFactorKYC", package: "thirdfactor-obsidian"), // add only if you need native NFC chip reading: .product(name: "ThirdFactorKYCNFC", package: "thirdfactor-obsidian"), ]), ] ``` The package exposes **two library products** — `ThirdFactorKYC` (core) and `ThirdFactorKYCNFC` (NFC). Link the NFC product only if you read chips; it keeps the core dependency-free. ## Permissions The flow captures the camera (document + selfie/liveness) and may use the microphone. iOS requires purpose strings in `Info.plist` or the app is rejected / the prompt never appears: ```xml NSCameraUsageDescription We use the camera to verify your identity. NSMicrophoneUsageDescription We use the microphone during liveness checks. ``` The SDK auto-grants the in-WebKit camera/mic permission; these strings back the one-time OS prompt. NFC needs additional configuration — see [NFC verification](#nfc-verification). ## Quick start ```swift // sessionURL is the `url` your backend got from POST /v3/session/ func startKYC(sessionURL: URL) { Task { let result = await ThirdFactor.verify(from: self, url: sessionURL) if result.status == .approved { showSuccess() } // then confirm server-side } } ``` ## Integration methods All three variants resolve the same `VerificationResult`. ## Configuration Pass a `VerifyOptions` to any `verify(...)` variant: ```swift public static func verify( from presenter: UIViewController, url: URL, options: VerifyOptions = .init() ) async -> VerificationResult ``` | Field | Type | Default | Notes | | --- | --- | --- | --- | | `title` | `String` | `"Identity verification"` | Navigation-bar title of the presented flow. | | `onEvent` | `((VerificationEvent) -> Void)?` | `nil` | Low-level lifecycle stream: `.ready` / `.completed` / `.error` / `.close`. | | `expectedOrigin` | `String?` | origin of `url` | Only override behind a proxy. Include the scheme, e.g. `"https://kyc.acme.com"`. | | `autoClose` | `Bool` | `true` | `false` keeps the flow's own result screen until the user taps Done. | | `nativeCommandHandler` | `NativeCommandHandler?` | `nil` | Host-side native capability (NFC). Set `ThirdFactorNFC.commandHandler()` to enable native NFC. | ```swift var options = VerifyOptions(title: "Verify identity", autoClose: true) let result = await ThirdFactor.verify(from: self, url: sessionURL, options: options) ``` ## Result handling `verify(...)` never throws — it always resolves a `VerificationResult`: ```swift public struct VerificationResult { let status: VerificationStatus // .approved .declined .inReview .cancelled .error let sessionId: String? // echoed from the flow when available let rawStatus: String? // raw server status, e.g. "abandoned" let error: ThirdFactorError? // set only when status == .error } ``` | `status` | When | | --- | --- | | `.approved` | flow reported `approved` | | `.declined` | flow reported `declined` | | `.inReview` | `in_review` / `review` / `manual_review` / `pending`, **or** any unknown terminal status (conservative) | | `.cancelled` | flow reported `abandoned`, **or** the user dismissed (Cancel / swipe) before a terminal decision | | `.error` | an `error` event or a load/transport failure — carries `error.code` + `error.message` | `ThirdFactorError` carries a stable `code` (branch on it, never on `message`): `invalid_session`, `expired`, `load_error`, `unknown` (extendable). ```swift var options = VerifyOptions() options.onEvent = { event in print("event:", event) } // ready/completed/error/close let result = await ThirdFactor.verify(from: self, url: sessionURL, options: options) switch result.status { case .approved: proceed() // confirm server-side first case .declined: showFailure() case .inReview: showPending() case .cancelled: break case .error: if result.error?.code == "expired" { reMintSession() } else { showError(result.error) } } ``` ## NFC verification Some documents carry an ICAO-9303 contactless chip (the eMRTD in a biometric passport, or an eID card). Reading it over NFC yields a cryptographically signed copy of the holder's data — stronger than an OCR/photo. A WebView cannot reach NFC, so this is a **native capability**: `ThirdFactorKYCNFC` reads the chip and hands raw bytes to the flow (or to your app directly). ### Setup — capability, entitlement, Info.plist Add the **Near Field Communication Tag Reading** capability in Xcode (Signing & Capabilities → **+ Capability**). That adds the entitlement: ```xml com.apple.developer.nfc.readersession.formats TAG ``` Plus `Info.plist` keys: ```xml NFCReaderUsageDescription We read your passport/ID chip to verify your identity. com.apple.developer.nfc.readersession.iso7816.select-identifiers A0000002471001 A00000045645444C2D3031 A0000002472001 ``` `A0000002471001` (the ICAO eMRTD application) is **required** to read passports; the others are optional and only needed for the specific eID cards you support. ### Flow-delegated NFC (automatic) If your workflow includes an `NFC_VERIFICATION` step, pass a `NativeCommandHandler` so the flow drives the chip read itself. The SDK advertises `["nfc"]` to the flow **only when the device can read NFC**, the flow sends an `nfc.scan` command with the MRZ it already OCR'd, and the result is posted back automatically — no extra app code: ```swift var options = VerifyOptions() if #available(iOS 15, *) { options.nativeCommandHandler = ThirdFactorNFC.commandHandler() } let result = await ThirdFactor.verify(from: self, url: sessionURL, options: options) ``` Without a handler (or on a device without NFC), the flow transparently keeps its server-side fallback (NFC step → manual review). ### Standalone read Read a chip directly, independent of the hosted flow. Provide the three MRZ fields (the SDK computes the ICAO check digits) or a precomputed key: ```swift func scanPassport() async { let key = MRZKey( documentNumber: "L898902C3", // from the MRZ dateOfBirth: "740812", // YYMMDD dateOfExpiry: "120415" // YYMMDD ) // Default data groups: [.DG1, .DG2, .SOD]. let result = await ThirdFactorNFC.read(mrzKey: key, dataGroups: [.DG1, .DG2, .SOD]) if result.success, let data = result.data { let payload = data.asDictionary() // ["sod_b64": …, "dg1_b64": …, "dg2_b64": …, "aa": …, "chipAuth": …] upload(payload) // POST to your backend for passive auth } else { // result.errorCode ∈ user_cancelled | tag_lost | wrong_key | // unsupported | nfc_disabled | timeout print("NFC failed:", result.errorCode ?? "", result.errorMessage ?? "") } } ``` Public surface: ```swift @available(iOS 15, *) public enum ThirdFactorNFC { static func read(mrzKey: MRZKey, dataGroups: [DataGroupId] = [.DG1, .DG2, .SOD]) async -> NfcReadResult static func commandHandler() -> NativeCommandHandler // for the hosted flow } public struct MRZKey { init(rawKey: String) init(documentNumber: String, dateOfBirth: String, dateOfExpiry: String) // dates YYMMDD } ``` `NfcReadResult` exposes `success`, `data: NfcData?`, `errorCode`, `errorMessage`. `NfcData.asDictionary()` produces the exact `nfc.scan` `data` shape (`sod_b64`, `dg1_b64`, `dg2_b64`, optional `dg11_b64`/`dg12_b64`/`com_b64`, plus `aa` / `chipAuth` `{ performed, success }`). For the full NFC architecture (device vs. server split, passive-auth decision mapping), see the [NFC deep-dive](/docs/sdks/nfc). ## Complete API reference Every public symbol in `ThirdFactorKYC` and `ThirdFactorKYCNFC`, verified against `sdks/ios/Sources/`: ### `ThirdFactor` (core, `@MainActor`) | Member | Signature | Notes | | --- | --- | --- | | `verify(from:url:options:)` | `static func verify(from: UIViewController, url: URL, options: VerifyOptions = .init()) async -> VerificationResult` | `async`/`await` variant. `@discardableResult`. Never throws. | | `verify(from:url:options:completion:)` | `static func verify(from: UIViewController, url: URL, options: VerifyOptions = .init(), completion: @escaping (VerificationResult) -> Void)` | Completion-handler variant; fires exactly once, on the main thread, after dismissal. | ### `VerifyOptions` | Field | Type | Default | Notes | | --- | --- | --- | --- | | `title` | `String` | `"Identity verification"` | Navigation-bar title of the presented flow. | | `onEvent` | `((VerificationEvent) -> Void)?` | `nil` | Lifecycle stream: `.ready` / `.completed` / `.error` / `.close`. | | `expectedOrigin` | `String?` | origin of `url` | Only override behind a proxy. Include the scheme, e.g. `"https://kyc.acme.com"`. | | `autoClose` | `Bool` | `true` | `false` keeps the flow's own result screen until the user taps Done. | | `nativeCommandHandler` | `NativeCommandHandler?` | `nil` | Set `ThirdFactorNFC.commandHandler()` to enable native NFC. | ### Result types | Type | Shape | Notes | | --- | --- | --- | | `VerificationResult` | `struct { status: VerificationStatus; sessionId: String?; rawStatus: String?; error: ThirdFactorError? }` | `Sendable`, `Equatable`. | | `VerificationStatus` | `enum { .approved, .declined, .inReview, .cancelled, .error }` | `Sendable`, `Equatable`. | | `ThirdFactorError` | `struct { code: String; message: String }` | Conforms to `Error` and `LocalizedError` (`errorDescription` returns `message`). Branch on `code`. | | `VerificationEvent` | `enum { .ready, .completed(status: String, sessionId: String?), .error(code: String, message: String?), .close }` | `Sendable`, `Equatable`. `status` on `.completed` is the **raw** server string. | ### SwiftUI | Member | Signature | Notes | | --- | --- | --- | | `ThirdFactorVerification` | `struct ThirdFactorVerification: UIViewControllerRepresentable` (iOS 14+) | `init(isPresented: Binding ## FAQ ## How it works `verify(...)` loads the flow with `?embed=1&sdk=ios` appended. In embed mode the flow reports lifecycle over `window.webkit.messageHandlers.thirdfactor`. The SDK registers a `WKScriptMessageHandler` named **`thirdfactor`** (load-bearing — hard-coded in the flow), validates the sender frame's origin against the session URL, keeps the WebView pinned to the trusted origin, auto-grants in-WebKit camera/mic, and resolves **exactly once**. See the [bridge protocol](/docs/sdks/bridge-protocol) for the full contract. ## Related --- ## Java server SDK **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/java **Description:** ai.thirdfactor:thirdfactor-sdk — create verification sessions, select workflows, retrieve decisions, and verify signed webhooks from Java. The official Java SDK is a **server-only** client for the ThirdFactor API. Its Maven coordinates are `ai.thirdfactor:thirdfactor-sdk` and its package namespace is `ai.thirdfactor`. ## Requirements - Java 11 or newer - Maven or Gradle - A tenant API key from **Console → Settings → API keys** ## Installation ## Create a session and select a workflow ```java ThirdFactorClient thirdFactor = new ThirdFactorClient( System.getenv("THIRDFACTOR_API_KEY"), "https://your-tenant.thirdfactor.ai", Duration.ofSeconds(15), HttpClient.newHttpClient() ); Map session = thirdFactor.createSession(Map.of( "vendor_data", "user-42", "workflow_id", "4c615aec-e173-450d-8089-30d4edc835f3", "callback_url", "https://app.example.com/kyc/done", "contact_email", "user@example.com", "metadata", Map.of("source", "java-sdk") ), "kyc-user-42"); String sessionId = (String) session.get("id"); String hostedUrl = (String) session.get("url"); ``` Pass the desired Console workflow UUID as `workflow_id`. Omit it to use the tenant's default workflow. Persist the session ID and send only the hosted URL to the applicant. Use a stable idempotency key for each logical attempt so a retry returns the original session. ## Retrieve a session or decision ```java Map current = thirdFactor.retrieveSession(sessionId); Map decision = thirdFactor.sessionDecision(sessionId); if ("approved".equals(decision.get("decision"))) { grantAccess(); } ``` The hosted UI result is advisory. Grant access, move money, or provision an account only after a signed webhook or this authenticated decision lookup. ## Verify webhooks Pass the untouched HTTP request bytes and the `X-Webhook-Signature` header before parsing JSON: ```java Map event = Webhooks.constructEvent( rawRequestBody, signatureHeader, System.getenv("THIRDFACTOR_WEBHOOK_SECRET"), 300 ); ``` The verifier uses constant-time HMAC comparison and the example permits a five-minute replay window. `constructEvent` throws `IllegalArgumentException` when the signature is invalid, stale, or the body is not valid JSON. ## Handle API errors ```java try { Map decision = thirdFactor.sessionDecision(sessionId); } catch (ThirdFactorException error) { System.err.println(error.getStatus()); System.err.println(error.getBody()); System.err.println(error.getRequestId()); } ``` ## Resources - [Maven Central: `ai.thirdfactor:thirdfactor-sdk`](https://central.sonatype.com/artifact/ai.thirdfactor/thirdfactor-sdk/0.1.0) - [Source: `thirdfactorai/thirdfactor-java`](https://github.com/thirdfactorai/thirdfactor-java) - [Sessions API](/docs/api-reference/sessions) - [Workflows API](/docs/api-reference/workflows) - [Webhook events](/docs/webhooks/events) --- ## NFC verification **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/nfc **Description:** The native-only ePassport / eID chip read — read-on-device, validate-on-server; per-platform setup, standalone APIs, and the flow-delegated path. Reading an **ePassport / eID chip over NFC** is the one capability a WebView cannot do — it needs the device NFC radio. So ThirdFactor uses a **hybrid**: the hosted flow keeps handling document capture, liveness, and face match in the WebView, and the native SDK supplies the NFC read over the [capability bridge](/docs/sdks/bridge-protocol#8-native-capability-channel-bidirectional). This mirrors how Didit and other providers split the work — **read on device, validate on server**. The SDK reads raw data groups; the backend does the cryptography. ## End-to-end sequence ``` Applicant scans document (WebView) ──► MRZ OCR'd by the engine │ ▼ flow reaches the NFC step AND window.__thirdfactorHost.capabilities includes "nfc" Flow ── nfc.scan { documentNumber, dateOfBirth, dateOfExpiry } ─► Native SDK │ │ │ BAC/PACE + read DG1/DG2/EF.SOD │ Flow ◄── resolveCommand(id, { ok, data:{ sod_b64, dg1_b64, dg2_b64 }}) ─┘ │ ▼ POST /api/verify//nfc/ Backend ── passive authentication ──► resolve NFC_VERIFICATION module ``` Web (iframe/popup) hosts advertise no capabilities, so the flow keeps the server-side fallback: `NFC_VERIFICATION` → manual review (`nfc_not_available`). ## What each layer does ### Native SDK (device) — reads, does not validate - Advertises `capabilities: ["nfc"]` via `window.__thirdfactorHost` **only when the device NFC radio is available**. - Handles the `nfc.scan` command: derives the BAC key from the MRZ (`documentNumber` + `dateOfBirth` + `dateOfExpiry`) or a CAN for PACE, opens secure messaging, and reads DG1 (MRZ), DG2 (face), and EF.SOD (the signed hash set). Returns them base64 — **raw bytes only**. - Also exposes a **standalone** `read(...)` API for apps that want NFC outside the hosted flow. | Platform | Library | Standalone entry point | | --- | --- | --- | | iOS | [`NFCPassportReader`](https://github.com/AndyQ/NFCPassportReader) (`ThirdFactorKYCNFC` product) | `ThirdFactorNFC.read(mrzKey:dataGroups:)` | | Android | JMRTD + SCUBA + BouncyCastle (`ThirdFactorNfc`) | `ThirdFactorNfc.read(activity, accessKey, …)` | | Flutter | [`dmrtd`](https://github.com/ZeroPass/dmrtd) (`ThirdFactorNfc`) | `ThirdFactorNfc.read(documentNumber:…)` | ### Backend (server) — validates, decides `POST /api/verify//nfc/` runs **passive authentication**: Decision mapping: | Condition | `NFC_VERIFICATION` result | | --- | --- | | a DG hash doesn't match the SOD (tamper) | `declined` | | SOD unparseable / no DGs to check | `in_review` | | integrity OK, no CSCA masterlist configured | `in_review` | | integrity OK + CSCA chain verified | `approved` | ## Two integration paths ### Flow-delegated (automatic) If your workflow includes an `NFC_VERIFICATION` step and you launch the hosted flow with the SDK on an NFC device, the chip read **just works** — the flow sends `nfc.scan` with the MRZ it OCR'd, the SDK reads and forwards, and the backend validates. On Android and Flutter this needs no extra code beyond `verify`. On iOS you opt in by setting a native command handler: ```swift var options = VerifyOptions() if #available(iOS 15, *) { options.nativeCommandHandler = ThirdFactorNFC.commandHandler() } let result = await ThirdFactor.verify(from: self, url: sessionURL, options: options) ``` Without a handler (or on a device without NFC), the flow keeps the server-side fallback (NFC step → manual review). The delegation is entirely driven by the [capability handshake](/docs/sdks/bridge-protocol#81-handshake-host-flow): the SDK advertises `["nfc"]` only when the radio is on, and the flow calls `nfc.scan` only when the host advertised it. ### Standalone (read outside the flow) Each mobile SDK exposes a standalone read for custom UI, offline enrolment, or re-verification. All three return the same `nfc.scan` `data` shape — base64 DGs plus `aa` / `chipAuth` `{ performed, success }` — which you POST to your own backend for passive authentication. ## Per-platform setup NFC needs entitlements/permissions and a dependency the core SDK doesn't pull in by default. Full details live in each SDK page; the essentials: ## Data groups The chip is an ICAO-9303 Logical Data Structure. Only a handful of elementary files matter for KYC; the SDKs default to the three the backend needs and let you request more: | File | What it holds | Default read? | | --- | --- | --- | | `EF.SOD` (`SOD`) | The signed hash set over all data groups — the anchor for passive authentication. | ✅ | | `DG1` | The MRZ, mirrored from the chip (name, document number, dates, nationality). | ✅ | | `DG2` | The encoded face image — used for face matching against the live selfie. | ✅ | | `DG11` | Additional personal details (optional). | ❌ (request explicitly) | | `DG12` | Additional document details (optional). | ❌ (request explicitly) | | `DG15` | Active Authentication public key — reading it lets the chip prove it's genuine, not cloned. | ⚠️ iOS/Flutter attempt AA when present; request explicitly to force it | | `EF.COM` (`COM`) | The list of data groups actually present on the chip. | ❌ (read best-effort for capability detection) | ## Error codes The `nfc.scan` result (and the standalone read failures) use a stable set of codes — branch on the code, never the message: | `code` | Meaning | Typical UX | | --- | --- | --- | | `user_cancelled` | User dismissed the NFC sheet | Let them retry | | `tag_lost` | Chip moved away mid-read | "Hold still against the phone" | | `wrong_key` | MRZ/CAN didn't match the chip's access key | Re-capture the MRZ | | `unsupported` | Document/chip type not supported (e.g. CAN-only on iOS, `mrzKey`-only on Android/Flutter) | Fall back to manual path | | `nfc_disabled` | NFC turned off at the OS level | Prompt to enable NFC | | `timeout` | No chip presented within the window (bridge default 180 s) | Try again | ## Troubleshooting ## FAQ ## Related --- ## SDKs **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/overview **Description:** Thin native shells around the same hosted verification flow — one session model, one result vocabulary, one bridge. ThirdFactor ships server SDKs for common backend stacks plus four client SDKs — **web**, **iOS**, **Android**, and **Flutter**. Server SDKs create sessions, select workflows, read decisions, and verify webhooks. Each client SDK is a thin shell around the *same* hosted verification flow (`/verify/`): it opens the flow, listens for lifecycle events over a small bridge, and resolves **one** normalized result. Document capture, liveness, and face matching remain server-driven by your configured workflow. ## Why a thin shell, not a fat SDK Most identity-verification SDKs bundle a camera pipeline, a document classifier, and a liveness model into the client — which means every capture bug, every model update, and every new document type ships as an app-store release. ThirdFactor inverts that: the actual verification work (document OCR, liveness, face match, AML) runs **server-side, inside the hosted flow**, and the SDK's only job is to present that flow and translate its lifecycle into one typed result. Ship a new liveness method or unlock a new document type from the Console, and every existing app on every platform picks it up on the next session — no client release required. ## The shared model Every SDK follows the same four-step shape. The only per-platform difference is *how* the flow is presented (browser surface vs. native WebView) and whether NFC is available. ## The normalized result Every SDK maps the raw server status onto the same small vocabulary (field casing follows each language's conventions): | `status` | When | Your move | | --- | --- | --- | | `approved` | Flow approved the applicant | Continue (after server confirm) | | `declined` | Flow rejected the applicant | Block / offer appeal | | `in_review` | Needs an operator / AML hit / any **unknown** terminal status (conservative by design) | Show "pending", wait for the webhook | | `cancelled` | User closed the surface, or the server reported `abandoned` | Let them retry | | `error` | Invalid/expired session or a load failure (carries `error.code`) | Surface `error.code`, retry / re-mint the session | The result also carries `sessionId` (when the flow sends it) and `rawStatus` (the original server string), so you can distinguish an explicit `abandoned` from a user closing the surface. The exact mapping is defined once in the [bridge protocol](/docs/sdks/bridge-protocol#5-normalized-result-host-app). ## Which SDK do I need? Pick based on where your app already lives — not on which SDK has more features, because the underlying verification capability is identical everywhere. | Your app is... | Use | Why | | --- | --- | --- | | A Python backend | [Python server SDK](/docs/sdks/python) — `thirdfactor-sdk` | Typed errors, session helpers, workflow selection, decisions, and webhook verification without hand-written HTTP/HMAC code. | | A .NET backend | [.NET server SDK](/docs/sdks/dotnet) — `ThirdFactor.SDK` | Async session and decision helpers plus constant-time webhook verification for .NET 6 and newer applications. | | A Java backend | [Java server SDK](/docs/sdks/java) — `ai.thirdfactor:thirdfactor-sdk` | Java 11 HTTP client, typed API failures, workflow selection, decisions, and signed-webhook verification. | | A website / web app (React, Next.js, plain JS) | [Web SDK](/docs/sdks/web) — `@thirdfactor/web` | Popup or redirect works in any browser; no native build. | | A native iOS app (Swift/UIKit/SwiftUI) | [iOS SDK](/docs/sdks/ios) — `ThirdFactorKYC` | Zero third-party deps in the core product; native NFC via a second product. | | A native Android app (Kotlin/Compose) | [Android SDK](/docs/sdks/android) — `com.thirdfactor.kyc` | Idiomatic Activity Result contract; NFC (JMRTD) bundled, not opt-in. | | A cross-platform app (Flutter) | [Flutter SDK](/docs/sdks/flutter) — `thirdfactor_kyc` | One Dart API for both platforms; NFC via `dmrtd` bundled inside the package. | | A React Native app | Roll your own host against the [bridge protocol](/docs/sdks/bridge-protocol) | No packaged SDK yet — `react-native-webview`'s `onMessage` already matches the transport the protocol documents (§4), so hosting it yourself is a small, well-specified job. | | No app at all — email/SMS link, backend-to-backend | No SDK — [hosted flow overview](/docs/hosted-flow/overview) | `POST /v3/session/` alone returns a `url` you can send anywhere; the applicant opens it in any browser. | | A non-Python backend service verifying users without any client UI | Call the [Sessions API](/docs/api-reference/sessions) directly and poll/webhook for the decision | The hosted flow does not require a client SDK; use the backend library for your language when available. | ## Capability matrix This matrix covers the four **client** SDKs. The Python SDK runs on your server and creates the session those clients open. Document capture, liveness, and face match all run **inside the hosted flow** (the WebView/iframe), so every SDK supports them identically. **NFC** needs the device NFC radio, which a WebView cannot reach — so it is a **native-only** capability present in the iOS, Android, and Flutter SDKs, not on web. | Dimension | Web | iOS | Android | Flutter | | --- | :--: | :--: | :--: | :--: | | Document verification | ✅ | ✅ | ✅ | ✅ | | Liveness (passive / colour-flash / 3D-action) | ✅ | ✅ | ✅ | ✅ | | Face match | ✅ | ✅ | ✅ | ✅ | | NFC ePassport / eID chip read | ❌ (server-side review fallback) | ✅ native | ✅ native | ✅ native | | Presentation surface | Popup / cross-origin modal iframe / full-page redirect | `WKWebView`, full-screen modal | `WebView`, full-screen `Activity` | `WebView`, full-screen pushed route | | Session creation helper included | ✅ `@thirdfactor/web/server` (optional) | ❌ — call `POST /v3/session/` yourself | ❌ — call `POST /v3/session/` yourself | ❌ — call `POST /v3/session/` yourself | | Distribution | npm (ESM) | Swift Package Manager | Gradle module (source today) | git / path dependency (`publish_to: none`) | | Minimum OS / runtime | Any evergreen browser | iOS 15+ | minSdk 24 (Android 7.0+) | Flutter ≥3.24.0, Dart ≥3.0.0 | | Result vocabulary carrier | `string` union type | `enum VerificationStatus` | `enum class VerificationStatus` | `enum VerificationStatus` | | Lifecycle events (`ready`/`completed`/`error`/`close`) | ✅ `onEvent` | ✅ `onEvent` | ✅ `onEvent` (separate from `VerifyOptions`, not parcelable) | ✅ `onEvent` | | Async style | `Promise` | `async`/`await` + completion handler | `suspend` + Activity Result callback + Compose | `Future` | NFC library per platform: iOS **NFCPassportReader** (`ThirdFactorKYCNFC` product), Android **JMRTD + SCUBA + BouncyCastle** (`ThirdFactorNfc`), Flutter **dmrtd** (`ThirdFactorNfc`). See the [NFC deep-dive](/docs/sdks/nfc). ## Package summary | Platform | Package / product | Distribution | | --- | --- | --- | | Python server | `thirdfactor-sdk` (import `thirdfactor`) | [PyPI](https://pypi.org/project/thirdfactor-sdk/) | | Node.js server | `@thirdfactor/sdk` | [npm](https://www.npmjs.com/package/@thirdfactor/sdk) | | Go server | `github.com/thirdfactorai/thirdfactor-go` | [Go module](https://pkg.go.dev/github.com/thirdfactorai/thirdfactor-go) | | PHP server | `thirdfactor/sdk` | [Packagist](https://packagist.org/packages/thirdfactor/sdk) | | Java server | `ai.thirdfactor:thirdfactor-sdk` | [Maven Central](https://central.sonatype.com/artifact/ai.thirdfactor/thirdfactor-sdk/0.1.0) | | .NET server | `ThirdFactor.SDK` | [NuGet](https://www.nuget.org/packages/ThirdFactor.SDK) | | Web (JS/TS) | `@thirdfactor/web` | npm (ESM) | | iOS (Swift) | `ThirdFactorKYC` + optional `ThirdFactorKYCNFC` | Swift Package Manager | | Android (Kotlin) | `com.thirdfactor.kyc` | Gradle module (Maven coordinate `com.thirdfactor:kyc-android` once published) | | Flutter | `thirdfactor_kyc` | git / path dependency (`publish_to: none`) | ## How embed mode works: the bridge handshake Every SDK talks to the flow the same way — this is the sequence behind `ThirdFactor.verify(...)` on every platform: See the [bridge protocol](/docs/sdks/bridge-protocol) for the full wire-level contract (message envelope, transport per platform, and the native capability channel), and the [NFC deep-dive](/docs/sdks/nfc) for step 4's `nfc.scan` detour in full. ## Hosted redirect vs. in-app SDK You are never required to use a client SDK — `POST /v3/session/` alone is a complete integration. | | Plain redirect (no SDK) | SDK (`verify(...)`) | | --- | --- | --- | | Setup | None — send the `url` anywhere (email, SMS, full-page redirect) | Install the package for your platform | | Presentation | Full browser tab / new window | In-app surface (popup, modal, native full-screen) | | Result delivery | `callback_url` redirect + your backend reads the decision | Typed `VerificationResult` returned in-process, **plus** the same webhook | | Best for | Backend-only integrations, links sent via email/SMS/chat, minimal frontend investment | Apps that want the applicant to stay in-product and want a typed client-side signal for UX (e.g. "pending" vs "failed" screens) | | NFC | Falls back to server-side review (browser can't read NFC either way) | Native on iOS/Android/Flutter | | Authoritative decision source | Always the webhook / `GET .../decision/` | Always the webhook / `GET .../decision/` — identical | ## Why the SDK can't create the session Session creation needs your **secret API key**. Putting that key in a mobile binary or browser bundle is a credential leak — both are extractable (a mobile binary can be decompiled; a browser bundle is plain-text on the wire to the client). Always mint the session on your backend with a single `POST /v3/session/`, then hand only the returned `url` to the client. See [Create a session](/docs/hosted-flow/create-session) and the [Sessions API reference](/docs/api-reference/sessions) for the full request/response contract. ## No SDK at all You never *have* to use an SDK. `POST /v3/session/` returns a hosted `url` you can simply redirect the browser to; the flow returns the user to your `callback_url` and your backend reads the decision. The SDKs exist to give you an in-app surface and a typed result — not to gate access to the flow. ## FAQ ## Related --- ## Python server SDK **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/python **Description:** thirdfactor-sdk — create verification sessions, retrieve decisions, manage risk lists, and verify signed webhooks from Python. The official Python SDK is a **server-only** client for the ThirdFactor Verify and Partner APIs. The PyPI distribution is `thirdfactor-sdk`; Python code imports it as `thirdfactor`. ## Requirements - Python 3.9 or newer - No runtime dependencies - A tenant API key from **Console → Settings → API keys** ## Installation ```bash pip install thirdfactor-sdk ``` ## Create a session ```python from thirdfactor import ThirdFactor tf = ThirdFactor( api_key=os.environ["THIRDFACTOR_API_KEY"], base_url="https://your-tenant.thirdfactor.ai", timeout=15, ) session = tf.sessions.create( vendor_data="user-42", workflow_id="4c615aec-e173-450d-8089-30d4edc835f3", callback_url="https://app.example.com/kyc/done", contact_email="user@example.com", idempotency_key="kyc-user-42", ) print(session["id"]) print(session["url"]) ``` Persist the session `id` and send only its hosted `url` to the applicant. Use a stable idempotency key for each logical attempt so a retry returns the original session without charging twice. ## Select a workflow List the workflows available to the API key's tenant: ```python workflows = tf.request("GET", "/v3/workflows/") for workflow in workflows: print(workflow["id"], workflow["name"], workflow["status"]) ``` Pass one of those IDs as `workflow_id` when creating the session. Omit it to use the tenant's default workflow. Existing sessions retain their frozen workflow snapshot; workflow edits affect newly created sessions. ## Retrieve a session or decision ```python session = tf.sessions.retrieve(session_id) decision = tf.sessions.decision(session_id) if decision["decision"] == "approved": grant_access() ``` The hosted UI result is advisory. Grant access, move money, or provision an account only after a signed webhook or this authenticated decision lookup. ## Verify webhooks Pass the untouched request bytes and the `X-Webhook-Signature` header before parsing JSON: ```python from thirdfactor import SignatureError try: event = tf.webhooks.construct_event( raw_body, signature_header, os.environ["THIRDFACTOR_WEBHOOK_SECRET"], ) except SignatureError: # Reject invalid, malformed, or stale deliveries. raise ``` The verifier uses constant-time HMAC comparison and a five-minute replay window by default. See [webhook signatures](/docs/webhooks/signatures) for the raw-body requirements. ## Handle API errors ```python from thirdfactor import APIError try: session = tf.sessions.retrieve(session_id) except APIError as error: print(error.status) # HTTP status print(error.body) # parsed API response print(error.request_id) # support correlation ID, when present ``` ## Resources - [PyPI: `thirdfactor-sdk`](https://pypi.org/project/thirdfactor-sdk/) - [Sessions API](/docs/api-reference/sessions) - [Workflows API](/docs/api-reference/workflows) - [Webhook events](/docs/webhooks/events) --- ## Web SDK **URL:** https://docs.v3.thirdfactor.ai/docs/sdks/web **Description:** @thirdfactor/web — open the hosted flow in a popup, cross-origin modal, or full-page redirect and resolve one typed result. `@thirdfactor/web` opens the hosted verification flow in a **popup**, a cross-origin **modal** iframe, or a full-page **redirect**, listens to the flow over the shared bridge, and resolves a single typed `VerificationResult`. It is a thin shell: your backend creates the session and gets a hosted `url`; the browser hands that `url` to the SDK. ## Requirements | | | | --- | --- | | Browser | Any modern evergreen browser (Chromium, Firefox, Safari) with `postMessage` + camera (`getUserMedia`). | | Bundler | ESM. Ships `.js` + `.d.ts`; works with Vite, webpack, Next.js, etc. | | Backend (for the session helper) | Node 18+ (global `fetch`) or any runtime with a WHATWG `fetch`. | | TypeScript | Optional; full types are bundled. | ## Installation ```bash npm install @thirdfactor/web ``` The package has **two entry points**, deliberately separated so your browser bundle never imports anything that touches your API key: ```ts ``` ## Quick start ## Presentation modes | Mode | Call | Best for | Notes | | --- | --- | --- | --- | | `auto` (default) | `verify({ url })` | Anything | Resolves to **popup** — a top-level context with reliable camera everywhere. | | `popup` | `verify({ url, mode: "popup" })` | Mobile web, zero-config | New window; most reliable camera. | | `modal` | `verify({ url, mode: "modal" })` | Desktop, in-page UX | Cross-origin iframe. **Opt-in** — the flow deployment must allowlist your origin (see below). | | `redirect` | `ThirdFactor.redirect(url)` | Max compatibility | Full-page navigate; does **not** return a Promise. Result arrives via `callback_url` + webhook. | ## API reference ### `ThirdFactor.verify(options): Promise ### `ThirdFactor.redirect(url: string): void` Full-page navigation to the flow. Does not return. The flow returns the user to the session's `callback_url` on completion; read the decision from your backend on that return page. Best camera compatibility. ### `VerificationResult` ```ts interface VerificationResult { status: "approved" | "declined" | "in_review" | "cancelled" | "error"; sessionId?: string; // echoed from the flow when available error?: { code: string; message: string }; // present only when status === "error" rawStatus?: string; // raw server status (e.g. "abandoned") } ``` ### `VerificationEvent` The low-level, per-message stream passed to `onEvent`. One `VerificationResult` is still resolved separately once the surface settles. ```ts type VerificationEvent = | { type: "ready" } | { type: "completed"; status: string; sessionId?: string } | { type: "error"; code: string; message?: string } | { type: "close" }; ``` ## Complete API reference Every exported symbol from `@thirdfactor/web` and `@thirdfactor/web/server`, verified against `sdks/web/src/`: | Export | Entry point | Type | Notes | | --- | --- | --- | --- | | `ThirdFactor` | `@thirdfactor/web` (default + named) | `{ verify, redirect }` | Namespace object; `import { ThirdFactor } from "@thirdfactor/web"`. | | `verify(options)` | `@thirdfactor/web` | `(VerifyOptions) => Promise ## The server helper: `createSession(config, input)` `@thirdfactor/web/server` wraps `POST /v3/session/` so you don't hand-roll the request. It runs **only** on your server. - `config`: `{ baseUrl, apiKey, fetchImpl? }` - `input` (all fields optional): | Field | Type | Maps to `POST /v3/session/` | Notes | | --- | --- | --- | --- | | `workflowId` | `string` | `workflow_id` | KYC workflow UUID; tenant default when omitted. | | `vendorData` | `string` | `vendor_data` | Your external user reference (also the linked identity's external id). | | `callbackUrl` | `string` | `callback_url` | Where the applicant is redirected after finishing. | | `contactEmail` / `contactPhone` | `string` | `contact_email` / `contact_phone` | OTP + notifications. | | `expiresInHours` | `number` | `expires_in_hours` | Hosted-URL lifetime, `1..720` (default `48`). | | `locale` | `string` | `locale` | Hosted-flow language (default `en`). | | `prefill` | `Record` | `prefill` | Applicant fields to pre-populate (only documented keys honored). | | `idempotencyKey` | `string` | `Idempotency-Key` header | Repeat creates return the original session, uncharged. | The helper returns the session object (`{ id, url, status, decision, expiresAt, ... }`). On a non-2xx response it throws `ThirdFactorApiError` with `.status` and a stable `.code` (e.g. `insufficient_credits`). ## Enabling `modal` (cross-origin iframe) `modal` embeds the flow in an iframe on **your** origin. For that to work, the flow deployment must allowlist your app origin — otherwise the browser refuses the frame (`X-Frame-Options`) and denies the iframe camera (`Permissions-Policy`). On the Obsidian flow deployment set: ```bash FLOW_EMBED_ORIGINS="https://app.acme.com https://staging.acme.com" ``` This adds your origins to the flow route's `frame-ancestors` and grants them `camera`/`microphone`. Every other route keeps `X-Frame-Options: DENY`. With `FLOW_EMBED_ORIGINS` unset (the default), cross-origin `modal` is refused by design — use `auto` (popup) or `redirect`. ## Error handling `status === "error"` carries a stable `error.code` — branch on it, never on `error.message`: | `error.code` | Cause | | --- | --- | | `invalid_session` | Token is not a valid session. | | `expired` | Session/URL expired. | | `popup_blocked` | `verify()` was not called from a user gesture. | | `load_error` | The flow surface failed to load. | | `missing_url` | `options.url` was empty. | | `no_window` | `verify()` ran outside a browser. | | `network` / `unknown` | Transport failure / unrecognized error. | ```ts const result = await ThirdFactor.verify({ url: session.url }); if (result.status === "error") { if (result.error?.code === "expired") reMintSession(); else showError(result.error); } ``` ## Language, prefill, and metadata The hosted flow's language, copy, prefill, and workflow are chosen at **session-create time**, not in the browser SDK: - **Locale** — pass `locale` to `createSession` (or `"locale"` in the raw `POST /v3/session/` body). The SDK renders whatever the flow serves; no per-platform i18n code. - **Prefill** — pass `prefill` to pre-populate the flow (`full_name`, `first_name`, `last_name`, `display_name`, `date_of_birth`, `email`, `phone`, `nationality`, `address_line1/2`, `city`, `state`, `country`, `zip_code`). Unknown keys are ignored. - **Your reference** — `vendorData` becomes the session's external reference and the linked identity's `external_user_id`; it comes back on the session and every webhook. Browser-side advanced knobs are just `expectedOrigin` (proxy override) and `autoClose`. ## NFC on the web **The web SDK has no NFC module.** Reading an ePassport / eID chip requires the device NFC radio, which a browser cannot reach. When a workflow includes an `NFC_VERIFICATION` step, the web host advertises no `nfc` capability, so the flow's server-side fallback applies (the NFC step resolves to manual review). NFC is a native-only capability — see the [NFC deep-dive](/docs/sdks/nfc) and the [iOS](/docs/sdks/ios), [Android](/docs/sdks/android), and [Flutter](/docs/sdks/flutter) SDKs. ## How it works `verify()` opens the hosted flow with `?embed=1&sdk=web` appended. In embed mode the flow reports lifecycle over `postMessage` instead of redirecting. The SDK validates every message against the flow's origin and the `thirdfactor` source tag before acting. The same bridge protocol backs the Flutter, iOS, and Android SDKs — see the [bridge protocol](/docs/sdks/bridge-protocol). ## Full end-to-end example A complete Next.js-style integration: a server route that mints the session, and a client component that opens it and reports the result. ## Troubleshooting ## FAQ ## Related --- ## Events **URL:** https://docs.v3.thirdfactor.ai/docs/webhooks/events **Description:** The KYC session and application event types, and exactly when each fires. Two families of events reach your webhook endpoint: ThirdFactor Verify **session** events and **application** workflow events. Route on the `event_type` field, which delivery storage adds to every payload. This page lists every `event_type` in use today, a full example payload for each, and exactly which status transition produces it. ## KYC session events Terminal ThirdFactor Verify sessions (`/v3`) emit the flat session payload with one of these `event_type` values: | `event_type` | Fires when | | --- | --- | | `identity.kyc.session.approved` | The session passed every enabled module, or an operator approved it in review. | | `identity.kyc.session.declined` | The session failed a module, or an operator declined it in review. | | `identity.kyc.session.review` | The session is held for manual review (for example, an AML hit or a low-confidence check). | | `identity.kyc.session.expired` | The hosted URL lapsed before the applicant reached a decision. | The payload is flat — branch on the top-level `decision` (mirrored by `status`) and identify the user by `vendor_data`. ## Application events Applications (`/api/v1`) emit an event when they reach one of three terminal (or terminal-for-now) states. The **legacy `event` string differs by outcome** — this is a common source of confusion, so treat the table below, not a naming pattern, as the source of truth: | Application `status` | `application.decision` | `event_type` fired | Payload | | --- | --- | --- | --- | | `completed` | `approved` | `identity.verification.completed` | Full envelope | | `sdk_callback_received` | `approved` | `identity.verification.completed` | Full envelope | | `kyc_failed` | `rejected` | `identity.verification.failed` | Full envelope | | `manual_review` | `manual_review` | `identity.application.manual_review` | Full envelope, `blocklist_match` populated when the trigger was a risk-list hit | ### Statuses that do not currently fire a webhook Not every status transition is wired to a delivery. If you're waiting on an event that never arrives, check whether the status you're watching is on this list before assuming a delivery failure: | Status (session or application) | Fires an event? | | --- | --- | | `not_started`, `in_progress`, `awaiting_user`, `resubmitted` (session) | No — not terminal | | `draft`, `awaiting_form`, `form_started`, `form_filled`, `scheduled`, `sdk_redirected`, `visit_branch` (application) | No — not terminal | | `abandoned` (session or application) | **No classic event fires.** A Connect endpoint subscribed to `session.abandoned` does receive it — see [Integrations](/docs/integrations/events). Otherwise poll `GET /v3/session/` / `GET /api/v1/applications/` with a `status` filter. | | `kyc_expired` (session) | No event fires; distinct from `expired` (hosted-URL TTL), which does fire `identity.kyc.session.expired`. | ## Flow-spawned sessions emit both A ThirdFactor Verify session created by a hosted flow's identity step is linked to a parent application. When it settles it emits its own `identity.kyc.session.*` event **and** advances the parent application, which emits the usual application event from the table above. In that case the session payload's `application_id` is set. If you subscribe to both families, deduplicate at the application level so you don't process the same customer twice — the two events have **different** `idempotency_key` values because they're genuinely different deliveries. For pure `/v3` sessions there is no parent application, `application_id` is `null`, and only the `identity.kyc.session.*` event fires. ## Retries and ordering Delivery is at-least-once, which has two consequences: - **Events can arrive more than once.** Deduplicate on `idempotency_key`. See [Webhooks overview](/docs/webhooks/overview) for the exact backoff schedule. - **Events can arrive out of order.** Do not assume `.review` lands before `.approved`. Compare `created_at`, or re-read the current decision from `GET /v3/session//decision/` when an event looks stale. ## FAQ ## Related --- ## Webhooks overview **URL:** https://docs.v3.thirdfactor.ai/docs/webhooks/overview **Description:** Signed server-to-server events — the authoritative channel for verification outcomes. Obsidian pushes a signed HTTP `POST` to your endpoint when something decisive happens: a verification session settles, an application changes decision, or a risk-list match forces a review. The webhook is the channel you build on — the result your browser or mobile SDK receives is a UX hint, nothing more. Configure the endpoint URL and signing secret in **Console → Developers → Webhooks**. That screen also lists every delivery attempt with its response code, lets you resend a delivery, and has a test-send button. ## Why a webhook and not just the redirect result Use the client result to update the UI. Use the webhook — or a direct `GET .../decision/` poll — to gate anything that matters: account creation, fund transfers, access grants. ## Delivery lifecycle Every delivery is a row in the tenant's `WebhookDelivery` log (visible in **Console → Developers → Webhooks**) that moves through three states: ```text enqueue_delivery() │ ▼ ┌───────────────┐ application/ │ PENDING │ HTTP POST, 15s timeout session reaches │ attempt = 0 │ X-Webhook-Signature + a terminal state└───────┬───────┘ X-Webhook-Delivery headers │ immediate in-process attempt (attempt 1) │ ┌─────────────┴─────────────┐ │ │ 2xx response non-2xx / timeout / │ SSRF-blocked / DNS fail ▼ ▼ ┌───────────────┐ ┌────────────────────┐ │ DELIVERED │ │ retry scheduled at │ │ delivered_at │ │ next_attempt_at │ │ set, done │ │ (backoff schedule) │ └───────────────┘ └──────────┬──────────┘ │ Celery beat polls every 60s for due rows → attempt_delivery() again │ ┌───────────────┴───────────────┐ │ │ 2xx response non-2xx again, │ attempt >= max attempts ▼ ▼ ┌───────────────┐ ┌────────────────────┐ │ DELIVERED │ │ FAILED │ └───────────────┘ │ (visible + resend- │ │ able in console) │ └────────────────────┘ ``` The first attempt happens **synchronously**, in-process, at the moment the event is enqueued — so a healthy endpoint typically sees the delivery within the same request cycle that produced the decision. Only failures fall through to the polling retry path. ## Retry and backoff behavior Delivery is **at-least-once**. A failed attempt (any non-`2xx` status, a timeout, a DNS failure, or an SSRF-guard rejection) is retried on a fixed backoff schedule, doubling the previous gap roughly five-to-tenfold each step: | Attempt | Trigger | Delay before this attempt | | --- | --- | --- | | 1 | Immediate, in-process, when the event is enqueued | — | | 2 | Celery beat retry pass (runs every 60s, picks up due rows) | 1 minute | | 3 | Retry pass | 5 minutes | | 4 | Retry pass | 30 minutes | | 5 | Retry pass | 1 hour | | 6 (final) | Retry pass | 24 hours | If attempt 6 also fails, the delivery is marked `failed` permanently — it will not be retried automatically again, but it stays visible in **Console → Developers → Webhooks** and you (or your tenant admin) can trigger a manual **Resend**, which creates a fresh delivery row with the **same `idempotency_key`** and makes one synchronous attempt immediately. Consequences of this model, worth designing around: - **The same event can arrive more than once.** A resend, a retry racing a slow-but-eventually-successful original attempt, or an operator-triggered resend of an already-delivered event can all put the same `idempotency_key` on your queue twice. - **Events can arrive out of order.** A `.review` event's retry can be in-flight when the `.approved` event that supersedes it fires and succeeds on the first try. Don't assume monotonic arrival — compare `created_at`, or re-read the authoritative decision from the API when in doubt. - **An endpoint that is down for more than ~26 hours loses events for good** (1 + 5 + 30 + 60 + 1440 minutes of backoff ≈ 26 hours of retrying). Anything that fails permanently is still visible and resendable in the console, but nothing pages you automatically — monitor delivery health from your side too (see Troubleshooting below). ## Two envelope shapes There are two families of events, and their payloads differ. Both are signed the same way and both add `event_type` and `idempotency_key`. ### The application envelope, field by field | Field | Type | Meaning | | --- | --- | --- | | `payload_version` | string | Schema version of the envelope (currently `2026-05-17`). | | `event` | string | Legacy event name — see [Events](/docs/webhooks/events) for the exact strings in use today. | | `application` | object | Full application snapshot: `id`, `external_ref`, `tracking_id`, `status`, `decision`, `flow_id`, `current_step_id`, `created_at`, `updated_at`. | | `form_data` | object | Pre-SDK applicant data plus any product selection. | | `product` | object \| null | Selected product/plan, when the flow captured one. | | `sdk_response` | object | Raw verification result from the identity step. | | `blocklist_match` | object \| null | Present only on a risk-list hit; carries `blocklist_id`, `blocklist_name`, `block_type`, `value`, `action`. | | `created_at` | string | When the event was generated. | | `event_type` | string | Added by delivery storage; mirrors `event`. Prefer this for routing. | | `idempotency_key` | string | Added by delivery storage; stable across retries and resends of the same event. | ## Headers on every delivery | Header | Present when | Value | | --- | --- | --- | | `Content-Type` | Always | `application/json` | | `X-Webhook-Signature` | A signing secret is configured for the tenant | `t=,v1=` — see [verifying signatures](/docs/webhooks/signatures) | | `X-Webhook-Delivery` | Always | The `WebhookDelivery` row's UUID. Stable across retries of the same attempt chain; useful for correlating your logs with the console's delivery list. | ## Designing an idempotent webhook handler Because delivery is at-least-once, your handler must treat "processed already" as the normal case, not an edge case. The shape that holds up in production: ## Handling deliveries well ## Configuring in the console 1. Open **Console → Developers → Webhooks**. 2. Set the destination URL. It must be publicly reachable over **HTTPS** and pass the SSRF guard — private, loopback, link-local, and reserved IP ranges are rejected (both IPv4 and IPv6), redirects are not followed, and a rejected delivery is recorded as `failed` with the reason for diagnosis. 3. Copy the signing secret and store it in your server config. Rotating it in the console invalidates the previous secret immediately. 4. Use the test-send button to fire a delivery and confirm your endpoint returns `2xx` and verifies the signature. ## Troubleshooting ## FAQ ## Next --- ## Verifying signatures **URL:** https://docs.v3.thirdfactor.ai/docs/webhooks/signatures **Description:** HMAC-SHA256 over the raw body with a timestamp — verify before you parse. Every delivery carries a Stripe-style `X-Webhook-Signature` header signed with your tenant webhook secret (managed in **Console → Developers → Webhooks**): ```text X-Webhook-Signature: t=,v1= ``` Verify it before you trust anything in the payload. An unsigned or mis-signed delivery is either a misconfiguration or a spoof attempt — reject it. ## Why HMAC + a timestamp, not just HMAC A bare `HMAC-SHA256(secret, body)` proves the body wasn't tampered with, but it does nothing to stop a captured request from being **replayed** later — the signature would still check out. Folding the Unix timestamp into the signed string (`"."`) means: - An attacker who captures a valid `(header, body)` pair can't change `t` to make the replay look fresh — that changes the signed string and breaks `v1`. - An attacker who replays the *original* `t` unchanged gets caught by the staleness check (reject anything more than 5 minutes old). Both checks are required — the HMAC alone doesn't prevent replay, and the timestamp alone (without being part of the signed input) doesn't prevent tampering. ## The algorithm 1. Split the header into `t` and `v1`. 2. Reject if `t` is stale — older than 5 minutes — to guard against replay. 3. Compute `HMAC-SHA256(secret, "." + raw_request_body)` as lowercase hex. 4. Compare it to `v1` in **constant time**. 5. Only then parse the JSON. ## Reference implementations The Python example below is the canonical one from the Obsidian API reference. ## Complete Node.js / TypeScript example The snippet above is the verification primitive; this is what it looks like wired into a full, dependency-light handler — raw body capture, signature check, fast acknowledgement, and a minimal idempotency guard — using only Node's built-in `http` module (no framework required): ```ts server.ts const WEBHOOK_SECRET = process.env.TF_WEBHOOK_SECRET!; const MAX_BODY_BYTES = 1_000_000; // 1MB — generous for these payloads const seenKeys = new Set(); // swap for a real store (Redis, Postgres) in production function verify(secret: string, header: string, rawBody: Buffer): boolean { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = parts.t; const v1 = parts.v1; if (!t || !v1) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5-minute window const expected = crypto .createHmac("sha256", secret) .update(`${t}.`) .update(rawBody) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(v1, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } const server = http.createServer((req, res) => { if (req.method !== "POST" || req.url !== "/webhooks/obsidian") { res.writeHead(404).end(); return; } const chunks: Buffer[] = []; let size = 0; req.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_BODY_BYTES) { req.destroy(); // guard against an oversized body tying up the process return; } chunks.push(chunk); }); req.on("end", () => { const rawBody = Buffer.concat(chunks); const signature = req.headers["x-webhook-signature"]; if (typeof signature !== "string" || !verify(WEBHOOK_SECRET, signature, rawBody)) { res.writeHead(400, { "Content-Type": "text/plain" }).end("bad signature"); return; } let event: any; try { event = JSON.parse(rawBody.toString("utf8")); } catch { res.writeHead(400, { "Content-Type": "text/plain" }).end("invalid json"); return; } // Ack immediately — Obsidian's 15s timeout starts from connect, not from // when you finish business logic. Don't make the sender wait on your DB. res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'); // Idempotency: same key means a retry or a manual resend of an event // you've already seen. In production, back this with a persistent store // (unique constraint on idempotency_key), not an in-memory Set. const key = event.idempotency_key; if (key && seenKeys.has(key)) return; if (key) seenKeys.add(key); void processEvent(event).catch((err) => { console.error("webhook processing failed", key, err); // Route to your own retry/alerting path — Obsidian already got its 2xx. }); }); req.on("error", () => res.writeHead(400).end()); }); async function processEvent(event: any): Promise { switch (event.event_type) { case "identity.kyc.session.approved": // event.vendor_data identifies your user break; case "identity.kyc.session.declined": break; case "identity.application.manual_review": // event.application.decision === "manual_review" break; default: // Unrecognised event_type — log and move on; don't throw. console.warn("unhandled event_type", event.event_type); } } server.listen(3000, () => console.log("listening on :3000")); ``` ## Wiring it into an Express handler Grab the raw body, verify, acknowledge, then process off the request thread. ```ts Express const app = express(); app.post( "/webhooks/obsidian", express.raw({ type: "application/json" }), // raw body — not express.json() (req, res) => { const sig = req.header("X-Webhook-Signature") ?? ""; if (!verify(process.env.TF_WEBHOOK_SECRET!, sig, req.body)) { return res.status(400).send("bad signature"); } const event = JSON.parse(req.body.toString("utf8")); res.status(200).end(); // ack fast void queue.enqueue(event); // work later, deduped on idempotency_key }, ); ``` ## Testing webhooks locally Your development machine isn't publicly reachable, so Obsidian can't `POST` to `http://localhost:3000` directly. Two ways to test: ## Replay protection The timestamp `t` is part of the signed string, so an attacker cannot replay an old body with a fresh timestamp — changing `t` breaks the signature, and reusing the original `t` trips the 5-minute staleness check. Keep these two guards: - **Reject stale timestamps.** More than 5 minutes of skew is rejected. Keep your server clock in sync (NTP). - **Deduplicate on `idempotency_key`.** Legitimate retries reuse the same key, so recording processed keys both dedupes retries and blocks in-window replays. ## Rotating the secret Rotating the secret in **Console → Developers → Webhooks** invalidates the previous one immediately. Plan a brief window, or point the new secret at a staging consumer and cut over once it verifies cleanly. ## FAQ ## Related --- ## Links - [GitHub](https://github.com/thirdfactor) - [Support](mailto:support@thirdfactor.ai)