Verification
SDKs
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/<session_token>): 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.
Python server
thirdfactor-sdk on PyPI — sessions, decisions, lists, and signed webhooks.
.NET server
ThirdFactor.SDK on NuGet — sessions, decisions, and signed webhooks.
Java server
ai.thirdfactor:thirdfactor-sdk on Maven Central — Java 11+.
Web
@thirdfactor/web — popup, cross-origin modal, or full-page redirect.
iOS
ThirdFactorKYC (SwiftPM) — UIKit + SwiftUI, optional native NFC.
Android
com.thirdfactor.kyc — Activity Result, coroutines, Compose, built-in NFC.
Flutter
thirdfactor_kyc — one verify(context, url:) call, native NFC.
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.
Your backend mints a session
One authenticated POST /v3/session/ with your secret tenant API key returns { id, url, ... }. Session creation needs the secret key, so it must run on your server — never in a client bundle.
Your app receives the url
Send the hosted url (and keep the id) down to the client from your API.
The SDK opens the flow
The SDK opens url?embed=1&sdk=<platform>. In embed mode the flow reports lifecycle over the bridge instead of redirecting to the session's callback_url.
You get one result; your backend gets the webhook
The SDK resolves exactly one VerificationResult — approved / declined / in_review / cancelled / error. Your backend confirms the authoritative decision from the signed webhook or GET /v3/session/<id>/decision/.
Warning
The client result is advisory. It is a UX signal only — grant access or move money only after your backend confirms the decision from the signed webhook (identity.kyc.session.approved / .declined / .review / .expired) or GET /v3/session/<id>/decision/. A user can close the surface after approval or tamper with the client; the webhook is HMAC-signed and cannot be spoofed.
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.
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 — thirdfactor-sdk | Typed errors, session helpers, workflow selection, decisions, and webhook verification without hand-written HTTP/HMAC code. |
| A .NET backend | .NET server SDK — ThirdFactor.SDK | Async session and decision helpers plus constant-time webhook verification for .NET 6 and newer applications. |
| A Java backend | Java server SDK — 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 — @thirdfactor/web | Popup or redirect works in any browser; no native build. |
| A native iOS app (Swift/UIKit/SwiftUI) | iOS SDK — ThirdFactorKYC | Zero third-party deps in the core product; native NFC via a second product. |
| A native Android app (Kotlin/Compose) | Android SDK — com.thirdfactor.kyc | Idiomatic Activity Result contract; NFC (JMRTD) bundled, not opt-in. |
| A cross-platform app (Flutter) | Flutter SDK — 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 | 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 | 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 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. |
Tip
Building for both iOS and Android as two separate native codebases (not Flutter/React Native)? You still integrate each platform's SDK independently — there is no combined "mobile" package. Each one is a thin, single-platform shell around the same hosted flow, so the integration code is nearly identical, just not shared.
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.
Package summary
| Platform | Package / product | Distribution |
|---|---|---|
| Python server | thirdfactor-sdk (import thirdfactor) | PyPI |
| Node.js server | @thirdfactor/sdk | npm |
| Go server | github.com/thirdfactorai/thirdfactor-go | Go module |
| PHP server | thirdfactor/sdk | Packagist |
| Java server | ai.thirdfactor:thirdfactor-sdk | Maven Central |
| .NET server | ThirdFactor.SDK | NuGet |
| 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:
1. Open with embed params
The SDK appends ?embed=1&sdk=<platform> to the hosted url before loading it (sdk is one of web / ios / android / flutter). embed=1 tells the flow to report lifecycle to a host instead of redirecting the browser to callback_url when it finishes.
2. (Native only) the host announces its capabilities
Before the page finishes loading, native SDKs inject window.__thirdfactorHost = { platform, sdkVersion, capabilities: [...] }. capabilities includes "nfc" only when the device actually has NFC hardware/radio available right now. The web SDK injects nothing, so the flow always sees zero host capabilities in a browser.
3. The flow mounts and confirms
Once the flow resolves the session and mounts its UI, it emits a ready event over every transport at once (postMessage on web, the named JS interface/channel on native). The SDK uses this to dismiss its own loading spinner.
4. The applicant works through the workflow
Document capture, liveness, face match, AML, and any other configured steps run entirely inside the flow's own UI — the SDK is just a passive window onto it. If the workflow reaches an NFC_VERIFICATION step and the host advertised "nfc" in step 2, the flow instead sends a command message (nfc.scan) and awaits a resolveCommand(...) response from the native SDK before continuing.
5. The flow reaches a terminal state
The flow emits a completed event carrying the raw server status (approved / declined / in_review / abandoned / …) and, when available, the sessionId. The SDK normalizes this into the shared VerificationStatus vocabulary and — unless autoClose: false — dismisses the surface immediately.
6. (If autoClose is false) the user taps Done
With autoClose: false, the flow keeps its own result screen visible. When the user taps Done, the flow emits a close event; the SDK dismisses. The result is still whatever the earlier completed event carried — close never overrides it.
7. Every SDK resolves exactly once
Whether the outcome was a completed, an error, or the user dismissing the surface early (back button, ✕, closed tab/window) before either arrived — which resolves cancelled — every verify(...) call settles with exactly one VerificationResult, never twice, and never throws.
See the bridge protocol for the full wire-level contract (message envelope, transport per platform, and the native capability channel), and the NFC deep-dive 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 |
Note
Both paths are equally secure and equally "real" integrations — Obsidian's own no-code links feature is the plain-redirect path taken to its extreme (no backend session-mint call per user, either). Choose based on UX, not capability.
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 and the Sessions API reference for the full request/response contract.
Tip
Pass an Idempotency-Key when creating the session so retries return the same session and aren't double-charged. Every SDK's quick-start example includes it.
Warning
Common pitfall: shipping the API key to the client "just for testing." Even in a debug build or a Simulator/emulator-only app, a hard-coded x-api-key gets extracted by anyone who decompiles the binary or inspects network traffic. Sandbox keys are still real credentials that spend real (sandbox) credits and can create real sessions against your tenant — mint sessions server-side even in development.
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.