
# Address Intelligence

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](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) 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](https://trust.myaza.co/documentation/decisioning/markdown), 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:

```json
{
  "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):

```json
{
  "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"
  }
}
```

- `status` and `tier` are add-only vocabularies; Phase 2's presence verification will extend both.
- `plusCode` is the pin's 10-digit [Plus Code](https://plus.codes), the open, offline-computable address handle for places without street addresses. Derived from the pin, never an input, and typeable into any maps app.
- `derivedAddress` is 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.
- `property` and `claimed` are what the applicant typed in the edit-details form: `property` carries the building name, number and unit; `claimed` carries 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. `composedAddress` merges 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` / `distanceMeters` report the attest fix against the pin (`null` when no fix was taken).
- Each corroboration source answers `match`, `no_match` or `unknown` (nothing to compare against). On a KYB flow `govRecord` is the **premises verdict**: whether the pin's locality agrees with the registry's registered address.
- `entitiesAtSamePin` counts **other** subjects of yours whose pin lands on the same map cell, and `entitiesAtSamePin30d` the 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. `corroborationSources` says where each verdict's evidence came from (`submission` or `held`), `heldMatch` answers whether the pin agrees with anything already held (null when nothing is held), and `knownAddresses` counts 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: false` in the plugin options opts out).
- **Flutter**: add the platform permission strings to your host app; see the [Flutter SDK](https://trust.myaza.co/documentation/sdk-flutter/markdown) setup. iOS crashes on the permission request if `NSLocationWhenInUseUsageDescription` is 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](https://trust.myaza.co/documentation/sandbox-testing/markdown) 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 live `presence` block with `status`, `nights` and `days`.
- `GET /api/kyc/address/presence/:externalUserId` (publishable key, your app) returns the same state plus a 0..1 `progress.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`](https://trust.myaza.co/documentation/api-verification-status/markdown) 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:

```ts
// React Native
import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native';
await reportAddressPresence({ apiKey: 'pk_live_…', externalUserId: 'user_42' });
```

```dart
// 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/address` answers the tier, months of tenure at the current address, a `strong`/`moderate`/`weak` stability grade, and the move count over 24 months. An origination-time pricing input.
- **Re-attestation** — `POST …/address/reattest` runs 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/packet` returns 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](https://trust.myaza.co/documentation/background-location-declarations/markdown) 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:

```json
{
  "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](https://trust.myaza.co/documentation/background-location-declarations/markdown) 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.
