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 |
Presence verification needs setup in your app. Switching it on in the workflow starts the check, but only a phone can collect the evidence. The SDK does two things by itself when the flow is submitted: it sends the first report, and it asks the person to allow location all the time so the phone can report with your app closed. That background part only works if your app has the background location setup below. Every other report has to come from your app calling the presence reporter when it opens. If your app has neither, every watch collects one day, never reaches what it needs, and ends
inconclusive.
What your app has to do
- Run the flow in the React Native or Flutter SDK. A hosted link or the web SDK runs in a browser, which cannot report presence, so no watch starts there.
- Pass your user reference as
userIdwhen you mount the flow. The SDK stores the pinned address on the phone under that value, and your app reports with the same value asexternalUserId. Without one, no watch starts. - Add the background location setup to your app (React Native, Flutter). Background monitoring is on by default: after the flow is submitted the SDK asks for the "always" location permission and arms the geofence itself (the Flutter SDK does this only in versions after 3.5.0), and from then on the phone reports whenever the person is at the address, even with your app closed. It can only do that if your app declares background location (and, on React Native, calls
registerBackgroundPresence()at startup). Without that setup the SDK skips the step and nothing is asked. To turn it off for a workflow, switch off Background monitoring on the Address step's Presence tab. - Call the presence reporter every time your app opens or comes back to the foreground, for as long as the watch runs. This covers people who refuse the "always" permission, and phones whose battery saver stops background work. The code is under Reporting presence from your app. From React Native SDK 3.5.0 and Flutter SDK 3.6.0 a report also switches background monitoring on for a person whose check is running without it (someone who verified on an older version): they see the system's "allow all the time" prompt once. Pass
autoBackground: falseto ask at a moment you choose. - Subscribe to the evening reminder and turn it into a push notification, if people do not open your app every day. See If your app is not opened often.
- Check it is working. After a day or two,
GET /api/kyc/address/presence/:externalUserIdor the Presence monitoring page in the dashboard should show days going up. Days stuck at 0 mean no report is arriving, and the reporter'sreasontells you why. See If days stay at 0.
How much evidence a watch needs. With the default policy a watch verifies on 3 nights or 5 days at the address within 10 days. Background monitoring counts a full night or day for every stay of 30 minutes or more, with nothing asked of the person. A report from your app counts a little over half of one (0.6), because it shows a moment rather than a stay, so with the reporter alone the person has to open your app at home on 5 different evenings (between 20:00 and 06:00, phone time) or on 9 different days within the window.
Evidence arrives two ways, weighted strongest first: OS geofence dwell (background monitoring, on by default, weight 1.0) and foreground one-shot checks (your app calls the SDK's presence reporter when it opens, 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.
Where a watch can start. Evidence only ever comes from a phone, so a watch is started only when one could report against it: the flow ran in the React Native or Flutter SDK, you passed your user reference as userId, and the subject is a person rather than a business. A flow that runs in a browser (a hosted link or the web SDK), carries no user reference, or verifies a business starts no watch, and the result's addressIntel.presence says so with arranged: false and an unavailableReason (browser_flow, unknown_client, no_user_reference or business_subject). The browser flow also does not tell the applicant a check is running.
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.
Reporting presence from your app
The SDK sends the first report itself the moment the flow is submitted (when location is allowed), so the watch gets its first day, and its first night when the person submits at night. Everything after that comes from this call. Make it when your app starts and every time it returns to the foreground, for the signed-in user:
// React Native: call this from your root component.
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native';
export function usePresenceReporting(userId: string | null) {
useEffect(() => {
if (!userId) return;
const report = () =>
reportAddressPresence({ apiKey: 'pk_live_…', externalUserId: userId });
report(); // the app has just opened
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') report(); // back in the foreground
});
return () => sub.remove();
}, [userId]);
}// Flutter: mix this into your root widget's State.
class _AppState extends State<App> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_reportPresence(); // the app has just opened
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _reportPresence(); // back in the foreground
}
Future<void> _reportPresence() async {
final userId = currentUserId; // your signed-in user's reference
if (userId == null) return;
await MyazaAddressPresence.report(apiKey: 'pk_live_…', externalUserId: userId);
}
}Use the same value you passed as userId when the flow ran, and your publishable key. It is safe to call on every open for every user: it never throws and never blocks, it returns straight away (without touching location) for a user this phone holds no address for, and a phone only holds one for users who ran a presence flow on it. Several reports on the same day count once. Calling it again yourself when the flow submits is harmless too: the watch is created a few seconds after a submission is accepted, and a report from a pin captured in the last 15 minutes waits for it (up to 90 seconds) rather than arriving first.
Every call returns a reason. Log it: it is the quickest way to see why a watch is not filling.
| React Native | Flutter | What it means | What to do |
|---|---|---|---|
reported | reported | The person was at the address; the day (and night, if it is between 20:00 and 06:00) was counted. | Nothing. |
outside_fence | outsideFence | The person is somewhere else right now. Nothing is sent: being away is never held against anyone. | Nothing. Normal on most opens. |
no_pin | noPin | This phone holds no address for this user. The flow ran on another device or in a browser, ran without this userId, or the stored address expired (45 days after capture, unless the watch is always-on). | Check the flow and this call use the same userId. |
no_watch | noWatch | No check is running for this user: it has finished, or none was started. | Nothing. |
stopped | stopped | Your organisation stopped monitoring this user. The SDK switched background location off and forgot the stored address. | Nothing. |
no_fix | noFix | Location permission was refused, or the phone could not work out where it is. | Explain why you need location, then send the person to settings (openLocationSettings()). |
services_off | servicesOff | The phone's location switch is off. | openLocationSettings('services'). |
network_error | networkError | Myaza could not be reached. | Nothing. The next open tries again. |
If days stay at 0
Check these in order. The first one that applies is usually the cause.
- No
userIdon the flow. No address is stored on the phone and no watch starts. The result'saddressIntel.presence.unavailableReasonreadsno_user_reference. - The flow ran in a browser (a hosted link or the web SDK). No watch starts; the reason reads
browser_flow. - Your app does not declare background location, or on React Native does not call
registerBackgroundPresence()at startup. Only app-open reports can count. - The person refused location, or location is switched off. The reporter answers
no_fixorservices_off. - Flutter SDK 3.5.0 or older. On iPhone and on Android 11 and later, those versions never asked the person to allow location all the time, so background monitoring stayed off unless they chose it in Settings. Update the SDK; see Flutter background monitoring. From 3.6.0 the presence reporter asks people who verified on an older version, the next time your app opens.
- The stay has not been reported yet. With background monitoring on, a stay is sent when the person leaves or at a check-in, the first one 35 minutes after they arrive. See When background evidence is sent. Opening the app sends an app-open report at once.
- React Native:
expo-background-taskwas installed without a rebuild. The periodic check-in needs the package's native code in the app build. SDK 3.4.0 and older closed the app when background monitoring was switched on in that state; later versions skip the periodic check-in instead. Rebuild the app after installing it. - No watch is running for that user. The reporter answers
no_watch, and the status endpoint answersnot_started. That is also the answer for a user reference the server does not know, so check the reference matches the one the flow used.
Reports the server does not accept are dropped without an error, so the status endpoint and the reporter's reason are the two places to look.
The check runs on the phone: only the day and a night flag are sent, never a coordinate. A report made with a faked location is sent flagged, because evidence of spoofing is worth more to the watch than silence. Call clearPresencePin(externalUserId) when the person signs out.
If your app is not opened often
With the reporter alone, a check only fills when the person opens your app at home, and a night only counts between 20:00 and 06:00 on their phone. Background monitoring removes that need, but some people refuse the "always" permission. For them, the way to get the app opened at the right moment is a push notification from you, and Myaza tells you when to send one.
entity.address_presence_reminder is sent each evening, between 20:00 and 22:00 at the person's own local time, for every running check that has no evidence for that night yet. It is sent at most once per check per evening. It is not sent when tonight is already counted, when the check has finished, or when background monitoring is already reporting for that phone. Myaza never contacts the person itself: we hold no push token, and a message from us would arrive from a name they do not know.
How to integrate it:
- Subscribe a webhook endpoint to
entity.address_presence_reminder(Developers, then Webhooks, in the dashboard, or the webhooks API). - When it arrives, find your user by
externalUserIdand send them a push notification straight away. The evening is when a report earns a night, so do not queue it for later. Honour the person's notification settings; if they have turned your notifications off, skip it. For example:- Title: "Confirm your address"
- Body: "Open the app while you're at home tonight to finish checking your address."
- Nothing else is needed on the device. Tapping the notification opens your app, the reporter you call on app open (step 4 above) runs, and if the person is at home that night is counted. You do not need to pass anything from the notification into the SDK. If you deep link somewhere, make sure the reporter still runs on that path.
- Test it in sandbox. Outside production a reminder is sent once, a few minutes after a check starts, whatever the time of day, so you can see it arrive and build against it. On your webhook's page in the dashboard, Send test event delivers a sample.
The payload carries ids, progress and the deadline, never a location:
{
"event": "entity.address_presence_reminder",
"data": {
"watchId": "aw_…",
"entityId": "ent_…",
"externalUserId": "user_42",
"localDay": "2026-10-01",
"reason": "no_evidence_tonight",
"progress": { "nights": 1, "days": 2 },
"policy": { "windowDays": 10, "minNights": 3, "minDays": 5 },
"deadlineAt": "2026-10-08T09:14:02.000Z",
"alwaysOn": false,
"environment": "PRODUCTION"
}
}localDay is the person's own calendar day the reminder is for. progress counts the nights and days already observed, against the policy the check has to meet by deadlineAt. The person's local time comes from their phone at the time of the flow, or from the address when the phone did not say.
Two more levers, both on the Address step's Presence tab: a longer window (up to 30 days) gives more chances to catch the occasional evening, and fewer nights required lowers the bar, at the cost of weaker evidence. For someone who will not share location at all, Proof of Address is the honest route.
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.
Both resolution webhooks say which evidence produced the verdict:
"evidence": {
"tier": "background",
"background": { "days": 4, "nights": 3 },
"foreground": { "days": 1, "nights": 0 },
"flaggedDays": 0,
"backgroundMeetsPolicy": true
}Each day and night is credited to the strongest evidence seen that day, so the two sources never count the same day twice. tier is the strongest evidence that credited any day (background, foreground, or null when nothing qualified). flaggedDays counts days reported with a faked location, which is what a failed verdict rests on. backgroundMeetsPolicy answers the question the counts alone cannot: whether the background evidence on its own met the check's bar. If your risk policy only accepts addresses confirmed with the app closed, require it to be true; if app-open evidence is enough for you, ignore it.
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 stop it (see Stopping monitoring), 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.
When background evidence is sent
The background tier also records a stay while the person is still there, not only when they leave. The first time is 35 minutes after the person arrives, and after that once every three hours (SDK versions before this change waited three hours for the first one too). At those points any confirmed reading inside the circle records the stay so far and starts a new one: an app open with reportAddressPresence() / MyazaAddressPresence.report(), each reading of the Android foreground service, a background job on Android (Flutter, built in: about 40 minutes after an arrival, then every couple of hours; React Native, with the optional expo-background-task: about every 40 minutes), each iOS launch, and iOS background refresh when you opt in (see each SDK's README). The phone's operating system decides when a background job actually runs, so these are "not before" times.
Nothing is sent while a stay is only running. Background evidence reaches the server when the person leaves the circle or at one of those check-ins, never in between. A person who verified ten minutes ago and has not moved shows 0 days, correctly. To tell them so, read the open stay on the phone with presenceStay(externalUserId) (React Native) or presenceStay() (Flutter): it returns when they arrived and the earliest time the next report can go out. Someone who hardly leaves home is therefore still credited day by day, and one exit the phone failed to report can no longer cost more than the hours since the last check-in. None of this needs a permission beyond the background location the tier already holds.
Knowing which tier is running
Stopping monitoring
Your team can stop monitoring a customer at any time; the customer cannot stop it themselves. In the dashboard, open Risk Intelligence → Presence checks, tick the customers (or open one) and choose Stop monitoring. From your backend, call POST /api/identity/entities/:externalUserId/address/revoke with an optional { "reason": "…" }.
A stop does two things: the check that is running ends without a result (its status becomes revoked and it is not billed), and always-on monitoring stops renewing, including when it was waiting between checks. Results already recorded stay as they are, and a year of always-on cover already paid for is not refunded. There is no resume: start a new check from the customer's Address card or with …/address/verify.
You receive entity.address_presence_stopped, and the phone learns the next time your app opens: the status endpoint answers stopped: true, and the presence reporter then switches off background location for that customer, forgets the stored pin and returns the reason stopped instead of reporting. Myaza support can also stop monitoring on your behalf; that appears in your audit log with their reason.
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). stopped is true when your team stopped monitoring and nothing has started since (see Stopping monitoring). 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.