On this page
Address strings in our markets are free prose: "3rd house after the yellow kiosk, off Adeola Odeku" is a real address and no string comparison will ever match it against a utility bill. Address Intelligence sidesteps the string entirely. The applicant places a map pin on where they live, optionally adds directions and an entrance photo, and the platform corroborates the pin against the evidence it already holds: the government record's address, the Proof of Address document, and the submission IP's country.
The result is a soft sub-result. It never changes the verification's own pass/fail status; it feeds decisioning, the webhook, and the dashboard's verification detail, where the pin renders on a map beside the tier badge that says how strongly it is corroborated. It is always the applicant's own claim, never presented as a verified fact.
On a business (KYB) flow the pin is the business premises, checked against the registry's registered address, and the entrance-photo slot is not offered.
Turning it on
Address collection is a workflow step: toggle Address Intelligence in the builder (it sits after Proof of Address on KYC flows, and right after the business-details step on KYB flows). The config block:
{
"addressCollection": {
"enabled": true,
"requirePin": false,
"photo": "optional",
"directions": "optional",
"attestPresence": true
}
}| Field | Meaning |
|---|---|
enabled | Adds the step to the flow. |
requirePin | When true, the step cannot be skipped, and a submission without a pin is refused with 422 address_collection_required. Default false: the applicant may skip. |
photo | The entrance-photo slot: off, optional (default) or required. Ignored on KYB flows, which never offer the slot. |
directions | The free-text directions field, same three modes. |
propertyFields | The default for every typed address detail (number, street, unit, building name, neighbourhood, city, state, area code): off, optional (default) or required. required here means the house or flat number must be given — the addressing fact — while the building name stays optional. |
fields | Per-field overrides on the same three modes, keyed by propertyName, propertyNumber, street, unit, neighbourhood, city, state, postcode. A field set here wins over propertyFields, so you can require a city without requiring a plot number. |
attestPresence | Take a one-shot GPS fix from the device when the applicant confirms. This is the evidence behind the attested tier. Best effort: a denied permission or a slow fix costs the tier, never the flow. |
presence | Phase 2: multi-day presence verification of the pin. { "enabled": true, "windowDays": 10, "minNights": 3 } mints a watch when the verification passes; see below. With it on, the step shows a plain-language consent notice (how it works, the person's control, data protection) and the success screen sets expectations: keep location on, periodic at-address checks, a notification when confirmed. |
The workflow is authoritative. If the step is off, a submitted address block is dropped (never stored, never billed); if requirePin is set, the server enforces it whatever the client did. A field set to off is stripped at submit rather than stored, and a required field left blank is refused with 422 missing_address_fields, which names the offending keys in missing.
Require carefully. Someone in an unnumbered compound genuinely cannot answer a required house number, and a required field they cannot fill is a dead end rather than better data. The applicant is the only honest source of a plot number; the map can supply the street and the area, which is why those prefill.
Assurance tiers
Every collected pin resolves to a tier, strongest first:
| Tier | Meaning |
|---|---|
attested | The person provably stood at the pin: the device's own GPS fix at confirmation was within a tolerance of the pin (250 m, widened to the fix's reported accuracy, capped at 1 km). |
corroborated | An independent record names the pin's locality: the government record's address, or the Proof of Address document. |
null | A collected claim with nothing yet backing it. Still useful for delivery, field visits, and future re-checks. |
Corroboration is deliberately locality-level (city/area, not street): street-level comparison of free-prose addresses manufactures no_match verdicts out of formatting. IP evidence stays country-level, because mobile carrier networks route thousands of subscribers through a handful of gateways and the city an IP resolves to is the gateway's, not the person's.
What comes back
The verification result and the completion webhook carry data.addressIntel (null when the step did not run):
{
"addressIntel": {
"status": "collected",
"tier": "attested",
"pin": { "lat": 6.4281, "lng": 3.4219, "accuracy": 12 },
"plusCode": "6FR5CCHC+6Q",
"property": { "name": "Sunrise Villa", "number": "11", "unit": "Flat 4" },
"claimed": { "neighbourhood": "Oniru", "city": "Lagos", "state": "Lagos", "postcode": "106104" },
"composedAddress": "Flat 4, Sunrise Villa, 11 Adeola Odeku Street, Oniru, Lagos, Lagos, 106104",
"capturedAtAddress": true,
"distanceMeters": 38,
"corroboration": {
"govRecord": "match",
"poaDocument": "unknown"
},
"geocode": { "city": "Lagos", "state": "Lagos", "country": "NG" },
"derivedAddress": "Adeola Odeku Street, Victoria Island, Lagos",
"directions": "black gate opposite the kiosk",
"entitiesAtSamePin": 0,
"entitiesAtSamePin30d": 0,
"heldMatch": true,
"knownAddresses": 4,
"corroborationSources": { "govRecord": "held" },
"checkedAt": "2026-08-25T12:00:00.000Z"
}
}statusandtierare add-only vocabularies; Phase 2's presence verification will extend both.plusCodeis the pin's 10-digit Plus Code, the open, offline-computable address handle for places without street addresses. Derived from the pin, never an input, and typeable into any maps app.derivedAddressis a street-level display line reverse-geocoded from the pin, the way OkHi builds its formatted addresses. It deliberately never carries a house number: map data holds street names, not plot numbers, and inventing one would dress a guess as a fact. Null where the map knows too little to say anything.propertyandclaimedare what the applicant typed in the edit-details form:propertycarries the building name, number and unit;claimedcarries their own corrections to the area fields (neighbourhood, city, state, area code). They are claims, recorded verbatim and clearly labelled as such — they never feed the corroboration verdicts, which stay on the map's own answer.composedAddressmerges the claims with the derived street into the full line, unit first. Two provenances in one string, which is why it is labelled composed and never presented as an official record. Null when the applicant typed nothing.capturedAtAddress/distanceMetersreport the attest fix against the pin (nullwhen no fix was taken).- Each corroboration source answers
match,no_matchorunknown(nothing to compare against). On a KYB flowgovRecordis the premises verdict: whether the pin's locality agrees with the registry's registered address. entitiesAtSamePincounts other subjects of yours whose pin lands on the same map cell, andentitiesAtSamePin30dthe same count over the trailing 30 days. Shared households are ordinary, so a small all-time number means little; a large recent one is the multi-accounting signal worth routing to review. It is a review flag, never an identity link and never a block.- When a submission carries nothing to corroborate against, the verdicts fall back to the address evidence the subject's entity already holds from earlier verifications.
corroborationSourcessays where each verdict's evidence came from (submissionorheld),heldMatchanswers whether the pin agrees with anything already held (null when nothing is held), andknownAddressescounts the entity's address-evidence records. - The entrance photo arrives as
media.addressPhoto, fetched like every other media URL with a secret key.
Decisioning
The address.* fields join the condition namespace, so a graph can route on them:
| Field | Type |
|---|---|
address.collected | boolean (null when the step did not run) |
address.tier | attested / corroborated / null |
address.capturedAtAddress | boolean |
address.distanceMeters | number |
address.govRecordMatch | match / no_match / unknown |
address.poaMatch | match / no_match / unknown |
address.entitiesAtSamePin | number |
address.entitiesAtSamePin30d | number |
address.heldMatch | boolean (null when the entity holds nothing to compare) |
address.knownAddresses | number |
A typical pattern: fast-track when address.tier exists, and send address.entitiesAtSamePin >= 3 or address.govRecordMatch equals no_match to review. Conditions on null values fail closed, so a flow without the step never trips an address rule.
Which map renders where
There is nothing to configure and no map vendor account to bring. Google Maps is bundled with the platform:
| Surface | Map |
|---|---|
| Hosted links (including key-person invites, step-up and re-run links) | Google Maps, automatically. The hosted pages run on our origin, so the platform key stays locked to it. |
| Embedded web SDK on your own site | Google Maps, automatically, through a small frame served from our origin (the map itself always runs on our pages, which is what keeps the key safe on any domain). No setup on your side. |
| Mobile SDKs (React Native, Flutter) | Google Maps, automatically, inside a small in-app web view served from our origin, on the same footing as an embedded web mount. React Native needs the optional react-native-webview peer; Flutter bundles webview_flutter. Without the web view, or when the page does not load, the built-in picker renders instead. |
If the map vendor ever fails to load, or the frame cannot verify the page embedding it, the step falls back to the built-in OpenStreetMap picker rather than going blank — the flow never depends on Google to finish. The interaction is identical everywhere: the map moves under a fixed centre pin, so the applicant drags the world, not the marker. Google surfaces render satellite imagery with labels, because "find your roof" is answerable in a dense compound where "find your street on a road map" often is not.
SDKs and permissions
All three SDKs render the step, including hosted links: a draggable map (fixed centre pin), an address search box (type "11 Bassey Street", pick a candidate, and the pin lands there with the house number prefilled; search runs on explicit submit and is available whenever the platform's geocoder is on), a "Use my current location" shortcut, an edit-details sheet where every part of the address is correctable — number, street, unit, building name, directions, neighbourhood, city, state and area code, prefilled from the map's own answer, each optional or required as the workflow sets it, with the flow's country shown read-only — plus the entrance photo. The pin always works by dragging alone, so location permission is never required to finish the flow; it powers only the shortcut and the attest fix.
On every SDK the step is a short guided flow rather than one long screen: find the address (search, with as-you-type suggestions where the platform has Google Places configured, ranked so streets near the applicant come first), confirm the pin (the edit-details sheet opens over the map), show the entrance, then review and confirm. A returning applicant with a confirmed pin lands straight on the review screen. The React Native and Flutter SDKs walk the same four steps as the web SDK, with the map and the entrance framing sized so Continue stays on screen on a small phone.
A field the workflow marks required is enforced in the app on all three SDKs: Continue on the pin step opens the details sheet naming what is still needed, required fields carry an asterisk, and the review step's confirm stays disabled until they are filled. A required field the applicant left on its map prefill counts as filled.
Street View entrance framing
Where a workflow enables it (Address step, "Street View entrance"), an applicant can frame their entrance in Google Street View instead of, or as well as, taking a photo: they pan the street imagery until the entrance sits inside a frame, and the platform fetches that exact view server-side. It is stored as its own capture, separate from the applicant's photo, and both the dashboard and the webhook media object label which is which (media.addressPhoto is the applicant's own photo; media.addressStreetView is the framed street imagery).
It runs on hosted links, on embedded mounts and in the mobile SDKs alike: an embedded or mobile SDK never receives a maps key, so it frames the imagery inside a page served from our own origin, which the platform hands it at mount (React Native needs the optional react-native-webview peer for this; without it the entrance photo is the route).
required removes the skip affordance while imagery exists, but it is a client-side nudge, not a gate. Street View coverage is not guaranteed anywhere, so where the pin has no coverage the card explains and the photo remains the route; the server never refuses a submission over a missing frame.
- Web: the browser's standard geolocation prompt. Nothing to configure.
- React Native: the SDK's Expo config plugin adds the iOS usage string and Android foreground location permissions by default (
location: falsein the plugin options opts out). - Flutter: add the platform permission strings to your host app; see the Flutter SDK setup. iOS crashes on the permission request if
NSLocationWhenInUseUsageDescriptionis missing.
Sandbox and billing
The step bills through the address_collection component when a submission carries an address block the workflow accepts; sandbox and development runs are free. On a test ID submission the result is canned: attested when a device fix rode along, else corroborated, so both tiers are exercisable at no cost.
Selecting the outcome. Outside production, metadata.sandboxOutcome accepts the address tokens address_attested, address_corroborated, address_collected and address_mismatch — the same contract as the business lookup's verified/not_found pin. The token decides the judged fields (tier, captured-at-address, corroboration verdicts) while the geocoded lines stay real; address_mismatch is the one your review-routing rules exist for. Production ignores the token silently. On hosted dev and sandbox flows the review step also shows a Test result control that sends the token for you.
Presence verification
The strongest tier goes beyond the moment of capture: with addressCollection.presence.enabled, a passing verification mints a presence watch that runs over the following days (the windowDays, default 10) and resolves to one of three verdicts:
| Verdict | Meaning | Billed? |
|---|---|---|
verified | Enough distinct nights (default 3) or qualifying days of at-pin evidence. The address.presence_verified trust signal fires. | Yes |
failed | An integrity contradiction was caught: repeated mock-location evidence at the fence. Absence is never a failure; only caught spoofing is. | Yes |
inconclusive | The window lapsed without enough evidence. Not a finding against anyone. | No |
Evidence arrives two ways, weighted strongest first: OS geofence dwell (mobile, org opt-in, weight 1.0) and foreground one-shot checks (the default tier: your app calls the SDK's presence reporter on app open, no background permission needed, weight 0.6). Both are evaluated on the device. There is deliberately no permissionless network tier: a country-level IP cannot evidence an address, so someone who declines location entirely is unverifiable by presence, and Proof of Address is their honest route.
Privacy is structural: the phone evaluates the geofence on-device against the pin it stored at capture, and only the derived record (calendar day + dwell + a night flag) is ever transmitted. Raw location traces never leave the device, and the server stores per-day aggregates only.
Knowing a check is running
A presence watch takes days to resolve, so you are told when it starts as well as when it ends. entity.address_presence_started fires once, the moment the watch is minted, whether that was a passing verification, your own POST /api/identity/entities/:externalUserId/address/verify call, the dashboard, a re-attestation, or the next cycle of an always-on chain (startedVia says which). It carries the watchId, your externalUserId, the policy the watch has to clear (windowDays, minNights, minDays) and the deadlineAt it must decide by, so you can show a real progress bar rather than a bare "in progress". Like every presence payload it carries no pin and no coordinate.
Re-minting for a customer who is already being monitored returns the live watch and fires nothing, because no new monitoring began.
There is deliberately no per-night progress webhook. Observations are per-day aggregates by design, and pushing each one to your endpoint as it lands would turn a compliance check into a location feed about your customer. Progress is a pull instead, at whatever cadence you want it:
GET /api/identity/entities/:externalUserId/address(secret key, your backend) returns the livepresenceblock withstatus,nightsanddays.GET /api/kyc/address/presence/:externalUserId(publishable key, your app) returns the same state plus a 0..1progress.score, for showing the person where their own check stands.
So: two pushes per watch, one when it starts and one when it decides, and a pull for everything between them.
While a watch is open, GET /api/kyc/status/:id also reports waitingOn: "address_presence" on the verification it belongs to, so a row that is still processing says why rather than looking stuck.
Holding approval until the address is confirmed
By default a verification is decided on the instant capture and can be approved while the watch that was meant to confirm the address is still running. Hold approval until the address is confirmed, on the Presence tab of the Address Intelligence step, stops that: the decision waits for the watch, approves a confirmed address, declines caught spoofing, and sends an unresolved one to review.
The switch writes the Presence wait step into your decision graph, so it is the same thing as adding that step on the canvas and you can do either. Set how long the decision waits (up to 30 days); make it at least as long as the watch's own window, or the decision gives up before the evidence is in and everything routes to review. If your graph already routes on the presence result in its own way the switch leaves it alone and says so, because rewriting it could change decisions you wrote deliberately.
The foreground reporter
Call it from your app on a natural moment (app open works well) for any user with an active watch:
// React Native
import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native';
await reportAddressPresence({ apiKey: 'pk_live_…', externalUserId: 'user_42' });// Flutter
await MyazaAddressPresence.report(apiKey: 'pk_live_…', externalUserId: 'user_42');Calling it the moment the flow submits is fine too: the watch is created a few seconds after a submission is accepted, so a report made from a pin captured within the last 15 minutes waits for the watch (up to 90 seconds) instead of arriving before it exists.
It never throws and never blocks: a missing pin, a denied permission, no live watch or a network fault come back as a reason. A fix outside the fence sends nothing (the server scores presence, never absence); a mock-location fix is reported flagged, because evidence of fraud is worth more to the watch than silence.
Holding the decision on it
Add the Presence wait step in the decision graph (or start from the presence-verified template): the run parks until the watch resolves, then address.presenceStatus, address.presenceNights and address.presenceDays route the outcome. The wait skips cleanly when no watch was minted, so it is safe on mixed traffic. You can also drive it server-to-server: POST /api/identity/entities/:externalUserId/address/verify mints a watch for an existing entity, …/address/revoke withdraws it, and the entity.address_verified / entity.address_presence_failed webhooks carry the resolution.
Sandbox fast-forwards the whole lifecycle: outside production the window runs in minutes, and the persona tokens presence_verified, presence_fail and presence_inconclusive on externalUserId produce canned streams through the real scorer — a plain user gets no canned stream, so you can drive your own reporter calls end to end, free.
Book triage
Before anything is deployed in your app, you can grade your existing book: upload a CSV (an address column, plus optional customer_id, name, country) at Compliance → Address triage in the dashboard, and each row comes back as corroborated (the evidence already held agrees on a locality — nothing to do), confirmable_in_app (a customer relationship exists, so a short re-capture flow confirms it: send a step-up link, or let the address step run on their next verification; there is no capture to reuse, so it is a short flow rather than one tap), or needs_presence (presence verification is the route to evidence). The distribution chart is the remediation plan; the CSV export carries every row. Metered per row via the address_triage component; runs of up to 5,000 rows.
Stability, re-attestation and the certificate
Once presence data flows, three more surfaces build on it:
- Stability attestation —
GET /api/identity/entities/:externalUserId/addressanswers the tier, months of tenure at the current address, astrong/moderate/weakstability grade, and the move count over 24 months. An origination-time pricing input. - Re-attestation —
POST …/address/reattestruns a short-window watch that answers "still at this address: yes / no / unknown". Address-status answers only, under the consent taken at onboarding; never live whereabouts. Billed on resolution like any watch. - Navigation packet —
GET …/address/packetreturns the pin, the entrance-photo URL and the directions your flow collected: your own data, packaged for a delivery or recovery team. - Verifiable certificate — the dashboard's entity Address card issues a PDF certificate whose QR resolves to a public, tokenised status page. The page answers the tier and confirmation dates only — never the address, the name, or a coordinate — and the underlying events sit on a tamper-evident, hash-chained audit log.
One privacy rule worth knowing: the pin is never used to link or merge identities, and same-pin density only ever flags for review. Coordinates stay your customer's collected data; they ride your webhook and your dashboard, and nothing else.
Address-only workflows (standalone, no identity step)
Address Intelligence also runs without any identity verification, for books
whose KYC already happened elsewhere (imports, a previous partner). Set
scope: "address" on a workflow (or start from the Address-only
verification template) and the flow becomes: consent, optional contact
checks, the address evidence you asked for, done. No ID step, no biometrics;
publish rejects every identity-evidence field on such a workflow, and the
consent screen stops claiming captures that will never happen.
You choose how the address is verified. There are two honest ways and the workflow picks either or both:
| On the rail | What the applicant does | What you get |
|---|---|---|
| Address step | Drops a pin on their home, optionally a door photo and directions | The pin, its tier, corroboration against the evidence you hold, and presence monitoring if you want it |
| Proof of address | Uploads a utility bill, bank statement or similar | The document read, its name match and recency, and the address extracted from it |
Turning one off is a switch on the rail, and the builder locks whichever is the last one left on: a flow that verifies neither would verify nothing, so that is the only combination publish refuses. A document-only flow never opens a map, and a pin-only flow never asks for a document.
Bill accordingly: each step is priced on its own, so you are never charged for the one you did not turn on.
Because there is no ID to name a country, the applicant declares their own, and the flow corrects itself as evidence arrives. It opens on the country the visitor's IP suggests, which is a guess and treated as one: the moment the pin resolves to a country, that replaces the guess, and picking a search result replaces even an explicit choice, because it is the newest and most specific thing the applicant has said. An accepted-country list on the Proof of Address step bounds all of it, so the flow never defaults to a market you do not take. Country search is biased, never restricted: a wrong guess must not make the right address unfindable.
Submissions carry idType: "address" (the workflow is required, exactly as
KYB flows require theirs) and bill only the address-side components:
address_collection, plus proof of address, contact OTPs, the questionnaire
and device intelligence where enabled. The row completes with the
corroboration verdicts and tier on addressIntel (also returned by
GET /api/kyc/verifications/:id), and never fails on a corroboration gap; the
decision layer is where you branch, including holding on the presence watch.
The linking contract is the important part for an existing book: mint a
session per customer (POST /api/kyc/sessions with their externalUserId,
or share the hosted link) and the capture lands on the entity you already
imported, writing the pin and nothing else. An EXTERNAL_VERIFIED
provenance, the identity link and the profile your partner's KYC established
all survive untouched; a customer with no existing record gets a plain
UNVERIFIED entity, which is exactly what it is. Presence verification,
always-on monitoring, certificates and triage then work exactly as below.
When capture happens on a hosted web link but presence should run in your own
app, hand the SDK the pin yourself: savePresencePin(externalUserId, { lat, lng }) (React Native) / savePresencePin(externalUserId, lat, lng) (Flutter)
is the org-side handoff that arms the foreground and background tiers.
Always-on monitoring
Set presence.alwaysOn: true on the workflow's address step and presence verification stops being a one-off: each resolved check automatically starts the next cycle, so the address keeps being confirmed month after month. Coverage is billed per entity per year, priced by cadence: the first presence check bills on its own resolution as usual, and every renewal cycle after it is covered by the year charge. presence.cadenceDays picks from a closed menu — monthly (30), every two months (60), quarterly (90, the default), every six months (180) or yearly (365) — each with its own rate-card row (address_monitoring_30 through address_monitoring_365), so a monthly book pays more per year than a yearly one. There is deliberately no back-to-back mode. In year one a monitored entity costs the capture plus the first check plus the year of coverage (quarterly example: ₦48 + ₦240 + ₦800, about ₦1,088 all-in); every year after is the coverage fee alone, and only the FIRST cycle ever bills the per-check price, so presence and monitoring never charge twice for one piece of work. Per-day observation rows are retained for 365 days after a watch resolves and then pruned; the watch's counters, verdict and score remain the durable record. Results arrive through the same entity.address_verified and entity.address_presence_failed webhooks, so "this person is no longer staying at the address" arrives as an ordinary resolution with verdict: "inconclusive". A short balance pauses the chain at its next renewal and it resumes automatically after a top-up. Monitoring stops when you revoke the watch, when a check fails on tampered evidence, or when the entity leaves your book. On mobile, pair it with the background tier and the OS confirms residency with no further user action. React Native: install expo-task-manager, set the config plugin's location: "always", call registerBackgroundPresence() at your app root and enableBackgroundPresence() after capture. Flutter: declare the background-location entries in your own manifest and Info.plist, then call MyazaBackgroundPresence.enable(); the plugin's native side survives app death, and on Android a reboot too. Declaring background location changes your app's store review posture, which is why it is opt-in at every layer on both platforms; the Background Location Declarations page carries the ready-to-paste Play Console and App Review texts.
Knowing which tier is running
A person can revoke the location permission in Settings and nothing tells your app. Both SDKs expose presenceStatus(externalUserId), which answers with the tier that is ACTUALLY running (background, foreground or none), the stored-pin state, the phone's location-services toggle (locationServicesEnabled: off, nothing can run whatever the permissions say, and the reporter answers services_off), both permission states, whether the geofence is armed and whether the Android foreground service is running; openLocationSettings() is the recovery path, since neither OS allows re-prompting in-app after a denial, and on Android openLocationSettings('services') opens the toggle itself. The dashboard's presence table shows the same fact from the server's side: each watch carries an evidence-tier chip derived from the sources its observations actually arrived on, so a watch quietly running on app-open moments alone, with no geofence reporting, is visible at a glance.
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, and a webhook relayed through your backend is the long way round. GET /api/kyc/address/presence/:externalUserId (publishable key, handoff-capable) answers:
{
"status": "in_progress",
"watchId": "aw_…",
"startedAt": "2026-09-03T09:12:00.000Z",
"deadlineAt": "2026-09-13T09:12:00.000Z",
"decidedAt": null,
"progress": { "score": 0.6, "nightsObserved": 3, "daysObserved": 4 },
"tier": "foreground",
"alwaysOn": false,
"nextCycleAt": null
}status is one of not_started, in_progress, verified, failed, inconclusive, expired or revoked (add-only). progress.score is the completion of the watch's bar on weighted evidence: five foreground nights and three geofence nights both read 1, so a foreground-only phone never looks further along than the scorer will credit it; the raw counts ride beside it for context. tier is what is actually feeding the watch. The endpoint is enumeration-safe (an unknown user, a business entity and a user with no watch all answer the identical not_started shape) and PII-light by construction: never the pin, the address or a coordinate. Poll it on app open, and send a notification of your own when it turns verified to bring the person back.
The Android foreground service
A geofence alone is not reliable on Android once a manufacturer's battery manager decides an 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 Nigerian and Kenyan markets carry. A foreground service, with its persistent notification, is the one thing those managers leave alone, and it is what OkHi's own integration guidance recommends for the same markets.
Both mobile SDKs offer it as a third opt-in beside the geofence: React Native enableForegroundService({ …, notification: { title, body } }) (the location: "always" plugin setting declares the two permissions it needs) and Flutter MyazaPresenceService.enable(…, notification: PresenceNotification(…)) (you declare the service and its permissions in your own manifest; the Background Location Declarations page has the block). While it runs, a low-power fix every ten minutes is turned into the same enter/exit spans the geofence folds, 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 is re-armed; and on Android a reboot restarts it. Android only: iOS region monitoring is reliable on its own, and continuous background updates there would be the heavier posture for nothing. The notification is yours to word, and it is on screen for days, so word it honestly.