Verification
Flutter SDK
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 and resolves a single typed VerificationResult; with the optional thirdfactor_kyc_nfc companion package it also reads ePassport / eID NFC chips natively. 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.
Warning
The client result is advisory. 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 background the app after approval or tamper with the client; the webhook is HMAC-signed and cannot be spoofed.
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 (optional) | The git-distributed thirdfactor_kyc_nfc companion package, backed by dmrtd v2.0.0 (ICAO-9303 BAC/PACE, secure messaging, EF/DG parsing) + its bundled flutter_nfc_kit ^3.6.x. Android minSdkVersion 23; iOS 13+ (15+ recommended), real device only. |
| SDK version | 1.3.0 (the value reported in the window.__thirdfactorHost handshake). |
Installation
# pubspec.yaml
dependencies:
thirdfactor_kyc: ^1.3.0or flutter pub add thirdfactor_kyc.
Optional — native NFC chip reading. The chip reader depends on dmrtd, which only exists as a git dependency, and pub.dev rejects packages with git dependencies — so the reader ships in the separate, git-distributed thirdfactor_kyc_nfc companion package:
dependencies:
thirdfactor_kyc: ^1.3.0
thirdfactor_kyc_nfc:
git:
url: https://github.com/thirdfactorai/thirdfactor-flutter.git
path: nfcThen inject it: ThirdFactor.verify(context, url: …, nfcReader: const ThirdFactorNfc()). Without the companion the SDK works with zero configuration — the hosted flow keeps its server-side NFC fallback (NFC step → manual review). The single import gives you the entire public surface:
import 'package:thirdfactor_kyc/thirdfactor_kyc.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).
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera" android:required="false" />The SDK sets setMediaPlaybackRequiresUserGesture(false) on the Android WebView and grants the WebView's PermissionRequest automatically, so the camera stream starts without a tap. On Android 6+ you must also obtain the runtime CAMERA permission before starting verification (e.g. with permission_handler) — the SDK keeps its dependencies minimal and does not request runtime permissions for you.
Precise location (only if a workflow enables Ask for precise location, optionally Require precise location to continue): declare both location permissions and pass requestLocationPermission, which the SDK calls when the flow asks — never at launch:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />options: VerifyOptions(
requestLocationPermission: () async =>
(await Permission.locationWhenInUse.request()).isGranted,
),The SDK answers the WebView's geolocation prompt for the flow's origin only (the plugin denies every prompt by default). A denial leaves the applicant on the flow's own retry screen.
Quick start
import 'package:thirdfactor_kyc/thirdfactor_kyc.dart';
Future<void> 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
The common path. The SDK owns the route and pops it with the result.
final result = await ThirdFactor.verify(
context,
url: sessionUrl, // hosted URL from POST /v3/session/
options: VerifyOptions(
title: 'Verify your identity',
onEvent: (e) => debugPrint('$e'), // ready | completed | error | close
),
);
switch (result.status) {
case VerificationStatus.approved: showSuccess();
case VerificationStatus.declined: showFailure();
case VerificationStatus.inReview: showPending();
case VerificationStatus.cancelled: /* user closed the flow */ break;
case VerificationStatus.error: showError(result.error);
}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<VerificationResult> verify(BuildContext context, {required String url, VerifyOptions? options, NfcChipReader? nfcReader}) | Pushes a full-screen route and resolves once. Never throws. A stray pop resolves cancelled. nfcReader injects native chip reading (normally const ThirdFactorNfc() from thirdfactor_kyc_nfc); null keeps the flow's server-side NFC fallback. |
ThirdFactorVerificationPage
The StatefulWidget behind verify(). Use it directly only when your app owns navigation.
| Field | Type | Default | Notes |
|---|---|---|---|
url | String | required | Hosted session URL from POST /v3/session/ (the url field). |
options | VerifyOptions? | null | Defaults are used when null. |
onResult | ValueChanged<VerificationResult>? | null | Invoked exactly once with the terminal result. |
popOnResult | bool | true | When true, pops the route with the result (how verify() receives it). Set false for custom navigation. |
nfcReader | NfcChipReader? | null | Native chip reader for the flow's NFC step (normally const ThirdFactorNfc() from thirdfactor_kyc_nfc). null = the SDK doesn't advertise nfc, and the flow keeps its server-side fallback. |
VerifyOptions
ThirdFactor.verify(context, {required url, options, nfcReader}) takes a VerifyOptions:
| Field | Type | Default | Notes |
|---|---|---|---|
title | String | 'Identity verification' | AppBar / accessible title for the screen. |
expectedOrigin | String? | origin of url | Origin the SDK trusts bridge messages from; only override behind a proxy that serves the flow on a different origin. 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 (the later close event then dismisses). |
onEvent | void Function(VerificationEvent)? | null | Every lifecycle event: ReadyEvent / CompletedEvent / ErrorEvent / CloseEvent. |
requestLocationPermission | Future<bool> Function()? | null | Android only. Called when the flow asks for precise location; request the OS permission (e.g. permission_handler) and return whether it was granted. null = rely on a permission the app already holds. See Platform setup. |
Result types
| Type | Shape | Notes |
|---|---|---|
VerificationResult | { status: VerificationStatus, sessionId: String?, rawStatus: String?, error: ThirdFactorError? } | The single normalized result. rawStatus preserves the original server string. |
VerificationStatus | enum { approved, declined, inReview, cancelled, error } | The small, shared vocabulary across all four SDKs. |
ThirdFactorError | { code: String, message: String } | Present only when status == error. Branch on code (invalid_session | expired | load_error | unknown), never on message. |
Lifecycle events (VerificationEvent)
A sealed hierarchy, so a switch over it is exhaustively checked by the analyzer.
| Event | Fields | Meaning |
|---|---|---|
ReadyEvent | — | Flow mounted and the session resolved OK — safe to hide your spinner. |
CompletedEvent | status: String, sessionId: String? | Terminal decision. status is the raw server status; the SDK normalizes it. |
ErrorEvent | code: String, message: String? | Unrecoverable (invalid/expired token, load failure). Branch on code. |
CloseEvent | — | User acknowledged the result screen (tapped Done). |
NFC types (NfcChipReader and friends)
Chip reading is injected: this package defines the NfcChipReader interface and the value types below, and the implementation — ThirdFactorNfc — lives in the git-distributed companion package:thirdfactor_kyc_nfc/thirdfactor_kyc_nfc.dart (see NFC verification).
| Symbol | Signature / shape | Notes |
|---|---|---|
NfcChipReader | abstract class { Future<bool> isAvailable(); Future<NfcReadResult> read({…}); } | The reader interface verify() / ThirdFactorVerificationPage take as nfcReader. Implement it yourself only to plug in a different chip reader. |
ThirdFactorNfc (from thirdfactor_kyc_nfc) | final class ThirdFactorNfc implements NfcChipReader — const ThirdFactorNfc() | The dmrtd-backed reader. Pass it as nfcReader, or call it directly for a standalone read. |
ThirdFactorNfc().isAvailable | Future<bool> isAvailable() | true when NFC hardware is on and usable. Never throws; false on web/desktop/simulator. |
ThirdFactorNfc().read | Future<NfcReadResult> read({required String documentNumber, required String dateOfBirth, required String dateOfExpiry, List<NfcDataGroup> dataGroups, String? canNumber, Duration timeout, String iosAlertMessage}) | Standalone chip read; see signature. |
NfcDataGroup | enum { dg1, dg2, dg11, dg12, sod, com } | Each carries a wireName (DG1, DG2, DG11, DG12, SOD, COM). Default read set is [dg1, dg2, sod]. |
NfcReadResult | { sodB64?, dg1B64?, dg2B64?, dg11B64?, dg12B64?, comB64?, aa, chipAuth } | Base64 elementary files + auth signals. toBridgeData() → the nfc.scan data shape. |
NfcAuthResult | { performed: bool, success: bool } | Advisory AA / chip-auth signal. .notPerformed() const for the empty case. |
NfcErrorCode | enum { userCancelled, tagLost, wrongKey, unsupported, nfcDisabled, timeout } | Each carries a wire string (user_cancelled, tag_lost, …). |
NfcException | { code: NfcErrorCode, message: String } | Thrown by read(). Branch on code, never on message. |
StatusMapper.normalize | static VerificationStatus normalize(String raw) | The raw→normalized mapping (PROTOCOL §5); exposed for advanced/debug use. |
Result handling
class VerificationResult {
final VerificationStatus status;
final String? sessionId; // echoed from the flow when available
final String? rawStatus; // original server string, e.g. "abandoned"
final ThirdFactorError? error; // when status == error
}VerificationStatus | When |
|---|---|
approved | Flow reported completed with server status approved. |
declined | completed with declined. |
inReview | completed with in_review / review / manual_review / pending, or any unknown terminal status (conservative). |
cancelled | completed with abandoned, or the user dismissed (AppBar close, or system back when the flow has no earlier step to go back to) before a terminal event. While the flow has an earlier step, system back steps back inside the flow instead. |
error | An error bridge event or a main-frame load failure. Carries error.code + error.message. |
Branch on error.code (invalid_session | expired | load_error | unknown), never on error.message:
final result = await ThirdFactor.verify(
context,
url: sessionUrl,
options: VerifyOptions(onEvent: (e) => debugPrint('$e')),
);
switch (result.status) {
case VerificationStatus.approved: proceed(); // confirm server-side first
case VerificationStatus.declined: showFailure();
case VerificationStatus.inReview: showPending();
case VerificationStatus.cancelled: /* let them retry */ break;
case VerificationStatus.error:
if (result.error?.code == 'expired') reMintSession();
else showError(result.error);
}Note
rawStatus lets you distinguish an explicit user cancel (no rawStatus) from a server-reported abandoned (both normalize to cancelled). Use it for analytics; keep your access decision gated on the server-confirmed decision, not on rawStatus.
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 the SDK reads the chip natively over the capability bridge and forwards the raw chip bytes for your backend to authenticate.
Chip reading is injected. The core package is on pub.dev, which rejects git dependencies, and the ICAO-9303 implementation (dmrtd v2.0.0 — BAC/PACE key derivation, secure messaging, EF/DG parsing, over its bundled flutter_nfc_kit) only exists as a git tag. So the reader, ThirdFactorNfc, ships in the git-distributed companion package thirdfactor_kyc_nfc, and you hand it to the SDK:
# pubspec.yaml
dependencies:
thirdfactor_kyc: ^1.3.0
thirdfactor_kyc_nfc:
git:
url: https://github.com/thirdfactorai/thirdfactor-flutter.git
path: nfcimport 'package:thirdfactor_kyc/thirdfactor_kyc.dart';
import 'package:thirdfactor_kyc_nfc/thirdfactor_kyc_nfc.dart';
final result = await ThirdFactor.verify(
context,
url: sessionUrl,
nfcReader: const ThirdFactorNfc(),
);(ThirdFactorVerificationPage(url: …, nfcReader: const ThirdFactorNfc()) works the same way.) From there it is automatic: the SDK advertises nfc only when a reader is passed and the device's NFC is on, and the flow sends nfc.scan only when it was advertised. Without a reader — or on a device without NFC — the flow keeps its server-side fallback (NFC → manual review); everything else works unchanged.
Warning
The SDK reads and forwards raw bytes only — it does not do passive authentication. Parsing EF.SOD, hashing the data groups, verifying the CSCA signer chain, and comparing DG1↔OCR / DG2↔selfie all happen server-side.
Setup — platform configuration
NFC needs extra configuration in the host app (the SDK packages are Dart packages and cannot declare these for you).
android/app/src/main/AndroidManifest.xml, and minSdkVersion 23 in android/app/build.gradle:
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />required="false" keeps the app installable on phones without NFC (the SDK degrades to the server-side fallback there).
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. ThirdFactorNfc comes from the companion package; the value types (NfcDataGroup, NfcException, NfcErrorCode, …) from the core:
import 'package:thirdfactor_kyc/thirdfactor_kyc.dart';
import 'package:thirdfactor_kyc_nfc/thirdfactor_kyc_nfc.dart';
const nfc = ThirdFactorNfc();
if (await nfc.isAvailable()) {
try {
final chip = await nfc.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 sdks/flutter_nfc/lib/src/nfc_reader.dart; the same shape as NfcChipReader.read):
// final class ThirdFactorNfc implements NfcChipReader { const ThirdFactorNfc(); … }
@override
Future<NfcReadResult> read({
required String documentNumber,
required String dateOfBirth, // YYMMDD
required String dateOfExpiry, // YYMMDD
List<NfcDataGroup> dataGroups = const [NfcDataGroup.dg1, NfcDataGroup.dg2, NfcDataGroup.sod],
String? canNumber, // PACE-by-CAN alternative to the MRZ fields
Duration timeout = const Duration(seconds: 60),
String iosAlertMessage = 'Hold your phone near the photo page of your document.',
});NfcReadResult carries the base64 elementary files (sodB64, dg1B64, dg2B64, optional dg11B64 / dg12B64 / comB64) plus aa / chipAuth ({ performed, success }) advisory signals. toBridgeData() returns the exact nfc.scan result shape.
Note
chipAuth is always notPerformed — dmrtd's high-level Passport API doesn't expose EAC Chip Authentication; the backend performs it from DG14 when present. aa (Active Authentication) is attempted automatically when the chip advertises DG15, and reports only whether the chip produced a signature — the backend re-verifies it against the DG15 public key.
For the full NFC architecture and passive-auth decision mapping, see the NFC deep-dive.
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.
curl -X POST https://<your-domain>/v3/session/ \
-H "x-api-key: $TF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: kyc-user-123" \
-d '{
"vendor_data": "user-123",
"callback_url": "https://app.acme.com/kyc/done",
"contact_email": "[email protected]",
"prefill": { "full_name": "Aarav Sharma", "nationality": "NP" }
}'
# 201 → { "id": "6f0e…", "status": "not_started",
# "url": "https://<your-domain>/verify/<token>", ... }import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:thirdfactor_kyc/thirdfactor_kyc.dart';
class KycScreen extends StatefulWidget {
const KycScreen({super.key});
@override
State<KycScreen> createState() => _KycScreenState();
}
class _KycScreenState extends State<KycScreen> {
String _status = 'Not verified';
bool _running = false;
/// Fetch the hosted session URL from YOUR API, which calls POST /v3/session/
/// server-side with the secret key. Never call the Obsidian API from the app.
Future<String?> _fetchSessionUrl() async {
final res = await http.post(Uri.parse('https://api.acme.com/kyc/session'));
if (res.statusCode != 200) return null;
return (jsonDecode(res.body) as Map<String, dynamic>)['url'] as String?;
}
Future<void> _startVerification() async {
setState(() => _running = true);
final url = await _fetchSessionUrl();
if (url == null || !mounted) {
setState(() => _running = false);
return;
}
final result = await ThirdFactor.verify(
context,
url: url,
options: VerifyOptions(
title: 'Verify your identity',
onEvent: (e) => debugPrint('[kyc] $e'),
),
);
if (!mounted) return;
setState(() {
_running = false;
_status = switch (result.status) {
// UX only — grant real access once your webhook handler flips the
// user's server-side status.
VerificationStatus.approved => 'Approved (pending server confirmation)',
VerificationStatus.declined => 'Declined',
VerificationStatus.inReview => 'Under review',
VerificationStatus.cancelled => 'Cancelled',
VerificationStatus.error => 'Error: ${result.error?.code ?? "unknown"}',
};
});
if (result.status == VerificationStatus.error &&
result.error?.code == 'expired') {
// Re-fetch a fresh session URL and let the user retry.
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Verify')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_status),
const SizedBox(height: 16),
FilledButton(
onPressed: _running ? null : _startVerification,
child: Text(_running ? 'Running…' : 'Verify identity'),
),
],
),
),
);
}
}A runnable version lives in sdks/flutter/example/ — paste a hosted session URL and tap Start verification (it passes nfcReader: ThirdFactorNfc()), or tap Read passport NFC (standalone) to exercise ThirdFactorNfc().read on a device.
Troubleshooting
Warning
Navigator operation requested with a context that does not include a Navigator, or the screen never appears. verify() pushes a route, so it needs a BuildContext beneath a MaterialApp / Navigator. Calling it before the first frame (e.g. straight from initState without a context under the app), or with a context from a disposed widget, throws exactly this. Call verify() from a button handler, or defer to WidgetsBinding.instance.addPostFrameCallback if you must trigger it on load.
Warning
The camera never opens / the flow shows a permission error on Android. Declaring CAMERA in the manifest is not enough on Android 6+ — you must request the runtime permission before verify(). The SDK grants the WebView's PermissionRequest for you, but the OS-level grant is the app's responsibility. Request it with permission_handler and only start verification once it's granted. On iOS, confirm NSCameraUsageDescription (and NSMicrophoneUsageDescription) are in Info.plist — iOS silently denies a missing usage string with no prompt.
Warning
The flow's location step never passes. Android: declare ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION and pass VerifyOptions.requestLocationPermission (or hold the permission before verify()); without an OS grant the WebView cannot produce a fix. iOS: add NSLocationWhenInUseUsageDescription to Info.plist — without it iOS silently ignores the request and the step times out. Only workflows with precise location need either.
Warning
The NFC step always falls back to manual review, nfc.scan always fails, or ThirdFactorNfc().isAvailable() returns false on a device that has NFC. Usual causes: (0) no reader passed — without nfcReader: const ThirdFactorNfc() the SDK never advertises nfc, so the flow never asks; (1) missing NFC permission/entitlement — Android needs android.permission.NFC and minSdkVersion 23; iOS needs the Near Field Communication capability plus select-identifiers including A0000002471001; (2) you're on the Simulator/emulator, which has no NFC radio; (3) NFC is switched off in OS settings (isAvailable() returns false, and a delegated scan resolves nfc_disabled). Nothing breaks either way — the flow keeps its server-side fallback.
Warning
wrong_key on every scan. The MRZ fields don't match the chip's access key. documentNumber, dateOfBirth, and dateOfExpiry must be exactly what's encoded on the chip, with the two dates as YYMMDD (a malformed date string throws ArgumentError, mapped to wrong_key). For PACE-only eIDs that have no MRZ, pass canNumber instead. In the flow-delegated path the MRZ comes from the engine's own OCR, so a persistent wrong_key there usually means a poor document scan — re-capture the photo page.
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 for the full contract.