Smile ID Flutter SDK
The Smile ID Flutter SDK runs identity verification inside a Flutter app on Android and iOS. You
compose the journey from screens (consent, instructions, capture, preview, processing) with a
builder, add the on-device analyzer packages for the capture types and platforms you ship, and
receive the submission acknowledgement in onResult. The verification verdict arrives on your
callback URL.
- How it works
- Requirements
- Installation
- Quick start
- Job types
- Builder reference
- Results and errors
- Analytics
- Recipes
- App size
- Troubleshooting
How it works
sequenceDiagram
participant Backend as Your backend
participant App as Your app
participant SDK as UseSmileIDBuilder
participant API as Smile ID
App->>Backend: Ask for a session token
Backend->>API: POST /v3/token (API key, server side only)
API-->>Backend: Short-lived v3 token
Backend-->>App: Token
App->>SDK: Screens, analyzers, token
SDK->>SDK: Consent, capture with on-device analyzers, preview
SDK->>API: Submit the job (processing screen)
API-->>SDK: Submission acknowledgement
SDK-->>App: onResult(UseSmileIDSuccess(JobSubmissionResponse))
API-->>Backend: Verdict on your callback URL
Four rules hold for every integration:
UseSmileIDBuilderis the only entry point. There is no initialisation call and no config file. The builder is a widget; configuration lives in itsbuildercallback.- Authentication is a short-lived v3 token minted by your backend with
POST /v3/token. Your long-lived API key never ships in the app. - There are no predefined products. You compose the screens a job type needs, and the builder validates the composition against that job type before anything renders.
- Results arrive on two paths.
onResultreports that Smile ID accepted the submission. The verdict, which is the source of truth, is delivered asynchronously to your callback URL.
Capture and on-device detection run natively, through the SDK's plugins: ML Kit or Huawei ML on Android and Apple Vision on iOS. Frames never cross into Dart.
Requirements
| Minimum | |
|---|---|
| Flutter | 3.44 |
| Dart | 3.12 |
| Android | API 24 (minSdk), compileSdk 36 (Flutter's default), Android Gradle Plugin 9.1 with a Gradle 9 wrapper |
| iOS | 15.0 |
| Backend | An endpoint that mints v3 tokens with POST /v3/token |
On iOS, add NSCameraUsageDescription to ios/Runner/Info.plist. Contributors: see
CONTRIBUTING.md for the secrets setup.
Installation
The SDK ships as pub packages: the usesmileid entry point plus one analyzer package per capture
type and platform.
dependencies:
usesmileid: ^12.3.0
# Android, on Google Play (GMS) devices
usesmileid_mlkit_face: ^12.3.0
usesmileid_mlkit_document: ^12.3.0
# iOS
usesmileid_vision_face: ^12.3.0
usesmileid_vision_document: ^12.3.0
| Package | Platform | What it adds |
|---|---|---|
usesmileid |
both | The builder, screens, camera, capture and networking |
usesmileid_mlkit_face, usesmileid_mlkit_document |
Android | Face and document detection on ML Kit |
usesmileid_huawei_face, usesmileid_huawei_document |
Android without Google Play services | Face and document detection on Huawei ML |
usesmileid_vision_face, usesmileid_vision_document |
iOS | Face and document detection on Apple Vision |
Add only the capture types you use: a selfie-only app needs no document package. Each analyzer
package is a platform-scoped plugin, so declaring the Android and iOS ones together is safe; only
the running platform's register native code. usesmileid_bridge and
usesmileid_platform_interface come in through usesmileid, so you don't declare them.
Android Gradle Plugin 9
The SDK's Android plugins use AGP's built-in Kotlin, so your app needs Android Gradle Plugin 9.1
or later with a Gradle 9 wrapper. Set the version in android/settings.gradle(.kts):
id("com.android.application") version "9.1.1" apply false
On AGP 8 the build fails with Dependency 'androidx.core:core:1.19.0' requires Android Gradle plugin 9.1.0 or higher. Android Studio's AGP Upgrade Assistant handles the upgrade. An app that
still applies the Kotlin Gradle Plugin also follows Flutter's
built-in Kotlin migration guide,
and keeps the plugin declared in android/settings.gradle(.kts) after the migration removes it
from android/app/build.gradle(.kts): Flutter applies it to plugin modules from there, and without
it their Kotlin does not compile.
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
Huawei
Android phones without Google Play services run the Huawei packages. Ship them one of two ways:
- One build for every store. List the Huawei packages as well as ML Kit's, and pick per phone. Every user downloads both sets.
- A build per store. Your AppGallery build uses the Huawei analyzers instead of ML Kit's, and
your Google Play build keeps ML Kit. List both sets and leave the other store's modules out of
each flavor in
android/app/build.gradle.kts.
Either way, pick with UseSmileIDMobileServices().analyzerProvider(): the vendor whose analyzers
the build carries, or with both, the phone's services. Turn away a phone whose services the build
cannot use with preferred() first, on Android only. The repository README's Huawei
section has the flavor block, the check and the
pick.
Quick start
A SmartSelfie enrollment: consent, a selfie with liveness, and the submission. token is the
short-lived v3 token your backend minted for this session.
import 'package:flutter/material.dart';
import 'package:usesmileid/usesmileid.dart';
class EnrollmentScreen extends StatelessWidget {
const EnrollmentScreen({super.key, required this.token});
final String token;
@override
Widget build(BuildContext context) {
return UseSmileIDBuilder(
builder: (smile) {
smile.network((network) {
network.config((config) {
config.jobType = JobType.smartSelfieEnrollment;
config.token = token;
config.partnerConfig((partner) {
partner.partnerId = 'your-partner-id';
partner.callbackUrl = 'https://your-registered-domain/webhook';
partner.useSandbox = true;
});
});
});
// The running platform's face analyzer, from the packages you installed.
smile.ml((ml) => ml.analyzers((analyzers) => analyzers.forCaptureType(CaptureType.selfie)));
smile.userDetails = const UserDetails(
givenNames: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
);
smile.screens((screens) {
screens.consent((consent) {
consent.partnerName = 'Acme Corp';
consent.partnerIcon = const Icon(Icons.business);
consent.partnerPrivacyPolicyUrl = 'https://acme.example/privacy';
});
screens.instructions();
screens.capture((capture) {
capture.captureType = CaptureType.selfie;
capture.selfie((_) {});
});
screens.preview();
screens.processing();
});
smile.onResult = (result) {
switch (result) {
case UseSmileIDSuccess(:final value):
debugPrint('Submitted job ${value.jobId}');
case UseSmileIDFailure(:final error):
debugPrint('Failed: $error');
case UseSmileIDCancelled():
debugPrint('The user left the flow');
case UseSmileIDCaptured(:final job):
debugPrint('Captured ${job.jobType}');
}
};
},
);
}
}
Two behaviours to design around:
- The builder reads its callback once.
UseSmileIDBuilderrunsbuilderwhen it first mounts and keeps the configuration for its lifetime. To start a new session with different inputs, such as a fresh token, give it a new key:UseSmileIDBuilder(key: ValueKey(token), ...). - An invalid flow never renders. The builder validates the flow against its job type before
the first screen paints. When validation fails, it renders an empty surface and
onResultreceives oneUseSmileIDFailurecarrying aBuilderValidationExceptionthat lists every issue. Setsmile.config((c) => c.enableDebugMode = true)during development to see the issues on screen as well.
Job types
Each job type needs a specific composition. Consent is either a consent screen or a pre-supplied
consentInformation, and exactly one of the two.
JobType |
Screens | Builder inputs |
|---|---|---|
smartSelfieEnrollment |
consent, selfie capture, processing | userDetails |
smartSelfieAuthentication |
consent, selfie capture, processing | userDetails, userId from a prior enrollment |
biometricKyc |
consent, selfie capture, processing | userDetails, biometricKYCParams |
documentVerification |
consent, selfie capture, document capture, processing | userDetails, documentVerificationParams |
enhancedDocumentVerification |
consent, selfie capture, document capture, processing | userDetails, enhancedDocumentVerificationParams |
residencyDocumentVerification |
consent, selfie capture, document capture of a passport, processing | userDetails, residencyDocumentVerificationParams |
enhancedKyc |
consent, processing | userDetails, enhancedKYCParams |
bvn |
consent, processing | none |
unknownProduct |
a selfie, a document or both, then processing, with skipApiSubmission |
none |
unknownProduct is for a product your backend chooses after the capture. It needs
config.skipApiSubmission = true, and applies none of a product's rules: capture a selfie, a
document, or both in either order, with consent optional. It needs at least one capture and allows
at most one of each type, and each preview follows its own capture. Forward the captured job to the endpoint of
the product you choose, with that product's own fields beside the package.
instructions and preview screens are optional wherever a capture appears. A selfie capture needs
a face analyzer package for the running platform. If your backend binds user details or consent
into the v3 token, its values win at submission. A token that binds consent satisfies the consent
requirement, and must bind all four consent fields. The SDK checks none of the user details: it
submits them as given, empty included, and the API reports any a job needs. See
docs/Flow-Validation-Rules.md for every rule.
Builder reference
Top-level properties
| Property | Type | Description |
|---|---|---|
onResult |
void Function(UseSmileIDResult<JobSubmissionResponse>) |
Called when the flow ends: success, failure or cancellation, or a captured job when skipApiSubmission is on |
onAnalyticsEvent |
void Function(UseSmileIDAnalyticsEvent)? |
Called for each analytics event. null by default |
userDetails |
UserDetails? |
The end user's names and a contact, sent as user_details |
userId |
String? |
The user from a prior enrollment, for smartSelfieAuthentication |
consentInformation |
ConsentInformation? |
Consent collected earlier; replaces the consent screen |
biometricKYCParams |
BiometricKYCParams? |
idType, idNumber, country, useEnrolledImage |
enhancedKYCParams |
EnhancedKYCParams? |
idType, idNumber, country, and optional bankCode and mobileOperator |
documentVerificationParams |
DocumentVerificationParams? |
country, and an optional idType the server can infer |
enhancedDocumentVerificationParams |
EnhancedDocumentVerificationParams? |
country and idType |
residencyDocumentVerificationParams |
ResidencyDocumentVerificationParams? |
country and idType, which must be 'PASSPORT' |
config
smile.config((config) {
config.enableDebugMode = false;
config.allowOfflineMode = false;
config.skipApiSubmission = false;
config.enableCrashReporting = true;
config.showAttribution = true;
config.globalPrivacyControl = GlobalPrivacyControl.optOut;
config.exitOrientations = const [DeviceOrientation.portraitUp];
});
| Property | Default | Description |
|---|---|---|
enableDebugMode |
false |
Verbose SDK logging, and the validation screen for an invalid flow. Leave it off in production |
allowOfflineMode |
false |
Writes captures to disk so the job can be submitted when connectivity returns |
skipApiSubmission |
false |
Captures the job and hands it back as UseSmileIDCaptured instead of submitting it. The files stay on disk for you to forward and delete |
enableCrashReporting |
true |
Reports the SDK's own crashes to Smile ID. Set false to opt out |
showAttribution |
true |
The "Powered by Smile ID" mark on every screen that carries it. Document capture never shows it |
globalPrivacyControl |
GlobalPrivacyControl.optOut |
The consent posture for every session. optOut pre-selects consent everywhere; optIn requires an explicit opt-in when the device locale is an EU member state |
exitOrientations |
[DeviceOrientation.portraitUp] |
The orientations applied when document capture, which pins orientation, exits. An app that supports more than portrait sets its own, or [] for the system default |
DeviceOrientation is in package:flutter/services.dart.
theme
smile.theme((theme) {
theme.primaryColor = theme.color(light: const Color(0xFF1A73E8), dark: const Color(0xFF4DA3FF));
theme.primaryForeground = theme.color(light: Colors.white, dark: Colors.black);
theme.secondaryColor = theme.color(light: const Color(0xFF5F6368), dark: const Color(0xFF9AA0A6));
theme.accentColor = theme.color(light: const Color(0xFF34A853), dark: const Color(0xFF81C995));
theme.fontFamily = 'Inter';
theme.buttonShape = 12;
theme.cardShape = 16;
});
| Property | Type | Default | Description |
|---|---|---|---|
primaryColor |
AdaptiveColor |
SDK default | Main actions and highlights |
primaryForeground |
AdaptiveColor |
SDK default | Text and icons on the primary colour |
secondaryColor |
AdaptiveColor |
SDK default | Secondary surfaces |
accentColor |
AdaptiveColor |
SDK default | Accents |
fontFamily |
String? |
null, the system font |
The font family name |
buttonShape |
double |
32 |
Button corner radius |
cardShape |
double |
16 |
Card corner radius |
color(light:, dark:) builds an AdaptiveColor that follows the platform brightness. See
docs/Theming.md.
Strings and locales
The SDK ships English for every si_* key (the canonical list is
usesmileid/lib/l10n/intl_en.arb). To translate, add a lib/l10n/intl_<lang>.arb file to your
app and declare it as an asset; the SDK loads it at flow start by device locale. You declare only
the keys you change:
# pubspec.yaml
flutter:
assets:
- lib/l10n/intl_fr.arb
{
"@@locale": "fr",
"si_consent_allow": "Autoriser",
"si_consent_deny": "Refuser"
}
Parameterised strings use named {name} placeholders, the same convention as the Android and iOS
SDKs, so translations copy across. See docs/Localization.md.
network
smile.network((network) {
network.config((config) {
config.jobType = JobType.documentVerification;
config.token = 'your-v3-token';
config.onTokenExpired = (previousToken) async => fetchFreshToken(previousToken);
config.partnerConfig((partner) {
partner.partnerId = 'your-partner-id';
partner.callbackUrl = 'https://your-registered-domain/webhook';
partner.useSandbox = true;
partner.partnerParams = const {'reference': 'order-1234'};
});
config.logging((logging) {
logging.enabled = true;
logging.level = LogLevel.basic;
});
});
network.timeouts((timeouts) {
timeouts.connect = const Duration(seconds: 30);
timeouts.call = const Duration(seconds: 120);
});
network.retry((retry) {
retry.strategy = RetryStrategy.exponential;
retry.maxAttempts = 3;
});
network.cache((cache) {
cache.maxSize = 100 * 1024 * 1024;
});
});
| Block | Property | Default | Description |
|---|---|---|---|
config |
jobType |
none | The job to submit. Required |
config |
token |
'' |
The short-lived v3 token, stamped on every authenticated request |
config |
onTokenExpired |
null |
Future<String> Function(String previousToken). Called on a 401; return a fresh token and the SDK retries the request once. Concurrent 401s share one call. If it throws, or the retry is also rejected, the original 401 surfaces |
partnerConfig |
partnerId |
'' |
Your Smile ID partner ID |
partnerConfig |
callbackUrl |
'' |
Where the verdict is delivered. Blank uses the callback URL configured for your account in that environment |
partnerConfig |
useSandbox |
false |
true for sandbox, false for production |
partnerConfig |
partnerParams |
null |
Your own key-value metadata, sent as partner_params with every job and echoed back on its result |
logging |
enabled, level, redactHeaders |
true, LogLevel.basic, [] |
Network logging: none, basic, headers or body. Credential headers (smileid-token, smileid-partner-id, smileid-api-key, smileid-device-nonce, authorization, cookie, set-cookie) are always masked; redactHeaders masks more |
timeouts |
connect, read, write, call |
60, 60, 60, 120 seconds | Per-request timeouts, as Durations |
retry |
strategy, maxAttempts |
RetryStrategy.none, 3 |
exponential (initialDelay, multiplier, maxDelay, jitter), fixed or none |
cache |
enabled, maxSize |
true, 50 MB |
The HTTP cache |
network.interceptors((i) { i.onRequest(…); i.onResponse(…); }) sees each submission: onRequest
gets its method, URL and headers before the SDK signs it and answers headers to add (tracing,
correlation and x-partner-* only), and onResponse gets each answer's status, duration and
headers. Submissions go through the native network client (the Android and iOS SDKs' own), which
sends a request only to Smile ID's API with the SDK's own headers.
ml
Detection runs natively, in the analyzer packages you install. With a package declared,
forCaptureType uses the running platform's default factory:
smile.ml((ml) => ml.analyzers((analyzers) {
analyzers.forCaptureType(CaptureType.selfie);
analyzers.forCaptureType(CaptureType.document);
}));
To choose the face factory yourself, pass it per capture type. Each face package exports one:
MlKitFaceAnalyzerFactory, HuaweiFaceAnalyzerFactory and VisionFaceAnalyzerFactory.
import 'package:flutter/foundation.dart';
import 'package:usesmileid_mlkit_face/usesmileid_mlkit_face.dart';
import 'package:usesmileid_vision_face/usesmileid_vision_face.dart';
FaceAnalyzerFactory selfieAnalyzerFactory() => switch (defaultTargetPlatform) {
TargetPlatform.android => const MlKitFaceAnalyzerFactory(),
TargetPlatform.iOS => const VisionFaceAnalyzerFactory(),
_ => throw UnsupportedError('Smile ID supports Android and iOS.'),
};
void registerAnalyzers(UseSmileIDFlowBuilder smile) {
smile.ml((ml) => ml.analyzers((analyzers) {
analyzers.forCaptureType(CaptureType.selfie, (selfie) => selfie.add(selfieAnalyzerFactory()));
}));
}
Each document package exports a factory the same way: MlKitDocumentAnalyzerFactory,
HuaweiDocumentAnalyzerFactory and VisionDocumentAnalyzerFactory, added inside
forCaptureType(CaptureType.document, ...). With none added, the loaded document plugin's default
runs.
screens
Screens run in the order you declare them.
smile.screens((screens) {
screens.consent((consent) {
consent.partnerName = 'Acme Corp';
consent.partnerIcon = const Icon(Icons.business);
consent.partnerPrivacyPolicyUrl = 'https://acme.example/privacy';
consent.onConsentGranted = (info) => debugPrint('Consent at ${info.grantedAt}');
});
screens.instructions();
screens.capture((capture) {
capture.captureType = CaptureType.selfie;
capture.selfie((selfie) => selfie.enableEnhancedLiveness = true);
});
screens.capture((capture) {
capture.captureType = CaptureType.document;
capture.document((document) {
document.documentType = const GenericDocument();
document.captureMode = const AutoCaptureWithManualFallback(activateManualAfter: Duration(seconds: 10));
document.captureBothSides = true;
});
});
screens.preview((preview) => preview.allowRetake = true);
screens.processing((processing) => processing.showProgressPercentage = true);
});
| Screen | Properties |
|---|---|
consent |
partnerName, partnerIcon (a Widget) and partnerPrivacyPolicyUrl are required. onConsentGranted, allowButton, denyButton |
instructions |
showHeroOval, continueButton |
capture |
captureType (required), and a selfie or document block to match it |
selfie |
enableEnhancedLiveness (active liveness), allowAgentMode (the rear camera, for an assisting agent). The two cannot both be true |
document |
documentType (required), captureMode, captureBothSides (default true, false for a passport), allowSkipBack, allowGalleryUpload, knownIdAspectRatio |
preview |
allowRetake (default true) |
processing |
showProgressPercentage |
The order is validated: consent first when present, instructions before the capture, every preview after a capture, and processing last. Non-capture screens appear at most once.
documentType is Passport(), SouthAfricaGreenBook() or GenericDocument(...). It drives the
frame's aspect ratio and whether a back side exists. captureMode is AutoCapture(),
ManualCapture(), or AutoCaptureWithManualFallback(activateManualAfter:), the default, which
offers the shutter after 10 seconds.
Button slots
A button slot, such as allowButton or continueButton, takes a function from a
ButtonSlotScope to any Widget; call scope.onClick from your button and respect
scope.enabled:
consent.allowButton = (scope) => FilledButton(
onPressed: scope.enabled ? scope.onClick : null,
child: const Text('Accept'),
);
Results and errors
smile.onResult = (result) {
switch (result) {
case UseSmileIDSuccess(:final value):
jobs.track(value.jobId);
case UseSmileIDFailure(:final error):
log(error);
case UseSmileIDCancelled():
onCancel(); // your own handler; the flow has already exited
case UseSmileIDCaptured(:final job):
forward(job); // only with config.skipApiSubmission = true
}
};
UseSmileIDCaptured carries a CapturedJob: the image file paths, and the rest of the request
sealed for Smile ID. To forward it, send the job type's /v3 request with job.headers
(smileid-token, smileid-source-sdk, smileid-source-sdk-version), the images as their usual
form parts, and job.encryptedPackage, job.packageNonce and job.packageTag as the
encrypted_package, package_nonce and package_tag form fields. The package holds everything
else the SDK would have sent (the consent record, the metadata and the request signature),
encrypted with AES-256-GCM for Smile ID. Forward it as is: the server rejects a package that was
changed. Forward the image files byte for byte too: they carry an integrity seal that re-encoding
invalidates. Send a
JobType.unknownProduct job to the endpoint of the product you choose, adding that product's own
fields, such as country and id_type, as form fields beside the package.
Send each file under the form part the SDK would have used: selfieImage as selfie_image,
livenessImages as liveness_images in the list's order, frontDocument as document,
backDocument as document_back and visaDocument as visa. The request signature in the package
covers the images by part name and order, so the server rejects a job sent any other way. Forward
the job before its access token expires: headers carries the token from the capture, and the
server rejects the job once that token has expired.
UseSmileIDResult<JobSubmissionResponse> is sealed, so a switch handles all four branches.
UseSmileIDSuccess means Smile ID accepted the submission; it is not the verdict. Its value
carries:
| Field | Type | Description |
|---|---|---|
jobId |
String |
The server-issued job ID |
userId |
String |
The server-issued user ID |
status |
String |
The submission status, such as "submitted" |
message |
String |
A human-readable status message |
createdAt |
String? |
When the server accepted the job, in ISO 8601 |
UseSmileIDCancelled means the user left before the flow finished: no job was submitted and there
is no error. The result does not echo your inputs, so keep any captured data or identity fields you
need at the call site.
UseSmileIDCaptured is delivered only when config.skipApiSubmission = true. Its job is a
CapturedJob:
| Field | Type | Description |
|---|---|---|
jobType |
JobType |
The configured job type |
sessionId |
String |
The flow's session ID, also on every analytics event |
sessionDirectory |
String? |
The session folder the SDK kept for this job. In this mode the SDK deletes neither it nor the files, so once you have forwarded the job, delete every file listed here, then this folder and everything left in it; null when the files don't share one of the SDK's session folders (a document job's selfie and document come from separate capture sessions), or the job captured no files (Enhanced KYC) |
selfieImage |
String? |
The selfie's path, for selfie-bearing jobs |
livenessImages |
List<String> |
The liveness frames' paths, for selfie-bearing jobs |
frontDocument, backDocument |
String? |
The document sides' paths, for document jobs |
visaDocument |
String? |
The visa page's path, for Residency Document Verification |
headers |
Map<String, String> |
smileid-token, smileid-source-sdk and smileid-source-sdk-version, to send as request headers |
encryptedPackage |
String |
The rest of the request, sealed for Smile ID; send it as the encrypted_package form field |
packageNonce, packageTag |
String |
The package's nonce and tag; send them as the package_nonce and package_tag form fields |
On iOS the system can move the app's data container between launches, for example across an app update, so don't save these absolute paths. To forward a job in a later launch, save each path relative to the container and rebuild it then.
The SDK closes its own route once it has called onResult, as for every other result. A host that
embeds the flow without a route removes it in onResult; otherwise the processing screen keeps its
spinner, because nothing was submitted.
Errors the SDK raises extend UseSmileIDException, which carries:
errorCode: aUseSmileIDErrorCodewhosecodeis a stable string for grouping, such as"NETWORK_TIMEOUT"or"BUILDER_VALIDATION_ERROR".message: a message safe to show, with no personal data.suggestedFix: what to change, for triage.cause: the underlying error, such as aNativeNetworkException.
To forward failures to a crash reporter, report the cause and tag the code:
smile.onResult = (result) {
if (result case UseSmileIDFailure(:final error)) {
final smileError = error is UseSmileIDException ? error : null;
Sentry.captureException(
smileError?.cause ?? error,
withScope: (scope) {
scope.setTag('smileid.error_code', smileError?.errorCode?.code ?? 'unknown');
scope.setExtra('smileid.suggested_fix', smileError?.suggestedFix ?? '');
},
);
}
};
Validating inputs early
smile.validate() reports what the flow would when it builds, without building: the screens, the
job-type rules, the pre-supplied consent and the job parameters, every problem at once. An invalid
flow reports the same problems through onResult. A ValidationStateInvalid lists each one with
its message and suggestedFix.
Analytics
Set onAnalyticsEvent to receive events as the flow runs. Each event has a type and a flat
Map<String, String> of extras, which every analytics backend accepts:
smile.onAnalyticsEvent = (event) {
analytics.logEvent(name: event.type, parameters: event.extras);
};
Every event carries session_id and timestamp (epoch milliseconds), so you can correlate one
run's events.
event.kind is the same event as a UseSmileIDAnalyticsEventKind, a sealed class with one subclass
per event type and its typed fields. A minor release can add a subclass for a new event, so keep a
default branch:
smile.onAnalyticsEvent = (event) {
switch (event.kind) {
case UseSmileIDFlowCompletedFailure(:final errorMessage):
log(errorMessage);
case UseSmileIDDocumentCaptured(:final side):
trackSide(side.wireName);
default:
break;
}
};
type |
When | Extras |
|---|---|---|
flow_started |
The flow starts | job_type, job_type_id |
screen_viewed |
A screen becomes active | screen_name |
consent_captured |
The user grants consent | decision |
selfie_captured |
The selfie and liveness frames are captured | liveness_image_count |
document_captured |
A document image is captured, one event per image | document_side: front, back or visa |
retake_requested |
The user goes back to redo a step | none |
selfie_session_restarted |
The selfie session restarts after an unsatisfied scan | reason, frames_discarded, duration_ms |
selfie_frame_rejected |
A liveness frame is rejected; Android and iOS only, since the Flutter bridge does not carry per-frame rejections | reason |
job_submitted |
The submission starts | job_type, job_type_id, attempt |
flow_completed |
The flow ends with a result; a cancellation sends none | result, job_id on success, error_message on failure |
job_type is this SDK's own label, such as documentVerification, and differs by platform.
job_type_id is the numeric id, such as 6, and is the same on Android, iOS, Flutter and React
Native, so group on it when one dashboard takes events from several platforms. error_message is
free-form developer text, not an identifier; leave it out of a backend that is not cleared for it.
Recipes
Document verification
A selfie and both sides of an ID, checked against the document.
UseSmileIDBuilder(
builder: (smile) {
smile.network((network) => network.config((config) {
config.jobType = JobType.documentVerification;
config.token = 'your-v3-token';
config.partnerConfig((partner) {
partner.partnerId = 'your-partner-id';
partner.useSandbox = true;
});
}));
smile.ml((ml) => ml.analyzers((analyzers) {
analyzers.forCaptureType(CaptureType.selfie);
analyzers.forCaptureType(CaptureType.document);
}));
smile.userDetails = const UserDetails(givenNames: 'Ada', lastName: 'Lovelace', email: 'ada@example.com');
smile.documentVerificationParams = const DocumentVerificationParams(country: 'GH', idType: 'NATIONAL_ID');
smile.screens((screens) {
screens.consent((consent) {
consent.partnerName = 'Acme Corp';
consent.partnerIcon = const Icon(Icons.business);
consent.partnerPrivacyPolicyUrl = 'https://acme.example/privacy';
});
screens.capture((capture) {
capture.captureType = CaptureType.document;
capture.document((document) {
document.documentType = const GenericDocument();
document.captureBothSides = true;
});
});
screens.capture((capture) {
capture.captureType = CaptureType.selfie;
capture.selfie((_) {});
});
screens.preview();
screens.processing();
});
smile.onResult = (result) { /* ... */ };
},
)
Residency: passport and visa
JobType.residencyDocumentVerification captures the passport data page and then a mandatory visa
page in the same document capture step, and submits both in one job.
residencyDocumentVerificationParams.idTypemust be'PASSPORT'.- The document capture sets
documentType = const Passport(). allowSkipBackstays unset, since the visa page cannot be skipped.
UseSmileIDBuilder(
builder: (smile) {
smile.network((network) => network.config((config) {
config.jobType = JobType.residencyDocumentVerification;
config.token = 'your-v3-token';
config.partnerConfig((partner) {
partner.partnerId = 'your-partner-id';
partner.useSandbox = true;
});
}));
smile.ml((ml) => ml.analyzers((analyzers) {
analyzers.forCaptureType(CaptureType.selfie);
analyzers.forCaptureType(CaptureType.document);
}));
smile.userDetails = const UserDetails(givenNames: 'Ada', lastName: 'Lovelace', email: 'ada@example.com');
smile.residencyDocumentVerificationParams =
const ResidencyDocumentVerificationParams(country: 'AE', idType: 'PASSPORT');
smile.screens((screens) {
screens.consent((consent) {
consent.partnerName = 'Acme Corp';
consent.partnerIcon = const Icon(Icons.business);
consent.partnerPrivacyPolicyUrl = 'https://acme.example/privacy';
});
screens.capture((capture) {
capture.captureType = CaptureType.document;
capture.document((document) => document.documentType = const Passport());
});
screens.capture((capture) {
capture.captureType = CaptureType.selfie;
capture.selfie((_) {});
});
screens.processing();
});
smile.onResult = (result) { /* ... */ };
},
)
The visa is sent as the visa part, next to the passport's document part.
Consent collected earlier
When the user consented in an earlier session, supply it and leave the consent screen out. The two are mutually exclusive:
smile.consentInformation = const ConsentInformation(
granted: true,
grantedAt: '2026-09-01T10:00:00Z',
noticeLanguage: 'EN',
noticePrivacyPolicyUrl: 'https://acme.example/privacy',
);
App size
Sizes are measured on every push to main and updated automatically by CI.
| Package | Download Size | Install Size |
|---|---|---|
usesmileid |
— | — |
usesmileid_bridge |
— | — |
usesmileid_mlkit_face |
— | — |
usesmileid_mlkit_document |
— | — |
usesmileid_huawei_face |
— | — |
usesmileid_huawei_document |
— | — |
usesmileid_vision_face |
— | — |
usesmileid_vision_document |
— | — |
| Sample app (Android) | — | — |
| Sample app (iOS) | — | — |
The full size tables, including what each provider set adds to a new app, are in the repository README.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
onResult receives a BuilderValidationException and nothing renders |
The flow does not match its job type | Read the exception's issues, or set enableDebugMode to see them on screen. See Job types |
pub get fails on the SDK constraint |
Dart is older than 3.12 | Upgrade Flutter to 3.44 or later |
requires Android Gradle plugin 9.1.0 or higher |
The app is on AGP 8 | Move to AGP 9.1 and a Gradle 9 wrapper, as in Android Gradle Plugin 9 |
| The selfie capture reports no viable analyzer | No face package for the running platform | Add usesmileid_mlkit_face (Android) or usesmileid_vision_face (iOS), or the Huawei package on devices without Google Play services |
| The app crashes when the camera opens on iOS | NSCameraUsageDescription is missing from Info.plist |
Add it with a sentence the user sees in the permission prompt |
| A new token never reaches the flow | The builder read its callback once | Key the widget on the token: UseSmileIDBuilder(key: ValueKey(token), ...) |
401 Unauthorized mid-flow |
The token expired | Set onTokenExpired to fetch a fresh token from your backend |
| The app is left in portrait after document capture | The SDK restores exitOrientations |
Set config.exitOrientations to your app's orientations |
| On an iPad, document capture stays in portrait | The app supports iPad multitasking, so iPadOS keeps it in the orientation it has and never rotates it to the landscape the SDK asks for | Set UIRequiresFullScreen to true in ios/Runner/Info.plist, as the sample does. It opts the whole app out of iPad multitasking (Split View, Slide Over, Stage Manager), and it is a stopgap: verified on iPadOS 27.0 with an app built with the iOS 27 SDK, but Apple deprecated the key in iPadOS 26 and will ignore it in a future release (TN3192), after which the screen may stay portrait again. Flutter still logs Failed to change device orientation … Code=101 when the screen rotates; the rotation happens regardless |
Further reading
- CHANGELOG.md: what changed in each release.
- docs/: the SDK's architecture, starting with docs/Flow-Validation-Rules.md and docs/Network-Architecture.md.
- docs.usesmileid.com: the product documentation, including job types and callback payloads.
License
MIT. See LICENSE.
Libraries
- usesmileid
- UseSmileID Flutter SDK — app-facing package.