Using the Flutter SDK
The attestid_sdk package provides Flutter components and APIs for performing identity verification, including document capture, image quality validation, optional passport NFC chip scanning, face liveness detection, and verification result retrieval.
Supported platforms:
- Android (including passport NFC chip scanning)
- iOS
- Flutter Web
Prerequisite: Before integrating the SDK, ensure you have obtained your access_key_id and api_key. See Creating a Company & Getting Your Keys.
Requirements
| Tool | Minimum version |
|---|---|
| Flutter | 3.32.0 |
| Dart | 3.8.0 |
| Android | API 24 (Android 7.0) |
| Xcode / iOS | 14+ |
1. Install
# pubspec.yaml
dependencies:
attestid_sdk: ^0.1.0
flutter pub get
2. Environment setup
Your api_key is a server-side credential and must not be included in your Flutter application. A .env file loaded via flutter_dotenv still ships inside the compiled APK/IPA as a plain asset — anyone can unzip it out or pull the string with a decompiler. There is no build variant of a Flutter app where apiKey is safe to hold, so AttestIDConfig doesn't accept it at all — only a getSdkToken callback that fetches an already-minted sdk_token from your own backend, which holds the key server-side.
# .env (never commit this file, and notice: no API key here at all)
ATTESTID_API_URL=https://api.attestid.io
MY_BACKEND_URL=https://api.yourcompany.com
The following example uses Node.js and Express. The same flow can be implemented using any server-side framework.
// your-backend/routes/attestid-session.ts
app.post('/attestid-session', async (req, res) => {
const response = await fetch(`${process.env.ATTESTID_API_URL}/v1/kyc/session`, {
method: 'POST',
headers: {
'API-Key': process.env.ATTESTID_API_KEY!,
'Access-Key-Id': process.env.ATTESTID_ACCESS_KEY_ID!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: req.body.userId }),
});
res.status(response.status).json(await response.json());
});
3. Quick start — zero setup
For new applications, AttestIDApp.run() provides a complete verification experience with minimal configuration.
// lib/main.dart
import 'dart:convert';
import 'package:attestid_sdk/attestid.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
Future<void> main() async {
await dotenv.load(fileName: '.env');
await AttestIDApp.run(
apiUrl: dotenv.env['ATTESTID_API_URL'] ?? '',
// Calls your own backend, which holds the AttestID API key server-side
// and calls POST /kyc/session on your behalf.
getSdkToken: (userId) async {
final res = await http.post(
Uri.parse('${dotenv.env['MY_BACKEND_URL']}/attestid-session'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'userId': userId}),
);
return SdkSessionResponse.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
},
sandbox: true, // shows a "sandbox" badge — set false for production
);
}
AttestIDApp provides the full home screen, AppBar, verification flow, and result display out of the box.
4. Embedding into an existing app
If you already have a MaterialApp, use AttestIDHomeScreen or push AttestIDPage directly onto your navigator.
Home screen integration
MaterialApp(
home: AttestIDHomeScreen(
client: AttestID(AttestIDConfig(
apiUrl: 'https://api.attestid.io',
// Never embed apiKey here — call your own backend instead (see step 2 above).
getSdkToken: (userId) => myBackend.fetchAttestIDSession(userId),
)),
sandbox: false,
),
)
Option: Verification page
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => AttestIDPage(
client: _client,
onResult: (KYCResult result) {
if (result.verified) navigateToSuccess();
},
),
),
);
Option: Verification widget
AttestIDVerifier(
controller: AttestIDController(client),
defaultUserId: currentUser.id,
onResult: (KYCResult result) { /* ... */ },
onError: (String msg) { /* ... */ },
enableNfcChipScan: true, // default — set false to skip the NFC step entirely
)
Note that sandbox means different things depending on the widget: on AttestIDApp / AttestIDHomeScreen it just shows a "sandbox" badge in the app bar. On AttestIDPage / AttestIDVerifier it controls whether the User ID field is shown and editable — leave it false in production so the field stays hidden and defaultUserId is used silently.
Passport NFC chip scan
When enableNfcChipScan is true (the default) and the device is Android, a passport verification walks through two extra steps — shown in the step bar as MRZ and NFC Chip — between the document photo and the face-liveness check:
- Document — capture/upload the passport photo, same as any other document type.
- MRZ — review the document number, date of birth, and expiry date. These are auto-filled from on-device OCR of the photo's MRZ line; correct anything misread, or fill them in yourself if OCR failed. They're only used to unlock the chip (BAC key) — nothing here is sent to your backend directly.
- NFC Chip — scan the passport's chip (reading DG1/MRZ and DG2/face photo via Basic Access Control). A successful scan swaps in the chip's face photo (more trustworthy than the camera capture) and shows a "Verified via passport chip" badge.
- Only then does "Continue to Face Check" call
startVerification(), sending the chip's MRZ fields alongside the photo viaStartKYCOptions.chipData— omitted entirely when no chip scan happened.
The MRZ/NFC steps only appear for IdType.passport on Android (with enableNfcChipScan true) — everything else (national ID, driver's licence, or any document on iOS/web) goes straight from Document to Face Check, unchanged.
On a device that can actually scan (NFC hardware present and turned on), the NFC Chip step's continue button stays disabled until the chip read succeeds. On an Android device without usable NFC (no hardware, or turned off), the MRZ/NFC steps still appear, but explain why scanning isn't available and let the user continue with just the photo instead.
Set enableNfcChipScan: false to skip both extra steps everywhere (e.g. an iOS-only rollout, or while your backend doesn't yet consume chip data) — submission then only ever needs the photo, same as national ID / driver's licence.
You don't need to add the android.permission.NFC manifest permission yourself — it's merged in automatically by the SDK's NFC dependency.
5. Platform setup
Android
android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="true"/>
<uses-feature android:name="android.hardware.camera.front" android:required="true"/>
<!-- Image picker: Android <= 12 -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<!-- Image picker: Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
android/app/build.gradle.kts — the SDK's face liveness detector requires minSdk = 24 and Java 8+ desugaring:
android {
defaultConfig {
minSdk = 24
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
Your MainActivity can stay a plain FlutterActivity() — the SDK's own plugin class handles the native liveness method channel and manifest merging (camera, NFC) automatically, since attestid_sdk is a real Flutter plugin.
iOS
ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Camera is required for document capture and liveness detection.</string>
Passport NFC chip scanning is Android-only; iOS verifications always go straight from Document to Face Check.
Web
Add to web/index.html (before </head>):
<script src="assets/packages/attestid_sdk/assets/web/liveness_bridge.js"></script>
That's the only markup needed — the bridge (a self-contained Amplify FaceLivenessDetector build) ships as a package asset, so your app never touches a CDN, a <link> tag, or Amplify directly.
6. API reference
Widgets — integration levels
| Widget | Use case |
|---|---|
AttestIDApp | Complete MaterialApp — call AttestIDApp.run() from main() |
AttestIDHomeScreen | Full home screen — embed in your existing MaterialApp |
AttestIDPage | Full-screen verification page — push onto your navigator |
AttestIDVerifier | Verification widget — embed inside your own Scaffold |
AttestIDResultCard | Pass/fail result banner — display KYCResult anywhere |
AttestIDLaunchButton | Styled CTA button — auto-switches "Start" / "Verify Again" / "Done" |
AttestIDApp
AttestIDApp(
apiUrl: 'https://api.attestid.io', // required
getSdkToken: (userId) => myBackend.fetchAttestIDSession(userId), // required
identityPoolId: 'us-east-1:...', // optional
sandbox: false, // shows "sandbox" badge
appTitle: 'My App', // MaterialApp title + AppBar
subtitle: 'KYC Verification', // AppBar sub-label
description: 'Verify your identity...', // body copy
enableNfcChipScan: true, // offer passport NFC chip scan (Android only)
)
// Static entry point
await AttestIDApp.run(apiUrl: '...', getSdkToken: (userId) => myBackend.fetchAttestIDSession(userId));
AttestIDHomeScreen
AttestIDHomeScreen(
client: myClient, // required AttestID instance
sandbox: false,
title: 'My App',
subtitle: 'KYC Verification',
description: 'Verify your identity...',
enableNfcChipScan: true,
)
AttestIDPage
AttestIDPage(
client: myClient, // required
onResult: (r) { /* ... */ }, // required
defaultUserId: 'user_123',
defaultIdType: IdType.passport,
title: 'Identity Verification',
onError: (msg) { /* ... */ },
sandbox: false, // shows/hides the User ID field
enableNfcChipScan: true,
)
AttestIDVerifier
AttestIDVerifier(
controller: myController, // required AttestIDController
onResult: (r) { /* ... */ }, // required
defaultUserId: 'user_123',
defaultIdType: IdType.nationalId,
onError: (msg) { /* ... */ },
livenessProvider: const AwsLivenessProvider(), // pluggable — see LivenessProvider below
deviceId: null, // optional, for fraud-risk device fingerprinting
sandbox: false, // shows/hides the User ID field
enableNfcChipScan: true,
)
AttestIDResultCard
AttestIDResultCard(result: kycResult)
Shows a green card for verified == true, red for false. Displays the full name from result.document or falls back to verificationId.
AttestIDLaunchButton
AttestIDLaunchButton(
onTap: _startVerification,
hasResult: _result != null, // switches label to "Verify Again"
isVerified: _result?.verified == true, // switches label to "Done" and disables the button
)
AttestIDConfig
| Field | Type | Description |
|---|---|---|
apiUrl | String | Base URL of your AttestID backend |
getSdkToken | Future<SdkSessionResponse> Function(String userId) | Fetches a short-lived sdk_token from your own backend — never construct this from an in-app apiKey; see step 2 above |
identityPoolId | String? | Cognito Identity Pool ID; optional — getLivenessCredentials() can fetch it from the backend instead |
region | String | AWS region, defaults to us-east-1 |
AttestIDController
AttestIDController (a ChangeNotifier) is the state machine AttestIDVerifier drives internally — use it directly for a fully custom UI.
final controller = AttestIDController(client);
controller.state // VerifyState enum
controller.isLoading // bool
controller.error // String?
controller.qualityIssues // List<String> — populated when the photo-quality gate fails
controller.result // KYCResult? — set when state == done
controller.livenessError // String? — a technical liveness-SDK error, distinct from a failed check
controller.rawLivenessSessionId // String? — provider-prefix stripped, ready for the liveness widget
await controller.startVerification(options);
await controller.completeVerification(deviceId: null);
await controller.retryLiveness(); // re-runs the liveness step using the last startVerification() options
controller.notifyLivenessError('message'); // records a liveness error without leaving the liveness step
controller.reset();
LivenessProvider
Liveness rendering is pluggable via the abstract LivenessProvider class — AttestIDVerifier defaults to const AwsLivenessProvider() (AWS Rekognition Face Liveness, via the native Amplify SDK on Android/iOS and the JS bridge on web). Implement LivenessProvider.buildWidget() yourself to swap in another provider.
KYCResult
| Field | Type | Description |
|---|---|---|
verified | bool | Overall pass/fail |
verificationId | String | Unique verification ID |
status | String | Raw status from the API |
document | DocumentInfo? | Extracted document fields |
checks | ChecksInfo? | Liveness + face match results |
failureReason | String? | Human-readable reason when failed |
IdType enum
IdType.passport
IdType.nationalId
IdType.driversLicence
PassportChipData
Returned by a successful NFC chip scan and passed as StartKYCOptions.chipData. Data read directly off the chip (DG1 MRZ + optional DG2 face photo) via Basic Access Control — more trustworthy than OCR since it's signed by the issuing authority.
| Field | Type | Description |
|---|---|---|
documentNumber, firstName, lastName, nationality, country, gender | String | MRZ fields read from the chip |
dateOfBirth, dateOfExpiry | DateTime | From the chip |
faceImageBytes | List<int>? | Raw chip face photo (DG2), if present |
faceImageIsJpeg | bool? | true if faceImageBytes is JPEG (displayable); false means JPEG2000, which the SDK cannot decode |
7. Verification flow
When using AttestIDVerifier, user consent is collected before verification begins. If you are building a custom integration, you are responsible for collecting consent and providing StartKYCOptions.consentGivenAt.
idle
| startVerification()
v
qualityCheck (client-side blur / brightness / size / face-count checks)
|
v
[ passport on Android with enableNfcChipScan: true only ]
| mrz (review/correct OCR'd document number, DOB, expiry)
v
| nfc (scan the chip — required if the device supports NFC and it's on)
v
uploading (parallel: upload ID photo [+ chip data] + fetch liveness credentials)
|
v
liveness (face liveness detection — provider from LivenessProvider)
|
v
completing (fetch final result from the API)
|
v
done (KYCResult available)
A failed liveness check (as opposed to a failed verification, e.g. a technical camera/SDK error) surfaces via controller.livenessError and can be retried in place with controller.retryLiveness(), without redoing the document step.
8. Photo quality thresholds
| Check | Threshold |
|---|---|
| Minimum file size | 30 KB |
| Blur (Laplacian variance) | below threshold flags "too blurry" |
| Brightness | flags too dark / overexposed outside the accepted range |
| Passport two-page spread | wide aspect ratio warns "wrong page photographed" |
| Document type mismatch | aspect-ratio check flags an obvious passport-vs-card mismatch against the selected IdType |
| Face count | ML Kit on-device detection (Android/iOS only) flags zero or multiple faces on the document photo |
9. Error handling
import 'package:attestid_sdk/attestid.dart';
try {
await client.startVerification(options);
} on AuthError catch (e) {
// Invalid/expired sdk_token (HTTP 401/403)
} on KYCError catch (e) {
// Any other non-auth API error
} on CameraError catch (e) {
// Camera access or frame capture failed
if (e.permanentlyDenied) {
// permission denied and the OS won't re-prompt — send the user to Settings
}
} on ValidationError catch (e) {
// Rejected client-side before the API was ever called, e.g. not a genuine JPEG/PNG
} on AttestIDError catch (e) {
// Base class — e.code / e.message / e.statusCode
}
AttestIDController surfaces the same errors via controller.error (a message string) rather than exceptions, so AttestIDVerifier-based UIs don't need a try/catch.
Example application
See Flutter Demo App for a complete working example, including an example backend proxy and platform run instructions.
Support
For API keys, onboarding, native platform setup files, or technical support: support@brimsage.com