
# 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** |

> **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

1. **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.
2. **Pass your user reference as `userId`** when you mount the flow. The SDK stores the pinned address on the phone under that value, and your app reports with the same value as `externalUserId`. Without one, no watch starts.
3. **Add the background location setup to your app** ([React Native](https://trust.myaza.co/documentation/sdk-react-native/markdown#background-monitoring-os-geofencing), [Flutter](https://trust.myaza.co/documentation/sdk-flutter/markdown#background-monitoring-native-geofencing)). 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.
4. **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](#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: false` to ask at a moment you choose.
5. **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](#if-your-app-is-not-opened-often).
6. **Check it is working.** After a day or two, [`GET /api/kyc/address/presence/:externalUserId`](#knowing-a-check-is-running) or the Presence monitoring page in the dashboard should show days going up. Days stuck at 0 mean no report is arriving, and the reporter's `reason` tells you why. See [If days stay at 0](#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 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.

### 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:

```tsx
// 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]);
}
```

```dart
// 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.

1. **No `userId` on the flow.** No address is stored on the phone and no watch starts. The result's `addressIntel.presence.unavailableReason` reads `no_user_reference`.
2. **The flow ran in a browser** (a hosted link or the web SDK). No watch starts; the reason reads `browser_flow`.
3. **Your app does not declare background location**, or on React Native does not call `registerBackgroundPresence()` at startup. Only app-open reports can count.
4. **The person refused location, or location is switched off.** The reporter answers `no_fix` or `services_off`.
5. **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](https://trust.myaza.co/documentation/sdk-flutter/markdown#background-monitoring-native-geofencing). From 3.6.0 the presence reporter asks people who verified on an older version, the next time your app opens.
6. **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](#when-background-evidence-is-sent). Opening the app sends an app-open report at once.
7. **React Native: `expo-background-task` was 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.
8. **No watch is running for that user.** The reporter answers `no_watch`, and the status endpoint answers `not_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:

1. **Subscribe a webhook endpoint** to `entity.address_presence_reminder` (Developers, then Webhooks, in the dashboard, or the webhooks API).
2. **When it arrives, find your user by `externalUserId` and 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."
3. **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.
4. **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:

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

```json
"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/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 stop it (see [Stopping monitoring](#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](https://trust.myaza.co/documentation/background-location-declarations/markdown) 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:

```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). `stopped` is `true` when your team stopped monitoring and nothing has started since (see [Stopping monitoring](#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](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.
