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, 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.

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.
WebViewwebview_flutter ^4.7 (+ webview_flutter_android / webview_flutter_wkwebview).
NFCdmrtd v2.0.0 (ICAO-9303 BAC/PACE, secure messaging, EF/DG parsing), which bundles flutter_nfc_kit ^3.6.x. Android minSdkVersion 23; iOS 13+ (15+ recommended), real device only.
SDK version1.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:

# 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:

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.

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

MemberSignatureNotes
verifystatic Future<VerificationResult> verify(BuildContext context, {required String url, VerifyOptions? options})Pushes a full-screen route and resolves once. Never throws. A stray pop resolves cancelled.

ThirdFactorVerificationPage

The StatefulWidget behind verify(). Use it directly only when your app owns navigation.

FieldTypeDefaultNotes
urlStringrequiredHosted session URL from POST /v3/session/ (the url field).
optionsVerifyOptions?nullDefaults are used when null.
onResultValueChanged<VerificationResult>?nullInvoked exactly once with the terminal result.
popOnResultbooltrueWhen true, pops the route with the result (how verify() receives it). Set false for custom navigation.

VerifyOptions

ThirdFactor.verify(context, {required url, options}) takes a VerifyOptions:

FieldTypeDefaultNotes
titleString'Identity verification'AppBar / accessible title for the screen.
expectedOriginString?origin of urlOrigin 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".
autoClosebooltruefalse keeps the flow's own result screen until the user taps Done (the later close event then dismisses).
onEventvoid Function(VerificationEvent)?nullEvery lifecycle event: ReadyEvent / CompletedEvent / ErrorEvent / CloseEvent.

Result types

TypeShapeNotes
VerificationResult{ status: VerificationStatus, sessionId: String?, rawStatus: String?, error: ThirdFactorError? }The single normalized result. rawStatus preserves the original server string.
VerificationStatusenum { 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.

EventFieldsMeaning
ReadyEventFlow mounted and the session resolved OK — safe to hide your spinner.
CompletedEventstatus: String, sessionId: String?Terminal decision. status is the raw server status; the SDK normalizes it.
ErrorEventcode: String, message: String?Unrecoverable (invalid/expired token, load failure). Branch on code.
CloseEventUser acknowledged the result screen (tapped Done).

NFC types (ThirdFactorNfc and friends)

SymbolSignature / shapeNotes
ThirdFactorNfc.isAvailablestatic Future<bool> isAvailable()true when NFC hardware is on and usable. Never throws; false on web/desktop/simulator.
ThirdFactorNfc.readstatic 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.
NfcDataGroupenum { 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.
NfcErrorCodeenum { 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.normalizestatic 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
}
VerificationStatusWhen
approvedFlow reported completed with server status approved.
declinedcompleted with declined.
inReviewcompleted with in_review / review / manual_review / pending, or any unknown terminal status (conservative).
cancelledcompleted with abandoned, or the user dismissed (system back / AppBar close) before a terminal event.
errorAn 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 this SDK reads the chip natively over the capability bridge 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.

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 is a pure Dart package 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:

import 'package:thirdfactor_kyc/thirdfactor_kyc.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):

static 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, 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

nfc.scan always fails, or ThirdFactorNfc.isAvailable() returns false on a device that has NFC. Three usual causes: (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.