Skip to main content

myaza_kyc_sdk_flutter 3.7.0 copy "myaza_kyc_sdk_flutter: ^3.7.0" to clipboard
myaza_kyc_sdk_flutter: ^3.7.0 copied to clipboard

Toden SDK for Flutter. ID verification, liveness detection, NFC chip reading and document capture.

myaza_kyc_sdk_flutter #

Flutter SDK for Toden — identity verification (ID capture, document scan, and on-device liveness) that talks to the Toden API.

New names. The entry points were renamed in 3.7.0 (Verification.show(), VerificationConfig, VerificationError). The earlier names (MyazaKYC.show(), MyazaKYCConfig, KYCError and the rest) still work, so existing code needs no change. The package name is unchanged.

Installation #

Add the dependency to your pubspec.yaml:

dependencies:
  myaza_kyc_sdk_flutter: ^3.7.0

Then run flutter pub get.

Requirements #

The SDK runs face detection on-device (Apple Vision on iOS, Google ML Kit on Android) and uses the camera, so it has native platform minimums:

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

ML Kit is pulled Android-only (native Gradle) — there is no cross-platform ML Kit iOS pod, so the SDK builds and runs on Apple-Silicon iOS simulators.

The iOS plugin ships a Swift package as well as a podspec, so it works whether your app uses Swift Package Manager (the default from Flutter 3.44) or CocoaPods. There is nothing extra to configure for either.

Platform setup #

The SDK needs camera permission on both platforms (there is no microphone permission — voice guidance is text-to-speech output only).

  • iOS — add to ios/Runner/Info.plist:

    <key>NSCameraUsageDescription</key>
    <string>We use the camera to photograph your ID and capture a live selfie.</string>
    

    Set the iOS deployment target to 13.0+: in Xcode (Runner, General, Minimum Deployments), and also in ios/Podfile (platform :ios, '13.0') if your app uses CocoaPods.

  • Android — add to android/app/src/main/AndroidManifest.xml:

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.INTERNET" />
    

    Ensure minSdkVersion is 21 or higher in android/app/build.gradle.

Location (only if your workflow uses Address Intelligence)

Workflows with the address-collection step offer "Use my current location" and can take a one-shot GPS fix at Continue. Both are best-effort — a denied permission never blocks the flow — but iOS crashes on the permission request if the usage string is missing, so add it whenever your workflow enables the step:

  • iOS — ios/Runner/Info.plist:
    <key>NSLocationWhenInUseUsageDescription</key>
    <string>Your location helps place the map pin on your address.</string>
    
  • 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" />
    
    Foreground ("while in use") only — the SDK never tracks in the background.

NFC (opting out if your workflows never read a chip)

The SDK reads the eMRTD chip in passports and chip ID cards, so it depends on flutter_nfc_kit, whose own manifest declares:

<uses-permission android:name="android.permission.NFC" />

Android merges a plugin's manifest into your app's, so that permission appears in your build whether or not your workflows use the chip step. If they never do, remove it:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">
  <uses-permission android:name="android.permission.NFC" tools:node="remove" />
</manifest>

The xmlns:tools declaration on the <manifest> element is required for tools:node to work.

Nothing else needs changing. The chip step already checks for a radio at runtime and skips itself when there is none, so a build without the permission behaves exactly like a phone that has no NFC hardware — the rest of the flow is untouched.

