Skip to main content

thirdfactor_kyc 1.2.0 copy "thirdfactor_kyc: ^1.2.0" to clipboard
thirdfactor_kyc: ^1.2.0 copied to clipboard

ThirdFactor / Obsidian KYC — Flutter client SDK. Opens the hosted verification flow in a WebView and returns one normalized result. Native ePassport/eID NFC reading plugs in via the thirdfactor_kyc_nf [...]

thirdfactor_kyc #

Flutter client SDK for the ThirdFactor / Obsidian native KYC engine. It opens the hosted verification flow in a full-screen WebView and resolves a single typed VerificationResult. Native ePassport / eID NFC chip reading plugs in via the thirdfactor_kyc_nfc companion package (optional — see NFC Verification).

The SDK is a thin shell around the same hosted flow that backs the web, iOS, and Android SDKs — it speaks the shared bridge protocol. Your backend creates the session and gets a hosted url; the app hands that url to this SDK. Session creation needs your secret API key and is not this SDK's job.

The client result is advisory. It is a UX signal only. Grant access / 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 / _wkwebview).
NFC (optional) The 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.

Installation #

# pubspec.yaml
dependencies:
  thirdfactor_kyc: ^1.2.0

or 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.2.0
  thirdfactor_kyc_nfc:
    git:
      url: https://github.com/thirdfactorai/thirdfactor-flutter.git
      path: sdks/flutter_nfc

Then 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).

Permissions #

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.

Android — 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) and grants the WebView's PermissionRequest automatically. 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.

iOS — ios/Runner/Info.plist #

<key>NSCameraUsageDescription</key>
<string>We use the camera to verify your identity.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We use the microphone during liveness checks.</string>

The SDK configures WKWebView with allowsInlineMediaPlayback: true and no user-gesture requirement, so the camera stream renders inline. NFC needs extra setup — see NFC Verification.

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();  // confirm server-side
}

verify() never throws — it always resolves a VerificationResult. Call it from a Navigator-capable BuildContext; it pushes a full-screen route and completes when that route pops.

Integration Methods #

Method 1 — ThirdFactor.verify (pushes its own route) #

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);
}

Method 2 — embed the page widget yourself #

If your app owns the navigation stack, place the page widget and read the result from onResult:

ThirdFactorVerificationPage(
  url: sessionUrl,
  popOnResult: false,             // don't self-pop; you control navigation
  onResult: (result) {
    // handle result, then navigate away yourself
  },
)

Configuration #

ThirdFactor.verify(context, {required url, options, nfcReader}) — nfcReader injects a native NFC chip reader (see NFC Verification); options is 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.
autoClose bool true false keeps the flow's own result screen until the user taps Done.
onEvent void Function(VerificationEvent)? null Every lifecycle event: ReadyEvent / CompletedEvent / ErrorEvent / CloseEvent.

Language / Localization #

The hosted flow's language and copy are chosen at session-create time, not in the SDK. Pass locale in the POST /v3/session/ body; the SDK renders whatever the flow serves. See OBSIDIAN_API.md.

Advanced Options #

Prefill, expected identity details, and metadata are session-create concerns:

  • Prefill — prefill on POST /v3/session/ pre-populates the flow (full_name, date_of_birth, nationality, email, phone, address fields, …; unknown keys ignored).
  • Metadata / your reference — vendor_data becomes the session's external reference and the linked identity's external_user_id; it comes back on every webhook.
  • Workflow selection — workflow_id picks a specific KYC workflow; omit for the tenant default.

SDK-side advanced knobs are expectedOrigin (proxy override) and autoClose.

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 (system back / close) before a terminal event.
error An error bridge event or a main-frame load failure. Carries error.code + error.message.

Error cases #

Branch on error.code (invalid_session | expired | load_error | unknown), never on error.message.

Full example #

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);
}

NFC Verification (ePassport / eID) #

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 (PROTOCOL.md §8) and forwards the raw chip bytes for your backend to authenticate.

Chip reading is injected: this core package defines only the NfcChipReader interface (it is published to pub.dev, which rejects the git-only dmrtd dependency the reader needs), and the implementation — ThirdFactorNfc — lives in the git-distributed thirdfactor_kyc_nfc companion package.

