myaza_kyc_sdk_flutter 3.7.0
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,KYCErrorand 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
minSdkVersionis 21 or higher inandroid/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:
Foreground ("while in use") only — the SDK never tracks in the background.<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
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_kitships no native library (it is Kotlin over Android's platform NFC APIs), so the code removed is negligible. The one measurable item isedu.ucar:jj2000(~500KB), the JPEG 2000 decoder for the chip portrait, and that stays — it is referenced byJp2Decoder.ktat 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
NFCReaderUsageDescriptionare only added if you add them yourself, andflutter_nfc_kitweak-linksCoreNFC, 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-environmentto your entitlements (developmentwhile testing,productionfor 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:integritydependency 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.
Recommended — mount a workflow #
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). |
Logo #
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 nologois given.
appearance: const VerificationAppearance(
primaryColor: Color(0xFF0F7B6C),
primaryTextColor: Color(0xFFFFFFFF),
surfaceColor: Color(0xFFF4F7F6),
borderColor: Color(0xFFD7E3E0),
logo: 'default',
theme: VerificationThemeMode.light,
),
Consent screen copy #
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.
onSubmitreports the businessverificationId.
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
fetchbackground mode toInfo.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.