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

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:

  • UseSmileIDBuilder is the only entry point. There is no initialisation call and no config file. The builder is a widget; configuration lives in its builder callback.
  • 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. onResult reports 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 the sample app needs.

Installation

The SDK ships as pub packages: the usesmileid entry point plus one analyzer package per capture type and platform.

dependencies:
  usesmileid: ^12.2.0

  # Android, on Google Play (GMS) devices
  usesmileid_mlkit_face: ^12.2.0
  usesmileid_mlkit_document: ^12.2.0

  # iOS
  usesmileid_vision_face: ^12.2.0
  usesmileid_vision_document: ^12.2.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

For Android devices without Google Play services, use the Huawei packages instead of the ML Kit ones. They need nothing else: the plugins add Huawei's Maven repository to your Gradle build and settle the HMS model-dependency manifest attribute between them, so your android/ directory is unchanged.

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

Two behaviours to design around:

  • The builder reads its callback once. UseSmileIDBuilder runs builder when 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 onResult receives one UseSmileIDFailure carrying a BuilderValidationException that lists every issue. Set smile.config((c) => c.enableDebugMode = true) during development to see the issues on screen instead.

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

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, the token satisfies those requirements and its values win at submission. A token that binds consent must bind all four consent fields. 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
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.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
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.add(i.gzip()); i.add(yourDioInterceptor); }) adds Dio interceptors of your own.

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

The document packages have no Dart API: declaring the dependency registers the native document analyzer.

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

UseSmileIDResult<JobSubmissionResponse> is sealed, so a switch handles all three 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.

Errors the SDK raises extend UseSmileIDException, which carries:

  • errorCode: a UseSmileIDErrorCode whose code is 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 a DioException.

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() checks the builder's own blocks (the screens, ml and network) without building; the job-type and screen rules run when the flow builds, and an invalid flow reports them through onResult. A ValidationStateInvalid lists every problem, each 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.idType must be 'PASSPORT'.
  • The document capture sets documentType = const Passport().
  • allowSkipBack stays 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.

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

License

MIT. See LICENSE.

Libraries

usesmileid
UseSmileID Flutter SDK — app-facing package.