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.

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 VerificationResultapproved / 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):

statusWhenYour move
approvedFlow approved the applicantContinue (after server confirm)
declinedFlow rejected the applicantBlock / offer appeal
in_reviewNeeds an operator / AML hit / any unknown terminal status (conservative by design)Show "pending", wait for the webhook
cancelledUser closed the surface, or the server reported abandonedLet them retry
errorInvalid/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...UseWhy
A Python backendPython server SDKthirdfactor-sdkTyped errors, session helpers, workflow selection, decisions, and webhook verification without hand-written HTTP/HMAC code.
A .NET backend.NET server SDKThirdFactor.SDKAsync session and decision helpers plus constant-time webhook verification for .NET 6 and newer applications.
A Java backendJava server SDKai.thirdfactor:thirdfactor-sdkJava 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/webPopup or redirect works in any browser; no native build.
A native iOS app (Swift/UIKit/SwiftUI)iOS SDKThirdFactorKYCZero third-party deps in the core product; native NFC via a second product.
A native Android app (Kotlin/Compose)Android SDKcom.thirdfactor.kycIdiomatic Activity Result contract; NFC (JMRTD) bundled, not opt-in.
A cross-platform app (Flutter)Flutter SDKthirdfactor_kycOne Dart API for both platforms; NFC via dmrtd bundled inside the package.
A React Native appRoll your own host against the bridge protocolNo 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-backendNo SDK — hosted flow overviewPOST /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 UICall the Sessions API directly and poll/webhook for the decisionThe 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.

DimensionWebiOSAndroidFlutter
Document verification
Liveness (passive / colour-flash / 3D-action)
Face match
NFC ePassport / eID chip read❌ (server-side review fallback)✅ native✅ native✅ native
Presentation surfacePopup / cross-origin modal iframe / full-page redirectWKWebView, full-screen modalWebView, full-screen ActivityWebView, 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
Distributionnpm (ESM)Swift Package ManagerGradle module (source today)git / path dependency (publish_to: none)
Minimum OS / runtimeAny evergreen browseriOS 15+minSdk 24 (Android 7.0+)Flutter ≥3.24.0, Dart ≥3.0.0
Result vocabulary carrierstring union typeenum VerificationStatusenum class VerificationStatusenum VerificationStatus
Lifecycle events (ready/completed/error/close)onEventonEventonEvent (separate from VerifyOptions, not parcelable)onEvent
Async stylePromiseasync/await + completion handlersuspend + Activity Result callback + ComposeFuture

NFC library per platform: iOS NFCPassportReader (ThirdFactorKYCNFC product), Android JMRTD + SCUBA + BouncyCastle (ThirdFactorNfc), Flutter dmrtd (ThirdFactorNfc). See the NFC deep-dive.

Package summary

PlatformPackage / productDistribution
Python serverthirdfactor-sdk (import thirdfactor)PyPI
Node.js server@thirdfactor/sdknpm
Go servergithub.com/thirdfactorai/thirdfactor-goGo module
PHP serverthirdfactor/sdkPackagist
Java serverai.thirdfactor:thirdfactor-sdkMaven Central
.NET serverThirdFactor.SDKNuGet
Web (JS/TS)@thirdfactor/webnpm (ESM)
iOS (Swift)ThirdFactorKYC + optional ThirdFactorKYCNFCSwift Package Manager
Android (Kotlin)com.thirdfactor.kycGradle module (Maven coordinate com.thirdfactor:kyc-android once published)
Flutterthirdfactor_kycgit / 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(...))
SetupNone — send the url anywhere (email, SMS, full-page redirect)Install the package for your platform
PresentationFull browser tab / new windowIn-app surface (popup, modal, native full-screen)
Result deliverycallback_url redirect + your backend reads the decisionTyped VerificationResult returned in-process, plus the same webhook
Best forBackend-only integrations, links sent via email/SMS/chat, minimal frontend investmentApps that want the applicant to stay in-product and want a typed client-side signal for UX (e.g. "pending" vs "failed" screens)
NFCFalls back to server-side review (browser can't read NFC either way)Native on iOS/Android/Flutter
Authoritative decision sourceAlways 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.

FAQ