Two things this does not do, so the trade is clear:

  • It does not make the app smaller. flutter_nfc_kit ships no native library (it is Kotlin over Android's platform NFC APIs), so the code removed is negligible. The one measurable item is edu.ucar:jj2000 (~500KB), the JPEG 2000 decoder for the chip portrait, and that stays — it is referenced by Jp2Decoder.kt at compile time. This is about the permission your users see and your store listing, not about bytes.
  • It does not change your iOS build. The NFC entitlement and the NFCReaderUsageDescription are only added if you add them yourself, and flutter_nfc_kit weak-links CoreNFC, so an app that never declares them is unaffected either way.

Device attestation (optional, only with Device Intelligence)

While a workflow has Device Intelligence on, the SDK adds a few device facts to the submission: an id that survives a reinstall (a Keychain item on iOS, ANDROID_ID on Android), root / jailbreak / hook heuristics, and, where the platform supports it, an attestation that the app and device are genuine. None of these prompts the user or needs a permission, and none can block or slow a submission by more than about 5 seconds: whatever cannot be read is left out. The upload requests also carry the same per-install id in an X-Myaza-Device-Id header.

The attestation needs a little setup on your side; without it the SDK simply sends no attestation.

  • iOS (App Attest). Add the App Attest capability to your app target in Xcode, which writes com.apple.developer.devicecheck.appattest-environment to your entitlements (development while testing, production for release builds). Then register your iOS App ID (Team ID + bundle id) with Toden. Simulators and iOS 13 report App Attest as unsupported and send nothing.
  • Android (Play Integrity, Standard API). Toden serves the Google Cloud project number the tokens are requested for. In Play Console, open App integrity → Play Integrity API and link your app to that Cloud project, then register your package name with Toden. Tokens are only issued to builds distributed through Play (internal testing tracks included); devices without Google Play services send nothing. The SDK adds the com.google.android.play:integrity dependency itself.

Root checks on Android look only at packages Android lets an app see without a <queries> declaration, so the SDK does not add one to your manifest.

Usage #

Verification.show() opens the full modal flow as a bottom sheet.

Build the flow once in the Toden dashboard as a workflow, then mount it by id. The country, ID types, capture steps, add-ons, branding and copy all come from the workflow, so changing the flow is a re-publish in the dashboard rather than an app release and an app-store review.

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

void startVerification(BuildContext context) {
  Verification.show(
    context: context,
    config: const VerificationConfig(
      apiKey: 'pk_live_xxx',
      workflowId: 'wf_AbC123dEf456',
      // Runtime data — a workflow is a shared template and cannot carry any of it.
      userId: 'usr_123',
      userData: UserData(firstName: 'Jane', lastName: 'Doe'),
      metadata: {'orderId': 'ord_456'},
    ),
    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 — that comparison is what produces dataMatch on the verification. 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.

Or configure everything in code #

Skip the workflow and pass the flow's shape in VerificationConfig. Useful for a quick start or a single fixed flow; anything you'd change later means an app release. country is any ISO-2 string and idTypes are the same kebab-case keys as the React SDKs — there is no enum.

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

void startVerification(BuildContext context) {
  Verification.show(
    context: context,
    config: VerificationConfig(
      apiKey: 'pk_live_xxx',
      country: 'NG',
      idTypes: const ['passport', 'bvn', 'nin', 'pvc'],
      userData: const UserData(firstName: 'Jane', lastName: 'Doe'),
      enableSelfie: true,
      enableDocumentCapture: true,
      enableLiveness: true,
      appearance: const VerificationAppearance(
        primaryColor: Color(0xFF5645F5),
        companyName: 'Toden',
        logo: 'default',
        theme: VerificationThemeMode.dark,
      ),
      consent: const VerificationConsentContent(
        title: 'Welcome, {firstName}',
        description: "A quick check to confirm it's really you.",
      ),
      success: const VerificationSuccessContent(
        title: "You're all set, {firstName}!",
        description: "We'll email you once your verification is reviewed.",
      ),
      userId: 'test_user_123',
      metadata: const {'orderId': 'ord_456'},
    ),
    onSubmit: (submission) {
      // Fires as soon as the server accepts the request.
      // submission.status is always 'pending' — the result arrives later via
      // webhook to your backend (or poll GET /api/v1/status/:id).
      debugPrint('Submitted: ${submission.verificationId}');
    },
    onError: (error) {
      // Technical errors only (network / 401 / 402 / upload).
      debugPrint('Error: ${error.code} — ${error.message}');
    },
    onClose: () => debugPrint('KYC closed'),
  );
}

Config (VerificationConfig) #

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 unrecognized prefix throws.
workflowId String? — Recommended. Id of a published workflow (wf_…) built in the dashboard. Its configuration is resolved on mount and takes precedence over these fields; makes country optional. Required for business (KYB) verification.
country String? (ISO-2) — Required unless workflowId is set (the workflow carries its own country). Any ISO-2 code works ('NG', 'GH', …) — the org's grants are enforced server-side.
idTypes List<String>? all for country Subset of ID-type keys to offer (['bvn', 'passport'] — the same kebab-case keys as the React SDKs). null shows all for country.
userId String? — Your own reference for the person or business being verified. Sent with the verification so results correlate back to your record. Not matched against the ID.
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 as an alternative to the camera. false hides the "upload instead" option (still offered on the camera-permission-denied screen as an escape hatch).
allowDocumentScan bool true Use the live camera (viewfinder with auto-capture) for document capture. false never opens the camera on the document step: the user picks a photo of each side (front, then back) from their device instead, and no camera permission is requested there. At least one of allowDocumentScan and allowDocumentUpload must stay on; if both are false, the camera is used.
enableLiveness bool true Run the liveness challenge step. Server can disable it per ID type.
selfieReview bool? false Show the selfie with Retake and Continue after the liveness capture. Off by default: the capture hands straight on to the next step.
voiceGuidance VoiceGuidanceConfig enabled (en-US) Spoken liveness instructions (accessibility, TTS output — no microphone). VoiceGuidanceConfig.off mutes it; VoiceGuidanceConfig(language: 'fr-FR') sets the voice. See Robustness & error handling.
appearance VerificationAppearance? brand defaults Brand & theme the flow — colors, logo, light/dark. See Appearance & theming.
consent VerificationConsentContent? built-in copy Override the consent/welcome screen title and description. See Consent screen copy.
success VerificationSuccessContent? built-in copy Override the success/submitted screen title and description. See Success screen copy.
metadata Map<String, dynamic>? — Forwarded with every verify request.
livenessConfig LivenessConfig? 2 challenges, 8s each Tune challenge count, pool, timeout, avatar.
userData UserData? — Pre-fills the user's details.

Environment #

There is no environment field — the SDK derives the environment (and the base URL) from the API key prefix, the single source of truth:

Key prefix Environment Base URL
pk_test_… / sk_test_… sandbox https://trust.myaza.app
pk_live_… / sk_live_… production https://trust.myaza.app

An unrecognized or malformed key throws an ArgumentError from Verification.show() (it never silently defaults).

Callbacks #

Passed to Verification.show() alongside config:

Callback Type Description
onSubmit void Function(VerificationSubmission) Called when the server accepts the verification. status is always 'pending'.
onError void Function(VerificationError) Called for technical errors only — receives a typed VerificationError (code + message). Verification outcomes don't come through here.
onClose void Function() Called when the user closes the flow.

Appearance & theming #

Pass a VerificationAppearance to brand the flow. Each override maps onto the SDK's internal color scheme; unset colors keep the built-in defaults (which differ between light and dark). Setting primaryColor also recolors its derived tints, so the whole brand family follows.

Field Type Description
primaryColor Color? Brand color — buttons, selected states, progress, the shield hero.
primaryTextColor Color? Text/icons rendered on top of primaryColor (e.g. button labels).
accentColor Color? Subtle fills / selected surfaces.
backgroundColor Color? Sheet background.
surfaceColor Color? Cards & panels.
borderColor Color? Borders and input outlines.
textColor Color? Primary text color.
companyName String Shown beside the header logo. Defaults to 'Toden'.
logoAsset String? Local asset path for the logo (Image.asset).
logo String? Network logo URL, or 'default' to use your org's logo. Takes precedence over logoAsset.
theme VerificationThemeMode Initial light/dark mode (defaults to light).

The org logo renders as a small circular avatar at the top-left of the sheet, persistent on every step, alongside companyName.

  • logo: 'https://…/logo.png' — uses that image directly (Image.network).
  • logo: 'default' — pulls your organization's logo configured in the Toden dashboard (returned by the server on mount). A broken/absent image simply hides the avatar.
  • logoAsset: 'assets/logo.png' — uses a bundled asset when no logo is given.
appearance: const VerificationAppearance(
  primaryColor: Color(0xFF0F7B6C),
  primaryTextColor: Color(0xFFFFFFFF),
  surfaceColor: Color(0xFFF4F7F6),
  borderColor: Color(0xFFD7E3E0),
  logo: 'default',
  theme: VerificationThemeMode.light,
),

The welcome/consent step shows a heading and a short description. Override either through the consent field:

Field Type Description
title String? Heading. Defaults to Welcome, {firstName} when a first name is known, else Identity Verification.
description String? Sub-text under the heading. Defaults to the built-in regulatory copy.

Both fields support {firstName} and {lastName} tokens, replaced with the values from userData (empty string when absent), so a custom title can still greet the user by name.

consent: const VerificationConsentContent(
  title: 'Welcome, {firstName}',
  description: "We just need to confirm it's really you. This takes about a minute.",
),

Success screen copy #

After the user submits, the final screen shows a confirmation heading and description. Override either through the success field:

Field Type Description
title String? Heading. Defaults to Verification Submitted!.
description String? Sub-text under the heading. Defaults to the built-in "submitted for review" copy.

Both fields support the same {firstName} / {lastName} tokens as consent, replaced with the values from userData (empty string when absent).

success: const VerificationSuccessContent(
  title: "You're all set, {firstName}!",
  description: "We'll email you once your verification is reviewed.",
),

Business verification (KYB) #

The SDK can verify a business instead of a person — a company-registry lookup, with no document capture and no liveness.

This is workflow-driven: build and publish a business workflow in the Toden dashboard, then pass its id as workflowId. There is no prop to turn it on — the SDK reads the subject type from the resolved workflow.

Verification.show(
  context: context,
  config: const VerificationConfig(
    apiKey: 'pk_test_xxxxxxxx',
    workflowId: 'wf_xxxxxxxx',    // a PUBLISHED business workflow
    userId: 'usr_123',
    // no `country` — the workflow sets the registry country
  ),
  onSubmit: (submission) => print(submission.verificationId),
  onError: (error) => print('${error.code} — ${error.message}'),
  onClose: () {},
);

The steps come from your workflow — you don't configure them here:

Step Shown when What the user does
Consent always Agrees to the business verification
Business details always Picks the registry country (if the workflow offers several) and the verification product (if it offers several), then enters the registration number — or a TIN, for the TIN product — plus the registered business name and any company details the workflow asks for
Directors & owners workflow collects key people Lists directors and 25%+ owners (name, role, ownership %, country, email). Each person with an email is sent a link to verify their identity
Business documents workflow requests documents Uploads each requested document (photo or PDF) — from the photo library, the camera, or Files
Verify your identity workflow requires applicant verification Declares their role at the business, then verifies their own identity with the normal ID + liveness steps
Questionnaire workflow configures one Answers your custom questions
Submitted always Sees the confirmation

Submission works exactly like an individual verification: onSubmit fires with a verificationId and status: 'pending', and the outcome arrives later by webhook or via the status endpoint.

When the workflow requires applicant verification, the applicant's own identity is submitted as a separate verification, linked to the business application server-side. onSubmit reports the business verificationId.

Sandbox. With a pk_test_… key only published test registration numbers are accepted (e.g. RC0000001, RC0000002); anything else returns a Sandbox mode accepts only published test registration numbers error.

Robustness & error handling #

The SDK is resilient to flaky networks, denied permissions, and poor capture conditions, and reports technical failures through onError with a typed code.

Typed errors (onError) #

onError receives a VerificationError with a code, a human-readable message, and optional details. The codes are identical to the React SDK:

onError: (VerificationError error) {
  switch (error.code) {
    case 'camera_permission_denied': /* ask the user to allow the camera */ break;
    case 'insufficient_credits':     /* error.details = { required, balance, currency } */ break;
    case 'network_error':
    case 'upload_failed':            /* shown only after automatic retries */ break;
  }
},
code When it fires
network_error Connection failure / timeout, after retries are exhausted.
invalid_api_key Server returned 401.
insufficient_credits Server returned 402. details = { required, balance, currency }.
upload_failed A media upload failed, after retries are exhausted.
camera_permission_denied The user denied (or the OS blocks) camera access.
feature_disabled Server returned 403 (ID type / feature not enabled for the org).
invalid_workflow The workflowId is unknown, unpublished, or misconfigured for this submission. See Business verification (KYB).
unknown Anything else.

Voice guidance is TTS output — it never records audio, so there is no microphone permission and no microphone error code.

Network resilience #

Media uploads and the verify submission are wrapped in exponential-backoff retry (with jitter), retrying only transient failures (network / timeout / 5xx); terminal 4xx surface immediately. The UI shows "Reconnecting… / retrying (n/3)…" between attempts, and onError fires only after retries are exhausted (upload_failed for uploads, network_error for connectivity).

Camera permission #

If the user denies camera access, the SDK shows a clear "camera access needed" screen with an Open Settings action instead of hanging, and reports camera_permission_denied to onError. The document step always keeps a gallery-upload fallback on that screen as an escape hatch.

Liveness quality guards #

  • Multiple faces — when more than one face is in frame (reported by Apple Vision on iOS / Google ML Kit on Android), the challenge pauses ("Make sure only your face is visible") and resumes automatically when only one face remains. Guards capture quality and a class of spoofing.
  • Lighting — too-dark and too-bright (glare) conditions are detected live during liveness; the SDK shows guidance and discourages auto-capture until lighting is acceptable.

App size #

The SDK adds native machine learning to a host app, and on Android that is where most of its weight sits. The defaults keep it small, and two settings on your side decide the rest.

On-device models are fetched, not bundled. Face detection and text recognition run on Google ML Kit on Android, and the SDK depends on the Play Services variants, which download their models instead of shipping them in your APK. Bundled, the pair costs about 18.5 MB on every arm64 phone (text 10.55 MB, face 7.95 MB), plus model files that ship to every device whatever its architecture. Measured on this package's example app, fetching them takes 23.75 MB off an arm64 phone's download and 16.73 MB off an older 32-bit one.

The SDK asks for both downloads the moment the flow opens, and its manifest names both models, so an install from the Play Store usually fetches them before the app first runs. If a model has not arrived when it is needed, the step says so instead of failing silently. The liveness step waits and explains, and the passport scanner says the printed code cannot be read and lets the person continue without the chip.

The trade is real. The Play Services variants need Google Play Services, so they do not work on Huawei or bare AOSP devices. If you ship to those, add this to android/gradle.properties to get fully offline models back, at 18.5 MB per device:

myazaKycBundledMlKit=true

Ship an App Bundle, or split per architecture. flutter build apk --target-platform limits only Flutter's own engine to one architecture. Plugin native libraries, ML Kit's included, still ship for every architecture, so that APK carries three phones' worth of them. flutter build appbundle lets Play deliver only what each device needs. If you ship APKs, build them with flutter build apk --split-per-abi.

Release builds need compileSdk 36 on every plugin. Several plugins the SDK depends on require it, while a few others, file_picker among them, still declare 34, so a release build stops at checkReleaseAarMetadata. Until they catch up, raise it for every plugin module in android/build.gradle.kts, above any evaluationDependsOn block:

subprojects {
    afterEvaluate {
        val android = extensions.findByName("android") as? com.android.build.gradle.BaseExtension
        val current = android?.compileSdkVersion?.removePrefix("android-")?.toIntOrNull()
        if (android != null && current != null && current < 36) {
            android.compileSdkVersion(36)
        }
    }
}

This changes only which Android APIs a plugin compiles against. Your app's minSdk and targetSdk, which decide how it behaves at runtime, are untouched.

Documentation #

Full documentation, configuration options, and webhook setup: trust.myaza.co/documentation/sdks.

Presence reporting (Address Intelligence) #

When a workflow enables presence verification (addressCollection.presence.enabled), the SDK stores the confirmed pin on-device at capture. Call the reporter from your app on a natural moment (app open works well):

final result = await AddressPresence.report(
  apiKey: 'pk_live_…',
  externalUserId: 'user_42', // the same userId the KYC flow ran with
);
// result.reason: reported | noPin | servicesOff | noFix | outsideFence
//              | noWatch | stopped | networkError

It never throws and never blocks startup. The geofence is evaluated ON-DEVICE: only the derived record (calendar day + a night flag) is transmitted, never a coordinate. A fix outside the fence sends nothing (the server scores presence, never absence); a mock-location fix is reported flagged. clearPresencePin drops the stored pin (sign-out, or once the watch resolves).

When your organisation stops monitoring for a user, the next report switches background location off, forgets the stored pin and answers stopped. Nothing more is sent for that user until a new check starts.

A report also switches background monitoring on for a person whose check is running without it (someone verified on an older version of the SDK). They see the system's "allow all the time" prompt once; the SDK never asks again from a report. Pass autoBackground: false to ask yourself, at a moment you choose.

Background monitoring (native geofencing) #

The stronger tier: the OS wakes the SDK on fence crossings around the stored pin, app closed or not, so dwell and nights accrue with nobody in the loop. Entries stamp a timestamp; exits fold the dwell span into per-day aggregates natively (Kotlin on Android, Swift on iOS) and flush them. As with the foreground tier, only the derived day records ever leave the phone.

final result = await BackgroundPresence.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); a refusal leaves the foreground tier working exactly as before. The second step is asked by the plugin's own native code on both platforms. iOS shows its "Change to Always Allow" prompt once per install. Android 11 and later opens the app's location settings page, where the person picks "Allow all the time", and shows nothing after two refusals. After a refusal enable() answers backgroundDenied without a prompt, and openLocationSettings() is the way back. Versions up to 3.5.0 never reached the second step on either platform. BackgroundPresence.disable() disarms and forgets the config. On Android the fence survives reboots (a boot receiver re-arms it); on iOS, region monitoring relaunches the app for crossings by itself.

Host declarations, required before enable() can succeed. The SDK deliberately does not merge these in, because declaring background location changes an app's store review posture and that decision belongs to you:

Android (AndroidManifest.xml — the example app carries a commented copy):

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

iOS (Info.plist):

<key>NSLocationWhenInUseUsageDescription</key>
<string>Used to confirm your address for verification.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Lets your address stay confirmed automatically.</string>

"Still here" check-ins. A fence only speaks when the person crosses its edge, so without help a stay is only recorded when they leave, and someone who hardly leaves home earns little background evidence. The plugin records a stay while the person is still there: the first time 35 minutes after they arrive, then once every three hours. Until one of those moments (or until they leave) nothing is sent, so the server's day count does not move:

  • On app open, automatically: an inside AddressPresence.report() reading also records the running stay. On iOS every launch or relaunch asks Core Location whether the phone is inside, too.

  • Android, periodically, automatically: a WorkManager job takes one low-power reading about 40 minutes after the person arrives and then about every two hours, with the background permission the tier already holds. It adds no manifest entry.

  • iOS, periodically, optional: add the identifier and the fetch background mode to Info.plist. Without both, nothing is registered.

    <key>BGTaskSchedulerPermittedIdentifiers</key>
    <array>
      <string>co.myazahq.kyc.presence.checkin</string>
    </array>
    <key>UIBackgroundModes</key>
    <array>
      <string>fetch</string>
    </array>
    

The OS decides when a background check-in actually runs, so treat it as best-effort.

presenceStay() reads the stay this phone has open, so your app can say "inside since 18:04, first report due after 18:39" rather than leave the person looking at a count that has not moved:

final stay = await presenceStay();
// null: no stay open. Otherwise stay.since and stay.nextReportAt.
// nextReportAt is "not before": the OS decides when a background check-in runs.

The Android foreground service (reliability on OEM-managed phones) #

A geofence alone is not reliable on Android once a manufacturer's battery manager decides your app is idle: transitions are dropped, nothing says so, and the watch quietly lapses to inconclusive. The phones on that list (Tecno, Infinix, itel, Xiaomi, Oppo, Vivo) are the ones the market carries. A foreground service, with its persistent notification, is the one thing those managers leave alone — and OkHi's own integration guidance for the same markets is exactly this.

Opt-in and Android only (iOS region monitoring is reliable on its own). The plugin ships the service class; you declare it, with its two permissions, in your own manifest, for the same reason you declare background location yourself — a location foreground service changes your Play review posture:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

<application>
  <service
      android:name="co.myazahq.kyc.PresenceForegroundService"
      android:exported="false"
      android:foregroundServiceType="location" />
</application>

Then, after the flow stored a pin:

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

enable() walks the same permission escalation as the geofence tier and arms the fence too. While it runs, a low-power fix every ten minutes (or hundred metres) is turned into the same enter/exit spans the geofence folds, natively, on the same stored state, so the two never double-count a stay; the queue flushes while the process is alive; a fence the OS dropped (a location toggle clears every registered fence) is re-armed; and a reboot restarts it. The notification uses its own channel, so yours are never touched. Word it honestly: it is on screen for days. PresenceService.disable() stops it.

Which tier is running? #

Permissions get revoked in Settings and nothing tells the app. Ask:

final status = await presenceStatus('user_42');
// status.tier: background | foreground | none
// status.locationServicesEnabled / foregroundPermission / backgroundPermission
// status.geofenceArmed / foregroundServiceRunning / alwaysOn
if (!status.locationServicesEnabled) {
  // The phone's location toggle is off: permission granted or not, no fix
  // can be taken. This opens the toggle's own screen.
  await openLocationSettings(target: PresenceSettingsTarget.services);
} else if (status.tier == PresenceTier.none && status.pinStored) {
  // The road back runs through Settings — no OS allows re-prompting in-app.
  await openLocationSettings();
}

Showing the person where the check stands #

Somebody kept from a feature until their address is verified should be able to see the progress in your app, without a webhook relayed through your backend. GET /api/v1/address/presence/:externalUserId with the publishable key answers { status, progress, tier, … } — status is one of not_started | in_progress | verified | failed | inconclusive | expired | revoked, progress.score is 0..1 on WEIGHTED evidence, and an unknown user answers the same not_started shape as a user with no watch.

The SDK wraps that read:

final check = await fetchPresenceWatchStatus(
  apiKey: 'pk_live_...',
  externalUserId: 'user_42',
);
if (check == null) {
  // The server could not be reached.
} else if (check.state == PresenceWatchState.inProgress) {
  // check.progress is 0..1; check.nightsObserved and check.daysObserved
  // are the raw counts; check.deadlineAt is when the check ends.
}

It never throws. presenceStatus() above answers for the phone (permissions, pin, tier); this answers for the check itself.