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 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 typed address details — the edit form's whole set (number, street, unit, building name, neighbourhood, city, state, area code): optional (default) or off. Deliberately no required mode: the applicant is the only honest source of a plot number, and someone in an unnumbered compound cannot answer a required field. |
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.
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",
"ipCountry": "match"
},
"geocode": { "city": "Lagos", "state": "Lagos", "country": "NG" },
"derivedAddress": "Adeola Odeku Street, Victoria Island, Lagos",
"directions": "black gate opposite the kiosk",
"entitiesAtSamePin": 0,
"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. Shared households are ordinary, so a small number means little; a large one is a multi-accounting signal worth routing to review. It is a review flag, never an identity link and never a block.- 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.ipCountryMatch | match / no_match / unknown |
address.entitiesAtSamePin | 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) | The built-in picker today; the same framed Google map is planned. |
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, all optional, 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 the web 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), 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 mobile SDKs keep the single-screen step for now.
Street View entrance framing
Where a workflow enables it (Address step, "Street View entrance": Optional), an applicant on a hosted link 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). There is deliberately no Required mode, because Street View coverage is not guaranteed anywhere; where the pin has no coverage the card explains and the photo remains available.
- 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.
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 three ways, weighted strongest first: OS geofence dwell (mobile, org opt-in), foreground one-shot checks (the default tier: your app calls the SDK's presence reporter on app open, no background permission needed), and a server-computed IP proxy (a residential or mobile-carrier IP in the pin's country, the weakest weight — it is what rescues users who decline location permission entirely).
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.
The foreground reporter
Call it from your app on a natural moment (app open works well) for any user with an active watch:
// React Native
import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native';
await reportAddressPresence({ apiKey: 'pk_live_…', externalUserId: 'user_42' });// Flutter
await MyazaAddressPresence.report(apiKey: 'pk_live_…', externalUserId: 'user_42');It never throws and never blocks: a missing pin, a denied permission or a network fault come back as a reason. A fix outside the fence sends nothing (the server scores presence, never absence); a mock-location fix is reported flagged, because evidence of fraud is worth more to the watch than silence.
Holding the decision on it
Add the Presence wait step in the decision graph (or start from the presence-verified template): the run parks until the watch resolves, then address.presenceStatus, address.presenceNights and address.presenceDays route the outcome. The wait skips cleanly when no watch was minted, so it is safe on mixed traffic. You can also drive it server-to-server: POST /api/identity/entities/:externalUserId/address/verify mints a watch for an existing entity, …/address/revoke withdraws it, and the entity.address_verified / entity.address_presence_failed webhooks carry the resolution.
Sandbox fast-forwards the whole lifecycle: outside production the window runs in minutes, and the persona tokens presence_verified, presence_fail and presence_inconclusive on externalUserId produce canned streams through the real scorer — a plain user gets no canned stream, so you can drive your own reporter calls end to end, free.
Book triage
Before anything is deployed in your app, you can grade your existing book: upload a CSV (an address column, plus optional customer_id, name, country) at Compliance → Address triage in the dashboard, and each row comes back as corroborated (the evidence already held agrees on a locality — nothing to do), confirmable_in_app (a customer relationship exists, so a short re-capture flow confirms it: send a step-up link, or let the address step run on their next verification; there is no capture to reuse, so it is a short flow rather than one tap), or needs_presence (presence verification is the route to evidence). The distribution chart is the remediation plan; the CSV export carries every row. Metered per row via the address_triage component; runs of up to 5,000 rows.
Stability, re-attestation and the certificate
Once presence data flows, three more surfaces build on it:
- Stability attestation —
GET /api/identity/entities/:externalUserId/addressanswers the tier, months of tenure at the current address, astrong/moderate/weakstability grade, and the move count over 24 months. An origination-time pricing input. - Re-attestation —
POST …/address/reattestruns a short-window watch that answers "still at this address: yes / no / unknown". Address-status answers only, under the consent taken at onboarding; never live whereabouts. Billed on resolution like any watch. - Navigation packet —
GET …/address/packetreturns the pin, the entrance-photo URL and the directions your flow collected: your own data, packaged for a delivery or recovery team. - Verifiable certificate — the dashboard's entity Address card issues a PDF certificate whose QR resolves to a public, tokenised status page. The page answers the tier and confirmation dates only — never the address, the name, or a coordinate — and the underlying events sit on a tamper-evident, hash-chained audit log.
One privacy rule worth knowing: the pin is never used to link or merge identities, and same-pin density only ever flags for review. Coordinates stay your customer's collected data; they ride your webhook and your dashboard, and nothing else.
Address-only workflows (standalone, no identity step)
Address Intelligence also runs without any identity verification, for books
whose KYC already happened elsewhere (imports, a previous partner). Set
scope: "address" on a workflow (or start from the Address-only
verification template) and the flow becomes: consent, optional contact
checks, the full address capture, done. No ID step, no documents, no
biometrics; publish rejects every identity-evidence field on such a workflow,
and the consent screen stops claiming captures that will never happen.
Submissions carry idType: "address" (the workflow is required, exactly as
KYB flows require theirs) and bill only the address-side components:
address_collection, plus proof of address, contact OTPs, the questionnaire
and device intelligence where enabled. The row completes with the
corroboration verdicts and tier on addressIntel (also returned by
GET /api/kyc/verifications/:id), and never fails on a corroboration gap; the
decision layer is where you branch, including holding on the presence watch.
The linking contract is the important part for an existing book: mint a
session per customer (POST /api/kyc/sessions with their externalUserId,
or share the hosted link) and the capture lands on the entity you already
imported, writing the pin and nothing else. An EXTERNAL_VERIFIED
provenance, the identity link and the profile your partner's KYC established
all survive untouched; a customer with no existing record gets a plain
UNVERIFIED entity, which is exactly what it is. Presence verification,
always-on monitoring, certificates and triage then work exactly as below.
When capture happens on a hosted web link but presence should run in your own
app, hand the SDK the pin yourself: savePresencePin(externalUserId, { lat, lng }) (React Native) / savePresencePin(externalUserId, lat, lng) (Flutter)
is the org-side handoff that arms the foreground and background tiers.
Always-on monitoring
Set presence.alwaysOn: true on the workflow's address step and presence verification stops being a one-off: each resolved check automatically starts the next cycle, so the address keeps being confirmed month after month. Coverage is billed per entity per year, priced by cadence: the first presence check bills on its own resolution as usual, and every renewal cycle after it is covered by the year charge. presence.cadenceDays picks from a closed menu — monthly (30), every two months (60), quarterly (90, the default), every six months (180) or yearly (365) — each with its own rate-card row (address_monitoring_30 through address_monitoring_365), so a monthly book pays more per year than a yearly one. There is deliberately no back-to-back mode. In year one a monitored entity costs the capture plus the first check plus the year of coverage (quarterly example: ₦48 + ₦240 + ₦800, about ₦1,088 all-in); every year after is the coverage fee alone, and only the FIRST cycle ever bills the per-check price, so presence and monitoring never charge twice for one piece of work. Per-day observation rows are retained for 365 days after a watch resolves and then pruned; the watch's counters, verdict and score remain the durable record. Results arrive through the same entity.address_verified and entity.address_presence_failed webhooks, so "this person is no longer staying at the address" arrives as an ordinary resolution with verdict: "inconclusive". A short balance pauses the chain at its next renewal and it resumes automatically after a top-up. Monitoring stops when you revoke the watch, when a check fails on tampered evidence, or when the entity leaves your book. On mobile, pair it with the background tier and the OS confirms residency with no further user action. React Native: install expo-task-manager, set the config plugin's location: "always", call registerBackgroundPresence() at your app root and enableBackgroundPresence() after capture. Flutter: declare the background-location entries in your own manifest and Info.plist, then call MyazaBackgroundPresence.enable(); the plugin's native side survives app death, and on Android a reboot too. Declaring background location changes your app's store review posture, which is why it is opt-in at every layer on both platforms; the Background Location Declarations page carries the ready-to-paste Play Console and App Review texts.
Knowing which tier is running
A person can revoke the location permission in Settings and nothing tells your app. Both SDKs expose presenceStatus(externalUserId), which answers with the tier that is ACTUALLY running (background, foreground or none), the stored-pin state, both permission states and whether the geofence is armed; openLocationSettings() is the recovery path, since neither OS allows re-prompting in-app after a denial. 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 network signals alone is visible at a glance.