Setup — enable NFC (2 steps) #

  1. Add the companion package (git dependency — it cannot go to pub.dev):

    dependencies:
      thirdfactor_kyc_nfc:
        git:
          url: https://github.com/thirdfactorai/thirdfactor-flutter.git
          path: sdks/flutter_nfc
    
  2. Pass a reader to verify() (or to ThirdFactorVerificationPage.nfcReader):

    import 'package:thirdfactor_kyc_nfc/thirdfactor_kyc_nfc.dart';
    
    final result = await ThirdFactor.verify(
      context,
      url: sessionUrl,
      nfcReader: const ThirdFactorNfc(),
    );
    

From there it is fully automatic: the SDK advertises the nfc capability only when a reader is injected and the device has NFC switched on, and drives the reader when the flow asks. If you pass no reader (or the device has no NFC), 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. 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 — 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).

iOS — reading passports needs the Core NFC entitlement, a usage string, and the passport application identifiers. Requires a real device (iPhone 7+) on iOS 13+ (iOS 15+ recommended); the Simulator has no NFC.

ios/Runner/Info.plist:

<key>NFCReaderUsageDescription</key>
<string>We read your passport chip to verify your identity.</string>

<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
  <string>A0000002471001</string>          <!-- eMRTD LDS1 application — REQUIRED -->
  <string>A00000045645444C2D3031</string>  <!-- eID (optional) -->
</array>

ios/Runner/Runner.entitlements (and enable Near Field Communication Tag Reading on the App ID in the Apple Developer portal):

<key>com.apple.developer.nfc.readersession.formats</key>
<array>
  <string>TAG</string>
</array>

Standalone read #

You can also read a chip yourself, independent of the hosted flow. 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';
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;
    }
  }
}

The interface (defined here in lib/src/nfc/nfc_chip_reader.dart; the thirdfactor_kyc_nfc implementation defaults dataGroups to DG1+DG2+SOD, timeout to 60s):

abstract class NfcChipReader {
  Future<bool> isAvailable();
  Future<NfcReadResult> read({
    required String documentNumber,
    required String dateOfBirth,   // YYMMDD
    required String dateOfExpiry,  // YYMMDD
    List<NfcDataGroup> dataGroups,
    String? canNumber,             // PACE-by-CAN alternative to the MRZ fields
    Duration timeout,
    String iosAlertMessage,
  });
}

NfcReadResult carries the base64 elementary files (sodB64, dg1B64, dg2B64, optional dg11B64 / dg12B64 / comB64) plus aa / chipAuth ({ performed, success }) advisory signals. chipAuth is always notPerformed (dmrtd's high-level API doesn't expose EAC-CA; the backend does it from DG14). toBridgeData() returns the exact nfc.scan result shape documented in PROTOCOL.md §8.4. All these value types live in this core package — only the reader implementation is in the companion.

End-to-End Workflow #

1. Backend  POST /v3/session/  (x-api-key: SECRET)          → { id, url }
2. Backend  send { url } to the app
3. App      ThirdFactor.verify(context, url: …)  → opens url?embed=1&sdk=flutter
4. Flow     bridge events over the ThirdFactorFlutter JS channel
              (+ nfc.scan on NFC devices) → SDK resolves ONE advisory result
5. Backend  AUTHORITATIVE decision — do this before granting access:
              webhook identity.kyc.session.{approved|declined|review|expired}
              or  GET /v3/session/<id>/decision/

Create the session with your secret key (never in the app):

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": "customer@example.com",
        "prefill": { "full_name": "Aarav Sharma", "nationality": "NP" } }'
# 201 → { "id": "6f0e…", "status": "not_started", "url": "https://<your-domain>/verify/<token>", ... }

See OBSIDIAN_API.md for the full request/response reference and webhook signature verification.

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.md §5), and resolves exactly one result. On page load it injects the window.__thirdfactorHost handshake (PROTOCOL.md §8.1) so the flow can delegate native-only steps — currently nfc.scan — to the SDK over the same channel; the nfc capability is advertised only when an NfcChipReader was injected via nfcReader: and the device has NFC enabled.

Example #

A runnable example lives in example/. Paste a hosted session URL and tap Start verification.

0
likes
110
points
10
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

ThirdFactor / Obsidian KYC — Flutter client SDK. Opens the hosted verification flow in a WebView and returns one normalized result. Native ePassport/eID NFC reading plugs in via the thirdfactor_kyc_nfc companion.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, webview_flutter, webview_flutter_android, webview_flutter_wkwebview

More

Packages that depend on thirdfactor_kyc