
# Flutter SDK

The Flutter SDK opens the full verification flow as a modal sheet via `MyazaKYC.show()`, with **on-device native liveness** (Apple Vision on iOS, Google ML Kit on Android). It uses a **publishable (`pk_`) key** and detects the environment automatically from the key prefix (`pk_test_*` → sandbox, `pk_live_*` → production). For shared concepts (supported countries, branding, results, and errors), see [Client SDKs](https://trust.myaza.co/documentation/sdks/markdown).

> **Feature availability.** This SDK is at **full parity** with the web and React Native SDKs: [workflow](https://trust.myaza.co/documentation/workflows/markdown) embedding (`workflowId`, **≥ 2.2.0**; earlier versions need a placeholder `country` to compile a workflow mount), the capture add-ons (contact OTP, proof of address, questionnaire), business (KYB) verification, any [Global Documents](https://trust.myaza.co/documentation/id-types/markdown) country, plus **NFC chip reading**, which the web SDK can't do at all (browsers can't talk to a passport chip). See [Feature availability](https://trust.myaza.co/documentation/sdks/markdown#feature-availability).

## Install

Add the dependency to your `pubspec.yaml`, then run `flutter pub get`:

```yaml
dependencies:
  myaza_kyc_sdk_flutter: ^2.2.0
```

## Requirements

| Requirement | Minimum |
|---|---|
| **Flutter** | **3.27** (Dart **3.6**) |
| **iOS** deployment target | **13.0** |
| **Android** `minSdkVersion` | **21** (Android 5.0) · `compileSdk` 34 |

Face detection runs **on-device** (Apple Vision on iOS, Google ML Kit on Android: an Android-only Gradle dependency, so there's no cross-platform ML Kit iOS pod and the SDK still builds on Apple-Silicon iOS simulators). Add the **camera** permission on both platforms (there is **no** microphone permission: voice guidance is text-to-speech output only):

```xml
<!-- iOS: ios/Runner/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>We use the camera to photograph your ID and capture a live selfie.</string>
```

```xml
<!-- Android: android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
```

If your workflows use the [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown) step, also add the location strings. Both are best-effort (they power the "Use my current location" shortcut and the attest fix; the pin always works by dragging alone), but iOS **crashes** on the permission request if the usage string is missing:

```xml
<!-- iOS: ios/Runner/Info.plist -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location helps place the map pin on your address.</string>
```

```xml
<!-- Android: android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
```

The SDK bundles `webview_flutter`, which renders the Google map and the Street View entrance framer on the address step inside your app; when that page cannot load, the built-in map and the entrance photo remain. Address fields the workflow marks required are enforced in the app (Continue opens the details sheet naming what is still needed), so a submission is never refused for a missing field at the end of the flow.

### Presence reporting (Address Intelligence): required if you use presence verification

> **Presence verification needs setup in your app.** When the flow is submitted the SDK sends the first report and turns on background monitoring, which only works once your app has the background location setup below. Every other report has to come from your app calling the reporter when it opens. If people do not open your app daily, also turn the [evening reminder webhook](https://trust.myaza.co/documentation/address-intelligence/markdown#if-your-app-is-not-opened-often) into a push notification. If your app has none of these, every presence check collects one day and ends `inconclusive`. The [full checklist](https://trust.myaza.co/documentation/address-intelligence/markdown#what-your-app-has-to-do) is on the Address Intelligence page.

When a workflow enables [presence verification](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification), the SDK stores the confirmed pin on the phone under the `userId` you pass (so pass one) and sends the first report when the flow is submitted. After that, call the reporter when your app starts and every time it comes back to the foreground, for the signed-in user:

```dart
// Mix this into your root widget's State.
class _AppState extends State<App> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _reportPresence(); // the app has just opened
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) _reportPresence(); // back in the foreground
  }

  Future<void> _reportPresence() async {
    final userId = currentUserId; // the same userId the KYC flow ran with
    if (userId == null) return;
    final result = await MyazaAddressPresence.report(apiKey: 'pk_live_…', externalUserId: userId);
    // result.reason: reported | noPin | servicesOff | noFix | outsideFence | noWatch | stopped | networkError
  }
}
```

It is safe to call on every open for every user: for a user this phone holds no address for it returns `noPin` straight away without touching location, and several reports on one day count once. What each `reason` means, and what to do about it, is in the [reason table](https://trust.myaza.co/documentation/address-intelligence/markdown#reporting-presence-from-your-app). With the reporter alone a person needs to open your app at home on 5 different evenings, or on 9 different days, within the default 10-day window; background monitoring below needs 3 nights or 5 days and happens without them doing anything.

It never throws and never blocks startup. The geofence is evaluated **on-device**: only the derived day + night flag is transmitted, never a coordinate. A fix outside the fence sends nothing; a mock-location fix is reported flagged. `clearPresencePin(externalUserId)` drops the stored pin. Stored pins self-expire after 45 days for bounded checks; a pin captured under [always-on monitoring](https://trust.myaza.co/documentation/address-intelligence/markdown#always-on-monitoring) never expires until revoked.

### Background monitoring (native geofencing)

The stronger tier: the OS wakes the plugin's native side on fence crossings, app closed or not. On Android the fence survives reboots, and iOS relaunches the app for crossings by itself. Entries stamp; exits fold the dwell into per-day aggregates natively and flush them.

**It is on by default.** When a presence flow is submitted, the SDK asks the person to allow location all the time and arms the geofence itself; you do not call anything for that. What it cannot do is declare background location for your app, so add the background-location entries to **your own** Android manifest and iOS Info.plist once. The plugin never adds them for you, because the declaration changes your store review posture; the [Background Location Declarations](https://trust.myaza.co/documentation/background-location-declarations/markdown) page carries the ready-to-paste texts. Without them the SDK's request comes back refused and your app's reporter is the only source of evidence. To turn it off for a workflow, switch off **Background monitoring** on the Address step's Presence tab.

You can still arm or disarm it yourself, for example to offer it again later to someone who refused:

```dart
final result = await MyazaBackgroundPresence.enable(
  apiKey: 'pk_live_…',
  externalUserId: 'user_42',
);
// result.reason: started | noPin | permissionDenied | backgroundDenied | unavailable
```

`enable()` asks for location in two steps (while in use, then all the time); `MyazaBackgroundPresence.disable()` disarms and forgets. A refusal leaves the foreground tier working exactly as before.

**Update past 3.5.0.** In versions up to and including 3.5.0 the second step never reached the person, on iPhone or on Android 11 and later. `enable()` answered `backgroundDenied` unless they had already chosen "Always" (iPhone) or "Allow all the time" (Android) in Settings, so background monitoring did not start. Later versions ask. The fix is native on both platforms, so rebuild your app after updating (and run `pod install` for iOS).

Three things follow from how the two systems work:

- **iPhone shows the "Change to Always Allow" prompt once per install.** If the person declines, `enable()` answers `backgroundDenied` from then on without a prompt.
- **Android 11 and later opens your app's location settings page** for the second step, where the person picks "Allow all the time". It is a settings page, not a pop-up. After two refusals Android shows nothing and `enable()` answers `backgroundDenied`.
- **After a refusal on either platform**, explain why you need it, then call `openLocationSettings()`.
- **People who verified before you updated were never asked**, and the reporter now asks for them. See below.

**People already being checked.** The SDK switches background monitoring on when a flow is submitted. People who verified on an older version, and people on always-on monitoring, never pass that step again. From 3.6.0 the reporter does it for them: when their check is still running and background monitoring is not on, a report switches it on. The person sees the system's "allow all the time" prompt once, and the SDK never asks again from a report. If they already allowed it in Settings there is no prompt. Your app still needs the background location setup below. To show your own explanation first, pass `autoBackground: false` and call `MyazaBackgroundPresence.enable()` when you are ready.

### The Android foreground service

On phones whose battery managers drop geofence transitions (Tecno, Infinix, Xiaomi and friends), a persistent notification is what keeps the check alive. Opt-in and Android only. The plugin ships the service class; you declare it, with its two permissions, in your own manifest (the block is on the [Background Location Declarations](https://trust.myaza.co/documentation/background-location-declarations/markdown#the-android-foreground-service) page), then:

```dart
final result = await MyazaPresenceService.enable(
  apiKey: 'pk_live_…',
  externalUserId: 'user_42',
  notification: const PresenceNotification(
    title: 'Address verification in progress',
    body: 'Open the app to see your progress',
  ),
);
// result.reason: started | unsupportedPlatform | noPin | permissionDenied | backgroundDenied | notDeclared | unavailable
```

`enable()` walks the same permission escalation and arms the fence too. A low-power fix every ten minutes becomes the same enter/exit spans the geofence folds, natively, on the same stored state; the queue flushes while the process is alive; a dropped fence is re-armed; a reboot restarts it. `MyazaPresenceService.disable()` stops it. See [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#the-android-foreground-service) for the reasoning.

### Which tier is running?

```dart
final status = await presenceStatus('user_42');
// status.tier: PresenceTier.background | foreground | none, plus pinStored, alwaysOn,
// locationServicesEnabled, both permission states, geofenceArmed, foregroundServiceRunning
if (!status.locationServicesEnabled) {
  await openLocationSettings(target: PresenceSettingsTarget.services); // the toggle is off
} else if (status.tier == PresenceTier.none && status.pinStored) {
  await openLocationSettings(); // the only road back after a denial
}
```

**When evidence is sent.** A stay is reported when the person leaves the area, or at a "still here" check-in: the first 35 minutes after they arrive, then every three hours (versions after 3.5.0; older ones waited three hours for the first). Nothing is sent in between, so the day count does not move while someone simply stays at home. `presenceStay()` returns the stay the phone has open (`since`, `nextReportAt`) or `null`, so your app can tell the person a report is on its way.

To show the person where the check stands on the server, call `fetchPresenceWatchStatus` (versions after 3.5.0; it reads `GET /api/kyc/address/presence/:externalUserId` with the publishable key):

```dart
final check = await fetchPresenceWatchStatus(
  apiKey: 'pk_live_…',
  externalUserId: 'user_42',
);
// null: the server could not be reached.
// check.state: PresenceWatchState.notStarted | inProgress | verified | failed |
//   inconclusive | expired | revoked | unknown
// check.progress (0..1), nightsObserved, daysObserved, deadlineAt, stopped
```

It never throws. See [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#showing-the-person-where-the-check-stands) for what each status means.

## Usage

`MyazaKYC.show()` opens the full flow as a modal bottom sheet (a full-screen page on Android). Note that `context` is a **named** parameter, and the callbacks are passed to `show()` alongside `config`, not inside it.

### Recommended: mount a workflow

Build the flow once in the dashboard as a [workflow](https://trust.myaza.co/documentation/workflows/markdown), then mount it by id. The country, ID types, capture steps, branding and copy all come from the workflow, so changing the flow is a re-publish rather than an app release, which matters more on mobile, where a redeploy means an app-store round trip.

```dart
import 'package:flutter/material.dart';
import 'package:myaza_kyc_sdk_flutter/myaza_kyc_sdk_flutter.dart';

void startKYC(BuildContext context) {
  MyazaKYC.show(
    context: context,
    config: const MyazaKYCConfig(
      apiKey: 'pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      workflowId: 'wf_AbC123dEf456',
      // Runtime data — a workflow is a shared template and cannot carry any of it.
      userId: 'user_42',
      userData: UserData(firstName: 'Jane', lastName: 'Doe'),
      metadata: {'requestId': 'order_1001'},
    ),
    onSubmit: (submission) => debugPrint('Submitted: ${submission.verificationId}'),
    onError: (error) => debugPrint('Error: ${error.code} — ${error.message}'),
    onClose: () => debugPrint('KYC closed'),
  );
}
```

**`userData` is worth passing.** It is the name you believe the user has, and it is compared against the name read off their document, and that comparison is what produces `dataMatch` on the verification. Leave it out and the check simply never runs: there is nothing to compare the document against, and `dataMatch` comes back `null`.

It cannot live on the workflow. `userId`, `userData` and `metadata` are per-user runtime values, and a workflow is a template shared by every visitor, so these stay in code even when everything else moves to the dashboard.

> `country` is optional here: the resolved workflow carries it, exactly as in the React SDK. Pass one only when you are not using a workflow.

### Or configure everything in code

Skip the workflow and pass the flow's shape in `MyazaKYCConfig`. Useful for a quick start or a single fixed flow; anything you'd change later means an app release.

```dart
import 'package:flutter/material.dart';
import 'package:myaza_kyc_sdk_flutter/myaza_kyc_sdk_flutter.dart';

void startKYC(BuildContext context) {
  MyazaKYC.show(
    context: context,
    config: MyazaKYCConfig(
      apiKey: 'pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      country: 'NG',
      idTypes: const ['bvn', 'drivers-license', 'passport'],
      userData: const UserData(firstName: 'Jane', lastName: 'Doe'),
      enableSelfie: true,
      enableDocumentCapture: true,
      enableLiveness: true,
      appearance: const MyazaKYCAppearance(
        primaryColor: Color(0xFF5645F5),
        companyName: 'Myaza',
        logo: 'default',
        theme: MyazaThemeMode.light,
      ),
      consent: const KYCConsentContent(
        title: 'Welcome, {firstName}',
        description: "A quick check to confirm it's really you.",
      ),
      success: const KYCSuccessContent(
        title: "You're all set, {firstName}!",
        description: "We'll email you once your verification is reviewed.",
      ),
      metadata: const {'requestId': 'order_1001', 'userId': 'user_42'},
    ),
    onSubmit: (submission) {
      // The verification was created; submission.status is always 'pending'.
      // The final result arrives via webhook to your backend (or fetch it with a
      // secret-key GET /verifications/:id call, never from the client).
      debugPrint('Submitted: ${submission.verificationId}');
    },
    onError: (error) {
      // Technical errors only (network / 401 / 402 / upload).
      debugPrint('Error: ${error.code} — ${error.message}');
    },
    onClose: () => debugPrint('KYC closed'),
  );
}
```

## Config (`MyazaKYCConfig`)

| Field | Type | Default | Description |
|---|---|---|---|
| `apiKey` | `String` | — | **Required.** Sent as `Authorization: Bearer`. The **environment is derived from the key prefix** (`pk_test_*` → sandbox, `pk_live_*` → production); an unrecognised prefix throws. |
| `workflowId` | `String?` | — | Run a published [workflow](https://trust.myaza.co/documentation/workflows/markdown) (`wf_…`) built in the dashboard. The SDK resolves its configuration on launch and uses it as the source of truth: **workflow config wins over overlapping fields**. Makes `country` optional. Requires **≥ 2.2.0**. |
| `country` | `String?` | — | **Required unless `workflowId` is set.** ISO-2 country whose ID types are offered (`'NG'`, `'GH'`, …). Any ISO country works: the org's grants are enforced server-side. |
| `countries` | `List<WorkflowCountryOption>?` | — | **Multi-region.** More than one entry inserts a country-select step; the picked entry's `idTypes` win. Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). |
| `idTypes` | `List<String>?` | all for country | Subset of [ID type](https://trust.myaza.co/documentation/id-types/markdown) keys to offer (`['bvn', 'passport']`, the same kebab-case keys as the React SDKs); `null` shows everything enabled for the country. |
| `userId` | `String?` | — | Your stable reference for the person being verified: repeat checks of the same `userId` collapse onto one entity, and it's how you correlate results back to your record. |
| `userData` | `UserData?` | — | Pre-fills the user's details. |
| `enableSelfie` | `bool` | `true` | Capture a selfie during liveness. |
| `enableDocumentCapture` | `bool` | `true` | Enable the document-scan step for document IDs. |
| `allowDocumentUpload` | `bool` | `true` | Allow picking a document photo from the device gallery instead of the camera. `false` hides the "upload instead" option, except on the camera-permission-denied screen, where it stays as an escape hatch. |
| `allowDocumentScan` | `bool` | `true` | Allow scanning the document with the live camera. `false` never opens the camera for documents: the applicant picks a photo of each side from the gallery instead. Keep at least one of `allowDocumentScan` and `allowDocumentUpload` on; with both `false` the camera stays on. Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). |
| `enableLiveness` | `bool` | `true` | Run the liveness challenge step. The server can still disable it per ID type. |
| `livenessMode` | `String` | `'gestures'` | How liveness proves presence: `'gestures'` (3D Active Motion, always including a head turn), `'flash'` (3D Flash Check, screen reflection), `'both'` (Dual Check: 3D Active Motion and 3D Flash Check in one run), or `'passive'` (Passive Liveness: the person holds still and a liveness model checks the capture). Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). |
| `deviceIntelligence` | `bool` | `true` | Collect device + IP fraud signals ([Device Intelligence](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons)). |
| `consentStep` | `bool` | `true` | Show the consent (welcome) screen as the first step. `false` when your app has already asked for consent: the flow opens on its first real step. Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). |
| `biometric` | `BiometricFlowConfig?` | see [Biometric re-authentication](#biometric-re-authentication) | The biometric scopes' flow options: `selfieReview`, `resultDelivery` (`'both'` \| `'app'` \| `'webhook'`), `doneButton` and `copy` (your own words on the face check screens, see below). Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown); ignored off the biometric scopes. |
| `voiceGuidance` | `VoiceGuidanceConfig` | enabled (`en-US`) | Spoken liveness instructions (accessibility, TTS **output**, no microphone). `VoiceGuidanceConfig.off` mutes it; `VoiceGuidanceConfig(language: 'fr-FR')` sets the voice language. |
| `selfieReview` | `bool?` | `false` | Show the selfie with Retake and Continue after the liveness capture. Off by default from 3.6.0: the capture hands straight on to the next step. |
| `showThemeToggle` | `bool` | `true` | Show a light/dark toggle in the header. Set `false` to hide it. The flow then stays on `appearance.theme` and the user can't switch it. |
| `disableClose` | `bool` | `false` | Hide the close (X) button and block **all** user dismissal (swipe-down drag, Android back, barrier tap). The flow can then only be closed programmatically by popping the route `MyazaKYC.show()` returns (its `Future` completes on close). The terminal "Submitted" step is non-dismissible regardless. |
| `appearance` | `MyazaKYCAppearance?` | brand defaults | Brand & theme the flow: colours, logo, light/dark. See [Branding & theming](https://trust.myaza.co/documentation/sdks/markdown#branding-theming). |
| `consent` | `KYCConsentContent?` | built-in copy | Override the consent/welcome screen `title` and `description`. See [Consent screen copy](https://trust.myaza.co/documentation/sdks/markdown#consent-screen-copy). |
| `success` | `KYCSuccessContent?` | built-in copy | Override the success/submitted screen `title` and `description`. See [Success screen copy](https://trust.myaza.co/documentation/sdks/markdown#success-screen-copy). |
| `metadata` | `Map<String, dynamic>?` | — | Forwarded with the verify request (include your `requestId`). |
| `livenessConfig` | `LivenessConfig?` | 2 challenges, 8s each | Tune the liveness challenge sequence (see below). |

`UserData` accepts `firstName`, `lastName`, `dateOfBirth`, `gender`, `address`, `phoneNumber` and `email` (all optional). `email` is never asked for in the flow: it is the address your organisation can have the applicant [emailed at about a decision](https://trust.myaza.co/documentation/api-review-verification/markdown#emailing-the-applicant).

## Callbacks

Passed to `MyazaKYC.show()` alongside `config`:

| Callback | Type | Description |
|---|---|---|
| `onSubmit` | `void Function(KYCSubmission)` | Called when the server accepts the verification. `status` is always `'pending'`. |
| `onResult` | `void Function(KYCResult)` | Called once with the verdict on a flow that **waits** for it in the app (a biometric re-authentication on the default delivery). Never on a timeout. See [Biometric re-authentication](#biometric-re-authentication). |
| `onError` | `void Function(KYCError)` | Called for **technical** errors only: receives a typed `KYCError` (`code`, `message`, optional `details`). Verification outcomes don't come through here. See [Errors](https://trust.myaza.co/documentation/sdks/markdown#errors). |
| `onClose` | `void Function()` | Called when the user closes the flow. |

## Biometric re-authentication

A workflow on the **biometric-authentication** scope confirms it is really your user, matching a live selfie against the face you enrolled. Three options on the workflow, plus your own words for its screens, shape how the Flutter flow behaves, exactly as on the web and React Native SDKs. All three live on the workflow's **Presence Intelligence** panel in the builder, under **Flow**, so they need no code change; a config can pass the same `biometric` block.

- **Where the verdict lands**: **In the app and on your webhook** (the default) keeps the person on one loading screen from the moment the selfie is taken until the check settles, polling the status endpoint for up to a minute, then shows whether it was them; your app hears the verdict in `onResult` and your webhook receives it as on every other flow. **In the app only** is the same wait, but the server sends no webhook for that check at all, so the verdict is your app's alone. **Webhook only** shows the ordinary submitted screen and leaves the verdict to your webhook. Enrolment workflows never wait.
- **Selfie review**: whether the captured selfie is shown back with Retake and Continue before it is sent. **Off by default** on the biometric scopes.
- **Done button**: whether the final screen carries a Done button. **On by default.** Turn it off when your app pops the sheet itself from `onResult`; the screen then stays until your app dismisses the SDK.
- **Screen copy**: your own title and description for the screens the person sees, on the workflow's Presence Intelligence panel under **Face check screens**: the loading screen while the check runs ("Checking it's you") and, on a verdict delivered in the app, the verified screen ("You're verified") and the not-verified screen ("We couldn't confirm it's you"). Leave a field empty to keep the default; `{firstName}` and `{lastName}` fill from the `userData` you pass, as on the welcome and success screens. A not-verified description replaces the reason the server sends. Enrolment offers the loading screen only. In code the same block is `biometric.copy` (`waiting`, `verified`, `declined`, each with `title` and `description`).

```dart
await MyazaKYC.show(
  context: context,
  config: const MyazaKYCConfig(
    apiKey: 'pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    workflowId: 'wf_AbC123dEf456', // a biometric-authentication workflow
    userId: 'user_42',               // required: the enrolled user being re-authenticated
  ),
  onSubmit: (s) => debugPrint('submitted ${s.verificationId}'),
  onResult: (r) {
    // r.status is the same vocabulary GET /api/kyc/status/:id serves.
    if (r.status == 'approved') unlockSensitiveAction();
  },
);
```

`onSubmit` still fires the moment the check is submitted. `onResult` fires once when the wait settles, never when it times out, and carries the state and reason pair only (`verificationId`, `status`, `reason`, `reasonCode`); scores and other result data stay behind your secret key. The webhook (where the flow sends one) and `GET /api/kyc/verifications/:id` remain the record.

## Countries & ID types are plain strings

`country` takes any ISO-2 code (`'NG'`, `'GH'`, `'FR'`, …) and `idTypes` takes the **same kebab-case keys as the React SDKs** (`'bvn'`, `'drivers-license'`, `'ghana-card'`, …). See the [ID types](https://trust.myaza.co/documentation/id-types/markdown) catalogue. There is no `Country`/`IdType` enum to import. The one enum you'll meet is `MyazaThemeMode` (`light` / `dark`) on `appearance.theme`.

## Liveness configuration

`LivenessConfig` tunes the active-liveness step. Defaults match the web SDK.

| Field | Type | Default | Description |
|---|---|---|---|
| `challengeCount` | `int` | `2` | Number of gesture challenges drawn from the pool. |
| `challengePool` | `List<ChallengeConfig>?` | `kDefaultChallengePool` | The set of challenges to draw from. |
| `timeoutPerChallenge` | `int` | `8` | Seconds allowed per challenge before it fails. |
| `enableAvatar` | `bool` | `true` | Show the animated avatar that demonstrates each gesture. |

The default pool covers four `LivenessChallenge` gestures: `nod`, `turn`, `blink`, and `smile`.
