thirdfactor_kyc 1.2.0
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) orGET /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.
- Bridge protocol:
../../docs/sdk/PROTOCOL.md - REST reference:
../../OBSIDIAN_API.md
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 —
prefillonPOST /v3/session/pre-populates the flow (full_name,date_of_birth,nationality,email,phone, address fields, …; unknown keys ignored). - Metadata / your reference —
vendor_databecomes the session's external reference and the linked identity'sexternal_user_id; it comes back on every webhook. - Workflow selection —
workflow_idpicks 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) #
-
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 -
Pass a reader to
verify()(or toThirdFactorVerificationPage.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.