# Myaza Trust
> Integration guides for identity verification, workflows, Risk Intelligence and Travel Rule. Read product decisions and failure handling as well as endpoint schemas.
## Discovery
- [Machine index](https://trust.myaza.co/documentation/index.json): all guides, integration skills and content digests.
- [Public v1 OpenAPI](https://trust.myaza.app/api/openapi.json): /api/v1 operations only; KYC and Identity APIs use separate guide contracts.
- [Agent guide](https://trust.myaza.co/documentation/ai-integration/markdown): API boundaries, security and acceptance.
## Integration skills
- [Identity verification](https://trust.myaza.co/documentation/skills/verification/SKILL.md): Capture identity evidence and return an actionable verification outcome.
- [Workflows and decisioning](https://trust.myaza.co/documentation/skills/workflows/SKILL.md): Design a customer journey and apply the organisation's published decisions.
- [Business verification](https://trust.myaza.co/documentation/skills/business-verification/SKILL.md): Verify businesses and the people associated with them.
- [Risk Intelligence](https://trust.myaza.co/documentation/skills/risk-intelligence/SKILL.md): Connect customer evidence to reviewable, measurable risk decisions.
- [Screening](https://trust.myaza.co/documentation/skills/screening/SKILL.md): Screen people, businesses and wallets and respond to potential matches.
- [Events and continuous monitoring](https://trust.myaza.co/documentation/skills/monitoring/SKILL.md): Observe changing customer risk and assess account activity or custom events.
- [Transactions and fraud decisions](https://trust.myaza.co/documentation/skills/transactions/SKILL.md): Assess fiat and crypto transactions before the customer's system acts.
- [Travel Rule](https://trust.myaza.co/documentation/skills/travel-rule/SKILL.md): Exchange transfer information securely and obtain an enforceable current decision.
- [Crypto operations](https://trust.myaza.co/documentation/skills/crypto-operations/SKILL.md): Manage crypto institutions, saved destinations and deposit observations.
- [Address Intelligence](https://trust.myaza.co/documentation/skills/address-intelligence/SKILL.md): Assess address evidence and maintain address confidence over time.
- [Investigations and reporting](https://trust.myaza.co/documentation/skills/investigations/SKILL.md): Triage findings, document decisions and measure policy quality.
- [Webhooks and operations](https://trust.myaza.co/documentation/skills/webhooks/SKILL.md): Deliver results reliably to the customer's system.
## Guides
- [Introduction](https://trust.myaza.co/documentation/introduction/markdown): Choose the Myaza Trust identity-verification or Risk Intelligence integration journey.
- [Quickstart](https://trust.myaza.co/documentation/quickstart/markdown): Run a complete identity verification from your terminal in a few minutes.
- [Authentication](https://trust.myaza.co/documentation/authentication/markdown): Create API keys and authenticate every request with a Bearer token.
- [Environments](https://trust.myaza.co/documentation/environments/markdown): Sandbox vs production, test vs live keys, and going live.
- [Integrate with AI](https://trust.myaza.co/documentation/ai-integration/markdown): Give your coding agent the contracts, product context and tests needed to build a reliable integration.
- [Get configuration](https://trust.myaza.co/documentation/api-config/markdown): GET /config: discover the ID types and features enabled for your organisation.
- [Upload media](https://trust.myaza.co/documentation/api-upload/markdown): POST /upload: store a document, selfie, or liveness file and get a mediaId.
- [Create a verification](https://trust.myaza.co/documentation/api-create-verification/markdown): POST /verify, submit an identity for verification. The core endpoint.
- [Get verification status](https://trust.myaza.co/documentation/api-verification-status/markdown): GET /status/:verificationId, minimal, publishable-safe submission status (no PII).
- [Get verification result](https://trust.myaza.co/documentation/api-verification-result/markdown): GET /verifications/:id, full verification result with PII. Secret key only.
- [Face match](https://trust.myaza.co/documentation/api-face-match/markdown): POST /face/compare: compare two photos 1:1 and get a verdict, synchronously.
- [Create a session](https://trust.myaza.co/documentation/api-create-session/markdown): POST /sessions, mint a hosted verification link for one applicant, server to server.
- [Errors](https://trust.myaza.co/documentation/errors/markdown): HTTP status codes, error response shapes, and the health check.
- [Rate limits](https://trust.myaza.co/documentation/rate-limits/markdown): Request limits and how to handle 429 responses.
- [Verify again](https://trust.myaza.co/documentation/api-verify-again/markdown): POST /verifications/:id/rerun, ask the applicant to redo a finished verification, whole or in part, server to server.
- [Decide a verification](https://trust.myaza.co/documentation/api-review-verification/markdown): POST /verifications/:id/review, approve, decline or send back a finished verification from your backend, or change an earlier decision.
- [List verifications](https://trust.myaza.co/documentation/api-list-verifications/markdown): GET /verifications: page through your verification history server to server.
- [Spot checks](https://trust.myaza.co/documentation/api-spot-checks/markdown): POST /checks: run one-off individual and business checks server to server, and read their history.
- [Delete records & expire sessions](https://trust.myaza.co/documentation/api-manage-records/markdown): DELETE /verifications/:id, DELETE /checks/*/:id, DELETE /face/checks/:id and POST /sessions/:id/expire: remove a record from your organisation, or end a live session early.
- [Email & SMS verification](https://trust.myaza.co/documentation/api-contact-verification/markdown): Send a one-time code to an email address or phone number, then check it. Synchronous, no SDK.
- [Verification lifecycle](https://trust.myaza.co/documentation/verifications/markdown): How a verification moves from pending to a final status, and how to handle each.
- [Countries & ID types](https://trust.myaza.co/documentation/id-types/markdown): Government-database markets, global document coverage, the ID types in each, and their capture requirements.
- [NFC chip verification](https://trust.myaza.co/documentation/nfc-chip/markdown): Reading an eMRTD chip (e-passports and chip eID cards), what data is extracted, and how it is authenticated server-side.
- [Supported countries](https://trust.myaza.co/documentation/countries/markdown): Every country Myaza Trust covers, the government-database KYC markets, global document verification, and the business (KYB) registry list.
- [Business verification (KYB)](https://trust.myaza.co/documentation/business-verification/markdown): Verify a company against the official registry, plus documents, declarations, and due diligence on its owners.
- [Key people & ownership](https://trust.myaza.co/documentation/key-people/markdown): Due diligence on the people who own or control a business, including corporate shareholders and ownership chains.
- [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown): Smart-address capture (map pin, entrance photo, directions) and how the platform corroborates it against the evidence it already holds.
- [Background Location Declarations](https://trust.myaza.co/documentation/background-location-declarations/markdown): Ready-to-paste store declarations, permission strings and review notes for apps that enable the background presence tier.
- [Hosted link events](https://trust.myaza.co/documentation/hosted-events/markdown): Listen to a hosted verification's lifecycle from a React Native or Flutter WebView, or from an iframe.
- [Webhooks](https://trust.myaza.co/documentation/webhooks/markdown): Receive signed event notifications and verify their authenticity.
- [Sandbox & test IDs](https://trust.myaza.co/documentation/sandbox-testing/markdown): Test your full integration with canned test IDs, every outcome and no external check cost.
- [Workflows](https://trust.myaza.co/documentation/workflows/markdown): Build a verification flow visually once, then embed it by id or share it as a hosted link, with no redeploys.
- [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown): Turn verification data into an approve / decline / review decision with a visual rules graph.
- [SDKs](https://trust.myaza.co/documentation/sdks/markdown): The server-side Risk Intelligence SDK and client verification SDKs.
- [Web SDK (React)](https://trust.myaza.co/documentation/sdk-react/markdown): Drop-in button and modal verification flow for React (≥ 18).
- [React Native SDK](https://trust.myaza.co/documentation/sdk-react-native/markdown): On-device native liveness for React Native / Expo, with the same API as the web SDK.
- [Flutter SDK](https://trust.myaza.co/documentation/sdk-flutter/markdown): MyazaKYC.show() modal verification flow for Flutter / Dart with on-device liveness.
- [What Risk Intelligence is](https://trust.myaza.co/documentation/risk-intelligence/markdown): Real-time decisions, ongoing customer monitoring, Travel Rule controls and investigations.
- [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown): Get a sandbox decision into your backend and dashboard in about ten minutes.
- [Read customer risk](https://trust.myaza.co/documentation/customer-risk-reads/markdown): Read one customer assessment or filter a customer book with consistent risk, monitoring and totals.
- [Risk overview](https://trust.myaza.co/documentation/compliance/markdown): Monitor entities, screen against watchlists, and investigate suspicious activity (FRAML).
- [Link customer identities](https://trust.myaza.co/documentation/identity-hub/markdown): Sync your users as monitored entities and read their risk state, server to server.
- [Screen for risk](https://trust.myaza.co/documentation/screening/markdown): Sanctions, PEP, and adverse-media watchlist screening for your entities.
- [Crypto operations](https://trust.myaza.co/documentation/crypto-operations/markdown): Review crypto transfers, match deposit observations and maintain your address book.
- [Monitor for changes](https://trust.myaza.co/documentation/monitoring-events/markdown): Send complete transaction relationships and customer activity for real-time screening and risk decisions.
- [Automate decisions](https://trust.myaza.co/documentation/monitoring-rules/markdown): Configure how events are scored, thresholds, money limits, built-in rules, and custom rules.
- [Keep customers under continuous review](https://trust.myaza.co/documentation/monitoring-policies/markdown): Configure policy defaults, then manage each customer's monitoring state from their Entity.
- [Fraud rules](https://trust.myaza.co/documentation/transaction-rules/markdown): Decide when supported activities should be allowed, reviewed or blocked.
- [Investigations and reporting](https://trust.myaza.co/documentation/investigations/markdown): Triage alerts, investigate evidence, and file suspicious-activity reports.
- [Measure decision effectiveness](https://trust.myaza.co/documentation/decision-effectiveness/markdown): Connect downstream outcomes to Myaza decisions, measure fraud capture, detect drift, and review controlled tuning recommendations.
- [Test in simulator](https://trust.myaza.co/documentation/platform-simulator/markdown): Run deterministic Myaza Trust risk stories in the sandbox.
- [Travel Rule overview](https://trust.myaza.co/documentation/travel-rule/markdown): Exchange required transfer information and act on the result.
- [Set up Travel Rule](https://trust.myaza.co/documentation/travel-rule-setup/markdown): Your company, institutions and application.
- [Transfer requirements](https://trust.myaza.co/documentation/travel-rule-policies/markdown): Choose rules, information packages and publication.
- [Create a transfer](https://trust.myaza.co/documentation/travel-rule-transactions/markdown): Send the information you already hold in one request.
- [Supply missing information](https://trust.myaza.co/documentation/travel-rule-information/markdown): Recover incomplete transfers without creating duplicates.
- [Exchange and authorise](https://trust.myaza.co/documentation/travel-rule-execution/markdown): Secure messaging and the customer wallet's execution gate.
- [Receive a transfer](https://trust.myaza.co/documentation/travel-rule-incoming/markdown): Review incoming information and admit a verified deposit.
- [Confirm an outgoing transfer](https://trust.myaza.co/documentation/travel-rule-confirmation/markdown): Report wallet confirmation and track message delivery.
- [Receive Travel Rule updates](https://trust.myaza.co/documentation/travel-rule-webhooks/markdown): Events, current status and safe notification handling.
- [Self-hosted wallets](https://trust.myaza.co/documentation/travel-rule-self-hosted/markdown): Signed control proof and separate address review.
- [Test and troubleshoot](https://trust.myaza.co/documentation/travel-rule-testing/markdown): Exercise information gaps, review and stopped transfers.
- [Authentication](https://trust.myaza.co/documentation/webhook-authentication/markdown): Verify V2 signatures, reject replays and rotate endpoint secrets safely.
- [Verification](https://trust.myaza.co/documentation/webhook-verification/markdown): Handle individual verification lifecycle events and their complete public result contract.
- [Business verification](https://trust.myaza.co/documentation/webhook-business-verification/markdown): Integrate entity, identity, key-person and workflow lifecycle events.
- [Risk Intelligence](https://trust.myaza.co/documentation/webhook-risk-intelligence/markdown): Consume risk assessments, immutable signals, level changes and decisions.
- [Activity decisions](https://trust.myaza.co/documentation/webhook-event-monitoring/markdown): Receive decisions for logins, account changes and other customer activity.
- [Transaction decisions](https://trust.myaza.co/documentation/webhook-transaction-monitoring/markdown): Receive the outcome and next action for an assessed transaction.
- [Screening](https://trust.myaza.co/documentation/webhook-screening/markdown): Handle sanctions, PEP, adverse-media and wallet screening outcomes.
- [Continuous Monitoring](https://trust.myaza.co/documentation/webhook-continuous-monitoring/markdown): Receive monitoring state changes, failures and material customer-risk deltas.
- [Alerts & Investigations](https://trust.myaza.co/documentation/webhook-alerts-cases/markdown): Integrate alert and investigation lifecycle events without exposing analyst evidence.
- [Wallet Intelligence](https://trust.myaza.co/documentation/webhook-wallet-intelligence/markdown): Integrate wallet screening and wallet-risk evidence using the canonical screening and risk events.
- [Billing](https://trust.myaza.co/documentation/webhook-billing/markdown): Track credit balance changes and API-key lifecycle events.
- [Webhook Testing](https://trust.myaza.co/documentation/webhook-testing/markdown): Test signed deliveries, retries, aliases and idempotency before going live.
---
# Introduction
Myaza Trust combines identity verification with Risk Intelligence. Use identity
verification to establish who a person or business is. Use Risk Intelligence to
screen customers, decide on transactions and account activity, keep customers
under continuous review, apply Travel Rule controls and manage investigations.
Choose the shortest path for the job:
- [Identity verification quickstart](https://trust.myaza.co/documentation/quickstart/markdown) for an SDK or hosted KYC flow.
- [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown) for a server-side
transaction decision using your own customer reference.
## How the integration works
Identity verification is **asynchronous**. You submit a verification request and
receive a verification ID immediately; the result arrives later through a
[webhook](https://trust.myaza.co/documentation/webhooks/markdown) or status read.
You can drive a verification two ways: pass the configuration in code (country, ID types, steps), or build the flow once in the dashboard as a [workflow](https://trust.myaza.co/documentation/workflows/markdown) and reference it by id, adding extra checks and an approve / decline / review [decision](https://trust.myaza.co/documentation/decisioning/markdown) without changing your code. Every flow runs in one of our [SDKs](https://trust.myaza.co/documentation/sdks/markdown) or as a shareable hosted link.
Risk activity and transaction decisions are **synchronous**. The response tells
your backend whether to allow, review or block the action. Webhooks are optional
notifications for those decisions and are the normal delivery path for ongoing
monitoring changes.
## Base URL
| Product | Sandbox | Production |
|---|---|---|
| Identity verification | `https://trust.myaza.app/api/kyc` | `https://trust.myaza.app/api/kyc` |
| Risk Intelligence | `https://sandbox.trust.myaza.app/api/v1` | `https://trust.myaza.app/api/v1` |
The official SDK chooses the correct host. API keys, webhook endpoints and
resources stay isolated by environment. See [Environments](https://trust.myaza.co/documentation/environments/markdown).
## Conventions
- All request and response bodies are JSON, except media upload which is `multipart/form-data`.
- Timestamps are ISO 8601 strings in UTC (e.g. `2026-04-27T12:00:00.000Z`).
- IDs are opaque strings; do not parse them.
- Statuses in responses are lower-case (`pending`, `verified`, `failed`, `not_found`, `error`).
## Next steps
- [Identity verification quickstart](https://trust.myaza.co/documentation/quickstart/markdown): a full verification end to end.
- [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown): a first transaction decision.
- [Authentication](https://trust.myaza.co/documentation/authentication/markdown): create and use API keys.
- [Create a verification](https://trust.myaza.co/documentation/api-create-verification/markdown): the core endpoint in detail.
- [Workflows](https://trust.myaza.co/documentation/workflows/markdown): configure a flow visually and reuse it by id or hosted link.
---
# Quickstart
> Looking for transaction decisions, customer screening, continuous monitoring
> or Travel Rule? Use the [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown).
This guide takes you through a full verification using `curl`. By the end you will have created an API key, discovered your enabled ID types, uploaded a selfie, submitted a verification, and read the result.
> **Building an app or website flow?** You usually don't need the raw API. Build a [workflow](https://trust.myaza.co/documentation/workflows/markdown) in the dashboard and mount it with an [SDK](https://trust.myaza.co/documentation/sdks/markdown) (`workflowId="wf_…"`), or share its hosted link with zero integration. This page walks the API underneath, for server-side integrations and for understanding what the SDKs do.
We use the **sandbox** environment (`pk_test_…` keys against `https://trust.myaza.app/api/kyc`) so you can test freely without affecting production. Sandbox and production share the same base URL, and your key prefix decides which environment you hit.
## 1. Create a test API key
In the dashboard, go to **Settings → Organization → Developers → API Keys**, switch the environment selector to **Sandbox**, and create a key. Copy the full key (`pk_test_…`); it is shown only once.
Store it in your shell:
```bash
export MYAZA_KEY="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export MYAZA_BASE="https://trust.myaza.app/api/kyc"
```
## 2. Check your configuration
`GET /config` returns the ID types enabled for your organisation and which features each supports.
```bash
curl "$MYAZA_BASE/config" \
-H "Authorization: Bearer $MYAZA_KEY"
```
```json
{
"environment": "SANDBOX",
"idTypes": [
{
"country": "NG",
"idType": "bvn",
"features": { "documentVerification": false, "livenessCheck": true, "govDbCheck": true }
}
]
}
```
## 3. Upload media (optional)
Number-only IDs (such as BVN) don't need media. For document or selfie/liveness flows, upload each file and keep the returned `mediaId`.
```bash
curl "$MYAZA_BASE/upload" \
-H "Authorization: Bearer $MYAZA_KEY" \
-F "type=selfie" \
-F "file=@./selfie.jpg"
```
```json
{ "mediaId": "media_01j9..." }
```
## 4. Create the verification
Submit the ID details. The `metadata.requestId` is **your** idempotency key: reuse it to safely retry without creating duplicates.
```bash
curl "$MYAZA_BASE/verify" \
-H "Authorization: Bearer $MYAZA_KEY" \
-H "Content-Type: application/json" \
-d '{
"country": "NG",
"idType": "bvn",
"idNumber": "12345678901",
"metadata": { "requestId": "order_1001", "userId": "user_42" }
}'
```
The API responds immediately with `202 Accepted`:
```json
{ "verificationId": "ver_01j9...", "status": "processing" }
```
## 5. Get the result
Verification runs asynchronously. The publishable key polls **minimal status** (no PII):
```bash
curl "$MYAZA_BASE/status/ver_01j9..." \
-H "Authorization: Bearer $MYAZA_KEY"
```
```json
{
"verificationId": "ver_01j9...",
"status": "approved",
"checkStatus": "verified",
"createdAt": "2026-04-27T12:00:00.000Z",
"completedAt": "2026-04-27T12:00:05.000Z"
}
```
To read the **full result** (biodata, ID number, facial match), call the
[result endpoint](https://trust.myaza.co/documentation/api-verification-result/markdown) from your backend with a **secret
(`sk_`) key**, never from client code:
```bash
curl "$MYAZA_BASE/verifications/ver_01j9..." \
-H "Authorization: Bearer $MYAZA_SECRET_KEY"
```
```json
{
"verificationId": "ver_01j9...",
"status": "approved",
"checkStatus": "verified",
"country": "NG",
"idType": "bvn",
"result": {
"firstName": "JOHN",
"lastName": "DOE",
"dateOfBirth": "1990-01-01",
"idNumberMasked": "1234•••901",
"facialMatch": null
},
"completedAt": "2026-04-27T12:00:05.000Z"
}
```
Better still, receive the result on your backend via a [webhook](https://trust.myaza.co/documentation/webhooks/markdown).
## Next steps
- Set up [webhooks](https://trust.myaza.co/documentation/webhooks/markdown) instead of polling.
- Review the full [verification lifecycle](https://trust.myaza.co/documentation/verifications/markdown) and all status values.
- Go live by reading about [environments](https://trust.myaza.co/documentation/environments/markdown) and production access.
---
# Authentication
Every request to the Myaza verification and Risk Intelligence APIs (except the public [health check](https://trust.myaza.co/documentation/errors/markdown#health-check)) must be authenticated with an API key.
## Two key types: publishable and secret
Keys come in two **scopes**. The scope decides
which endpoints a key may call:
| Type | Prefix | Where it lives | Can call |
|---|---|---|---|
| **Publishable** | `pk_…` | Client-side, safe to ship in the SDK / your frontend | Start a verification (`/verify`), uploads (`/upload`), SDK config (`/config`), and **minimal** submission status (`/status`: state + reason, **no PII**) |
| **Secret** | `sk_…` | **Backend only**, never in client code | Full verification results and every Risk Intelligence endpoint under `/api/v1` |
Each scope also carries the environment dimension in its prefix:
| Prefix | Type | Environment |
|---|---|---|
| `pk_test_` | Publishable | Sandbox |
| `pk_live_` | Publishable | Production |
| `sk_test_` | Secret | Sandbox |
| `sk_live_` | Secret | Production |
A key looks like `pk_test_aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY`: the prefix plus 32 random alphanumeric characters.
> **Never ship a secret (`sk_`) key in your SDK, mobile app, or any client code.**
> The publishable key in your frontend can only *start* verifications and poll
> minimal status; it can never read identity data. Fetch results from your
> backend with a secret key. A publishable key hitting a secret-only endpoint
> gets `403 secret_key_required`.
## Authenticating a request
Pass the full key as a Bearer token in the `Authorization` header:
```bash
curl "https://trust.myaza.app/api/kyc/config" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
The key identifies your organisation and its environment. There is no separate organisation ID to send.
Risk Intelligence uses `https://sandbox.trust.myaza.app/api/v1` with `sk_test_`
and `https://trust.myaza.app/api/v1` with `sk_live_`. The official Node.js SDK
selects the host from its `environment` option.
## Creating and managing keys
API keys are managed in the dashboard under **Developers → API Keys**.
- Pick the **key type** (Publishable or Secret) when creating a key. The **environment selector** in the dashboard header decides whether it's a `_test_` (sandbox) or `_live_` (production) key.
- **Secret keys are shown only once**, at creation. Store the value immediately in a secrets manager; it cannot be retrieved again, only the prefix is displayed afterward.
- **Publishable keys can be viewed anytime** in the dashboard: they're public by design (they ship in your frontend), so there's no secret to protect.
- Creating or revoking a key requires the `api_keys:create` permission. Members without it see keys read-only.
- **Revoke** a key the moment it may be compromised. Revocation is immediate: the next request with that key returns `401`.
> Creating and revoking keys emit the `api_key.created` and `api_key.revoked` [webhook events](https://trust.myaza.co/documentation/webhooks/markdown) so you can wire them into your own security auditing.
## Errors
| Status | Body | Cause |
|---|---|---|
| `401` | `{ "error": "Missing or invalid Authorization header" }` | No `Authorization: Bearer …` header. |
| `401` | `{ "error": "Invalid API key" }` | Unknown or revoked key. |
| `403` | `{ "error": "secret_key_required" }` | A publishable (`pk_`) key was used on a secret-only endpoint (full result or media). Use a `sk_` key from your backend. |
| `403` | `{ "error": "environment_mismatch" }` | The key's environment doesn't match the resource or host (for example, a test key used for a production resource). Keep one environment throughout a flow and use the documented host for that product. |
See [Errors](https://trust.myaza.co/documentation/errors/markdown) for the full list.
## Where each key is used
- **Publishable (`pk_…`)**: the [client SDKs](https://trust.myaza.co/documentation/sdks/markdown), which take an `apiKey` and call
the API from the user's device, plus any frontend code that starts verifications.
Match the environment: `pk_test_` with the SDK's `sandbox` environment, `pk_live_`
with `production`.
- **Secret (`sk_…`)**: your **backend** reading results: `GET /verifications/:id`
for the full result + PII and `GET /verifications/:id/media/:kind` for captured
media, plus every Risk Intelligence request under `/api/v1`. Keep it
server-side only. See the [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown).
## Best practices
- **Never put a secret key in client code.** If a publishable key leaks it can only start verifications; a leaked secret key exposes identity data, so treat it like a password.
- **Never commit a key to a public repository** or paste it where it could be scraped. Revoke immediately if a key leaks.
- Use `_test_` keys while integrating and in CI; reserve `_live_` keys for production traffic.
- Rotate keys periodically.
- Scope keys per service or environment so you can revoke one without disrupting everything.
---
# Environments
Myaza Trust has isolated Sandbox and Production environments. The key prefix
always identifies the environment. Risk Intelligence also uses a separate
Sandbox host, while identity verification keeps one host and selects the
environment from the key.
| Product | Sandbox | Production | Key type |
|---|---|---|---|
| Identity verification | `https://trust.myaza.app/api/kyc` | `https://trust.myaza.app/api/kyc` | `pk_test_` / `pk_live_` in client SDKs; `sk_test_` / `sk_live_` for backend-only results |
| Risk Intelligence | `https://sandbox.trust.myaza.app/api/v1` | `https://trust.myaza.app/api/v1` | `sk_test_` / `sk_live_` on your backend |
Data does not cross environments: keys, verifications, webhook endpoints, and configuration are all scoped to one environment.
## Sandbox
Use Sandbox to build and test your integration. It exercises the same public API
surface and webhook delivery as Production. Explicit Risk Intelligence
`sandboxScenario` fixtures are deterministic and non-billable. Ordinary Sandbox
requests without a scenario use configured Sandbox pricing and can be billed.
Use the Risk Intelligence SDK with `environment: 'sandbox'`; it selects the
Sandbox host for you. Remove `sandboxScenario` in Production.
## Production
Production runs real verifications against live government databases and **uses your credit balance**.
### Production access requires an approved business
Before your `pk_live_` keys can run verifications, your organisation's business profile (KYB) must be **approved** by Myaza. Until then, production verify and upload requests are rejected:
```json
{
"error": "business_not_approved",
"message": "Your business must be approved by Myaza before using production verification services."
}
```
Complete and submit your business profile under **Onboarding** in the dashboard, then wait for approval. Sandbox is unaffected and remains available throughout.
### Billing
Each completed production verification deducts from your organisation's credit balance at the price configured for that country and ID type. If the account has insufficient credit, the verification is recorded and marked `failed` with an insufficient-credits reason (it still fires a `verification.failed` webhook) rather than being silently dropped. Top up and set low-balance thresholds under **Billing**.
## Switching environments in the dashboard
The dashboard header has an environment switcher (Sandbox / Production). It controls which environment's data you see (including which keys you create) without changing the dashboard URL.
## Checklist for going live
1. Your business profile is **approved** for production.
2. You created the required live keys: publishable for a verification client,
secret for backend results or Risk Intelligence.
3. You registered **production** webhook endpoints and verified signatures.
4. Your credit balance is funded and a low-balance threshold is set.
5. You swapped test keys for live keys and, for Risk Intelligence, selected the
Production environment/host.
---
# Integrate with AI
Give your agent the [documentation index](/llms.txt), then choose the product skill below. Each skill covers the customer journey, decisions, security, recovery and acceptance tests—not just API calls.
## Choose a skill
Save the linked file as `.agents/skills/myaza-/SKILL.md` for Codex, or `.claude/skills/myaza-/SKILL.md` for Claude Code, inside your integration repository. Review it before enabling it. These are portable Markdown instructions; they do not install software, request credentials or run commands.
| Product | Integration skill |
| --- | --- |
| Identity verification | [Download skill](/documentation/skills/verification/SKILL.md) |
| Workflows and decisioning | [Download skill](/documentation/skills/workflows/SKILL.md) |
| Business verification | [Download skill](/documentation/skills/business-verification/SKILL.md) |
| Risk Intelligence | [Download skill](/documentation/skills/risk-intelligence/SKILL.md) |
| Screening | [Download skill](/documentation/skills/screening/SKILL.md) |
| Events and continuous monitoring | [Download skill](/documentation/skills/monitoring/SKILL.md) |
| Transactions and fraud decisions | [Download skill](/documentation/skills/transactions/SKILL.md) |
| Travel Rule | [Download skill](/documentation/skills/travel-rule/SKILL.md) |
| Crypto operations | [Download skill](/documentation/skills/crypto-operations/SKILL.md) |
| Address Intelligence | [Download skill](/documentation/skills/address-intelligence/SKILL.md) |
| Investigations and reporting | [Download skill](/documentation/skills/investigations/SKILL.md) |
| Webhooks and operations | [Download skill](/documentation/skills/webhooks/SKILL.md) |
## Give the agent a useful brief
Describe your customer journey, target markets, review team and what your application should do when a result is approved, held or blocked. Share synthetic examples only.
```text
Integrate Myaza Trust for our customer onboarding and crypto transfers.
Start in Sandbox. Read the Myaza product skills and current documentation.
Map our customer reference to externalUserId.
Propose the decision and review flow before changing our policies.
Keep credentials on our server. Verify and deduplicate signed webhooks.
Add executable tests for retries, missing information and blocked outcomes.
Return the implementation, test evidence and remaining Live prerequisites.
```
## Find the exact contract
| Resource | Purpose |
| --- | --- |
| [llms.txt](/llms.txt) | Concise discovery and product links |
| [llm.txt](/llm.txt) | Alias for clients using the singular name |
| [Machine index](/documentation/index.json) | Every guide, product skill and content digest |
| [Complete Markdown](/llms-full.txt) | All published guides, without page navigation |
| [Public v1 OpenAPI](https://trust.myaza.app/api/openapi.json) | Machine-readable public v1 operations and schemas |
Every guide has a **Markdown** link and an `/markdown` URL. Fetch individual guides first; use the full bundle when your tool cannot follow links. The index's SHA-256 values identify the exact content reviewed.
### API families
Do not replace one API prefix with another. They are separate contracts.
| Family | Read first | Boundary |
| --- | --- | --- |
| `/api/kyc` | [Authentication](https://trust.myaza.co/documentation/authentication/markdown), [verification quickstart](https://trust.myaza.co/documentation/quickstart/markdown) | Capture, verification and SDK operations; some are publishable-key safe, others are server-only |
| `/api/v1` | [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown), OpenAPI | Server-side risk, transactions and crypto operations |
| `/api/identity` | [Events](https://trust.myaza.co/documentation/monitoring-events/markdown) | The documented Identity event contract, not a replacement transaction endpoint |
| Dashboard and platform routes | Dashboard UI | Session/role protected administration; not a substitute for customer secret-key APIs |
The v1 schema does not describe every KYC or Identity operation. Read the relevant guide as well as the schema. Follow each endpoint's authentication contract; never expose a secret key in a browser or mobile app.
## Build the whole customer flow
1. **Identify:** use stable customer and operation references, scoped to your organisation and environment.
2. **Collect:** request only the evidence needed for the selected journey.
3. **Decide:** distinguish capture completion, processing, review, policy outcome and execution authority.
4. **Enforce:** your application applies the current decision. Myaza does not operate your wallet or freeze customer accounts.
5. **Recover:** verify webhooks, deduplicate, reconcile uncertain requests and handle out-of-order events.
6. **Audit:** retain the reason, policy version, decision and your application's action without copying sensitive payloads into logs.
For Travel Rule, complete information, a successful connection test and a low fraud score are not interchangeable with transfer clearance. See [wallet authorisation](https://trust.myaza.co/documentation/travel-rule-execution/markdown).
## Before Live
Test permission denial, tenant/environment isolation, duplicate requests, timeouts, missing evidence, rejected decisions and webhook recovery. Use [Sandbox scenarios](https://trust.myaza.co/documentation/sandbox-testing/markdown) and the product testing guide. Ordinary Sandbox traffic may use configured pricing; do not assume all testing is free.
Keep proposed policy changes separate from publication. Your authorised team approves risk appetite, country obligations and Live activation. Report local tests, Sandbox integration, real partner communication and Live acceptance separately.
## Read-only MCP
The Core repository provides an optional `pnpm mcp:integration` process with documentation, v1 schema and example tools. It does not accept customer keys or execute transactions. This is a local integration aid, not a publicly hosted MCP endpoint.
---
# Get configuration
```
GET /api/kyc/config
```
Returns the ID types enabled for your organisation in the current environment, along with the features available for each. Call this on startup to drive which ID options you present to users and which capture steps you run.
**Authentication:** `Authorization: Bearer pk_…` (required).
## Request
```bash
curl "https://trust.myaza.app/api/kyc/config" \
-H "Authorization: Bearer $MYAZA_KEY"
```
## Response `200 OK`
```json
{
"environment": "PRODUCTION",
"idTypes": [
{
"country": "NG",
"idType": "bvn",
"features": {
"documentVerification": false,
"livenessCheck": true,
"govDbCheck": true
}
},
{
"country": "NG",
"idType": "drivers-license",
"features": {
"documentVerification": true,
"livenessCheck": true,
"govDbCheck": true
}
}
],
"branding": {
"companyName": "Acme Inc.",
"logo": "https://trust.myaza.app/api/kyc/branding/logo/ast_123",
"primaryColor": "#5645F5"
}
}
```
### Fields
| Field | Type | Description |
|---|---|---|
| `environment` | string | `SANDBOX` or `PRODUCTION`, derived from your API key. |
| `idTypes[]` | array | The ID types enabled for your org. Disabled ones are omitted entirely. |
| `idTypes[].country` | string | ISO-3166 alpha-2 country code: a government-database market (`NG`, `GH`, `KE`, `ZA`, `CI`) or any country enabled for [global document verification](https://trust.myaza.co/documentation/id-types/markdown). |
| `idTypes[].idType` | string | The ID type identifier to send to [`POST /verify`](https://trust.myaza.co/documentation/api-create-verification/markdown). |
| `idTypes[].features.documentVerification` | boolean | Whether document image capture is enabled for this ID type. |
| `idTypes[].features.livenessCheck` | boolean | Whether a liveness/selfie step is enabled. |
| `idTypes[].features.govDbCheck` | boolean | Whether the ID is checked against the government database. |
| `branding` | object | Your organisation's branding, set under **Settings → Organization**. May be omitted if nothing is configured. |
| `branding.companyName` | string | Your organisation's display name. |
| `branding.logo` | string | Absolute, public URL of your org logo. The [SDKs](https://trust.myaza.co/documentation/sdks/markdown) render this when `appearance.logo` is set to `'default'`. Omitted if no logo is uploaded. |
| `branding.primaryColor` | string | Your org's brand colour (hex), if set. |
Only enabled ID types are returned. If an ID type you expect is missing, enable it under **Settings → Organization → ID Types** (or ask Myaza to enable it for your account).
See [Countries & ID types](https://trust.myaza.co/documentation/id-types/markdown) for the catalogue of supported values.
---
# Upload media
```
POST /api/kyc/upload
```
Uploads a single capture file (document image, selfie, or video) and returns a `mediaId`. You then reference these IDs in [`POST /verify`](https://trust.myaza.co/documentation/api-create-verification/markdown). Number-only verifications (e.g. BVN) don't require any upload.
**Authentication:** `Authorization: Bearer pk_…` (required).
**Content type:** `multipart/form-data`.
**Production note:** like verify, this endpoint requires an [approved business](https://trust.myaza.co/documentation/environments/markdown#production) for `pk_live_` keys.
## Form fields
| Field | Required | Description |
|---|---|---|
| `file` | yes | The raw file bytes. |
| `type` | yes | What the file is: one of the values below. |
### Allowed `type` values
`document_front`, `document_back`, `selfie`, `document_front_video`, `document_back_video`, `liveness_video`, `proof_of_address`, `business_document`, `address_photo`, `auth_reference`
`auth_reference` is the reference face photo for a biometric-authentication session: upload it here, then pass its `mediaId` as `faceReferenceMediaId` on [`POST /api/kyc/sessions`](https://trust.myaza.co/documentation/api-create-session/markdown). It is never sent by an applicant mid-flow.
### Allowed MIME types
`image/jpeg`, `image/png`, `image/webp`, `video/webm`, `video/mp4`. Proof-of-address and business documents also accept `application/pdf`.
### Limits
Maximum file size is **25 MB**. Uploaded files are held with a short TTL and are only retained permanently once referenced by a verification. Orphaned uploads are discarded automatically.
## Request
```bash
curl "https://trust.myaza.app/api/kyc/upload" \
-H "Authorization: Bearer $MYAZA_KEY" \
-F "type=document_front" \
-F "file=@./id-front.jpg"
```
## Response `200 OK`
```json
{ "mediaId": "media_01j9abc..." }
```
Pass the returned IDs to verify under `mediaIds`, keyed by capture slot:
```json
{
"mediaIds": {
"documentFront": "media_01j9abc...",
"selfie": "media_01j9def..."
}
}
```
## Errors
| Status | Body | Cause |
|---|---|---|
| `400` | `{ "error": "file is required" }` | No `file` field sent. |
| `400` | `{ "error": "type must be one of …" }` | Missing or invalid `type`. |
| `400` | `{ "error": "mimeType one of …" }` | Unsupported MIME type. |
| `400` | `{ "error": "File too large (max 25MB)" }` | File exceeds the size cap. |
| `401` | `{ "error": "Invalid API key" }` | Auth failed. |
See [Errors](https://trust.myaza.co/documentation/errors/markdown) for the complete reference.
---
# Create a verification
```
POST /api/kyc/verify
```
Submits an identity for verification. The request returns immediately with `202 Accepted` and a `verificationId`; the actual verification runs asynchronously. Receive the outcome via [webhooks](https://trust.myaza.co/documentation/webhooks/markdown) or by polling [`GET /status/:id`](https://trust.myaza.co/documentation/api-verification-status/markdown).
This call is also what fires [`verification.started`](https://trust.myaza.co/documentation/webhooks/markdown#when-the-applicant-submits). That is the submission event: it fires here, before any check has run, and is how your backend learns that somebody finished the flow.
**Authentication:** `Authorization: Bearer pk_…` (required).
**Content type:** `application/json`.
**Production note:** `pk_live_` keys require an [approved business](https://trust.myaza.co/documentation/environments/markdown#production).
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `country` | string | yes | ISO-3166 alpha-2 country code: a government-database market (`NG`, `GH`, `KE`, `ZA`, `CI`) or any country enabled for [global document verification](https://trust.myaza.co/documentation/id-types/markdown). |
| `idType` | string | yes | ID type identifier, e.g. `bvn`. See [Countries & ID types](https://trust.myaza.co/documentation/id-types/markdown). |
| `idNumber` | string | no* | The ID number. Required for number-based IDs; omit for pure document flows. |
| `userData` | object | no | Applicant-provided details to cross-check: `firstName`, `lastName`, `dateOfBirth`. |
| `mediaIds` | object | no | `mediaId`s from [`POST /upload`](https://trust.myaza.co/documentation/api-upload/markdown). Keys: `documentFront`, `documentBack`, `selfie`, `documentFrontVideo`, `documentBackVideo`, `livenessVideo`. |
| `externalUserId` | string | no | **Your** stable reference for the person being verified (your user id). It is returned as `externalUserId`, links the resulting [entity](https://trust.myaza.co/documentation/identity-hub/markdown), and rides every [webhook](https://trust.myaza.co/documentation/webhooks/markdown#your-reference-on-every-event) about this verification. Strongly recommended. |
| `metadata` | object | yes | Request metadata (see below). Any extra keys are **your customer metadata**: bounded (16 KiB, four levels, 100 keys), stored apart from Myaza's own fields, and echoed in responses and on every webhook about this verification. Use opaque correlation values (a loan id, an account id), never credentials or unnecessary personal data. |
| `metadata.requestId` | string | yes | **Your** unique idempotency key for this request. |
| `metadata.userId` | string | no | Legacy location for your user reference. Prefer the top-level `externalUserId`; when both are present the top-level field wins. |
| `metadata.device` | object | no | Free-form device metadata; forwarded to the dashboard. |
\* Whether `idNumber` and which `mediaIds` are needed depends on the ID type. Check its `features` from [`GET /config`](https://trust.myaza.co/documentation/api-config/markdown).
### Optional header
| Header | Description |
|---|---|
| `X-SDK-Version` | The SDK version making the call; recorded with the verification for support. |
## Request
```bash
curl "https://trust.myaza.app/api/kyc/verify" \
-H "Authorization: Bearer $MYAZA_KEY" \
-H "Content-Type: application/json" \
-d '{
"country": "NG",
"idType": "drivers-license",
"idNumber": "ABC12345",
"userData": { "firstName": "John", "lastName": "Doe", "dateOfBirth": "1990-01-01" },
"mediaIds": { "documentFront": "media_01j9abc...", "selfie": "media_01j9def..." },
"externalUserId": "user_42",
"metadata": { "requestId": "order_1001", "loanId": "loan_20191" }
}'
```
## Response `202 Accepted`
```json
{
"verificationId": "ver_01j9...",
"status": "processing",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" }
}
```
`externalUserId` and `metadata` come back exactly as stored, so a retry of the same `requestId` returns the same values.
The verification is now queued. Track it via its `verificationId`.
`processing` is the same word [Get verification status](https://trust.myaza.co/documentation/api-verification-status/markdown) returns for this id a moment later. There is one status vocabulary across the whole API, so nothing changes meaning between the response that accepts a submission and the one that reports on it.
## Idempotency
`metadata.requestId` is your idempotency key. If you submit a request with a `requestId` that already exists **for your organisation**, the API returns the existing verification instead of creating a new one:
```json
{ "verificationId": "ver_01j9...", "status": "approved" }
```
Always send a stable `requestId` per logical verification so retries (timeouts, network errors) never create duplicates or double-charge you. Reusing another organisation's `requestId` is not possible: a collision across orgs returns `403`.
## Errors
| Status | Body | Cause |
|---|---|---|
| `400` | `{ "error": "Invalid request body", "message": "…" }` | Validation failed (bad country, missing `requestId`, etc.). |
| `401` | `{ "error": "Invalid API key" }` | Auth failed. |
| `403` | `{ "error": "Forbidden" }` | The `requestId` belongs to another organisation. |
| `403` | `{ "error": "business_not_approved", … }` | Production key but business not approved. |
| `429` | `{ "error": "Too many requests, please try again later." }` | [Rate limit](https://trust.myaza.co/documentation/rate-limits/markdown) exceeded. |
> An accepted request (`202`) can still end in a `failed`, `not_found`, or `error` status later, for example insufficient credit, an ID not found in the government database, or a face mismatch. Those outcomes arrive via status/webhook, not as HTTP errors, and each carries a `reason` plus a stable `reasonCode`. See the [verification lifecycle](https://trust.myaza.co/documentation/verifications/markdown) and the [failure reason codes](https://trust.myaza.co/documentation/verifications/markdown#failure-reason-codes).
---
# Get verification status
```
GET /api/kyc/status/:verificationId
```
Returns the **minimal** lifecycle state of a verification. `status` is the merged
top-line (`not_started`, `in_progress`, `processing`, `in_review`,
`awaiting_resubmission`, `approved`, `declined`, `abandoned`, `expired`, `error`)
and `checkStatus` is what the checks themselves found (`pending`, `verified`,
`failed`, `not_found`, `error`). They differ when a person overrode the automated
result. See [Status and checkStatus](https://trust.myaza.co/documentation/webhooks/markdown#status-and-checkstatus).
This endpoint is **publishable-safe**: it can be
called with the `pk_` key that ships in your SDK, so it deliberately returns **no
PII, no match scores, and no result data**. To read the full result + extracted
biodata, use [Get verification result](https://trust.myaza.co/documentation/api-verification-result/markdown) with a **secret**
(`sk_`) key from your backend.
[Webhooks](https://trust.myaza.co/documentation/webhooks/markdown) are the recommended way to learn about completion without polling.
**Authentication:** `Authorization: Bearer pk_…` or `sk_…` (required). A verification is only visible to the organisation that created it.
## Request
```bash
curl "https://trust.myaza.app/api/kyc/status/ver_01j9..." \
-H "Authorization: Bearer $MYAZA_PUBLISHABLE_KEY"
```
## Responses
Every response includes `verificationId`, `status`, `createdAt`, your
`externalUserId` (so a publishable key can tell which user an id belongs to;
your `metadata` stays on the [secret-key result](https://trust.myaza.co/documentation/api-verification-result/markdown)), and
the workflow attribution pair `workflowId` / `workflowVersion` (both `null` when
the SDK was configured with plain props instead of a [workflow](https://trust.myaza.co/documentation/workflows/markdown)), and
`attempt` with its `submittedAt`. A verification you
[send back or verify again](https://trust.myaza.co/documentation/api-verify-again/markdown) keeps its id: when the applicant
resubmits, `attempt` counts up and `submittedAt` and `completedAt` describe their
latest attempt.
### Why a verification is still `processing`
`processing` means "no answer yet", and it covers both the eight seconds we
spend reading a document and the eight days an [address presence
watch](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification) runs. Those read identically
on a row, so **`waitingOn`** says which:
| `waitingOn` | The decision is held on |
|---|---|
| `null` | Nothing. The checks are simply still running. |
| `"screening"` | A sanctions, PEP or adverse-media screen. |
| `"key_people"` | The directors and owners on a business application finishing their own verifications. |
| `"address_presence"` | A presence watch confirming the person lives at the address. Days, not seconds. |
It is only ever set while a decision run is genuinely parked, and it clears the
moment the run continues. Values are add-only: treat one you do not recognise
as a check you have not learned about yet, rather than mapping it to a known
one.
### `processing` / `approved`
```json
{
"verificationId": "ver_01j9...",
"status": "approved",
"checkStatus": "verified",
"externalUserId": "user_42",
"workflowId": "wf_AbC123dEf456",
"workflowVersion": 3,
"attempt": 1,
"createdAt": "2026-04-27T12:00:00.000Z",
"submittedAt": "2026-04-27T12:00:00.000Z",
"completedAt": "2026-04-27T12:00:05.000Z"
}
```
> `workflowVersion` is the published version that **actually ran**. Publishing a
> workflow overwrites its live configuration in place, so keep the pair if you need
> to explain later exactly which rules a submission went through. The workflow's
> human-readable name is on the [full result](https://trust.myaza.co/documentation/api-verification-result/markdown); this
> endpoint stays minimal.
> An `approved` status confirms the person was accepted, but carries no identity
> data. Fetch the [full result](https://trust.myaza.co/documentation/api-verification-result/markdown) with a secret key to read
> the biodata and facial-match score.
> `approved` with `checkStatus: "failed"` is not a contradiction. It means a person
> reviewed the verification and accepted it despite what the checks found, and
> `reason`/`reasonCode` below say what they accepted it despite. Branch on `status`;
> store `checkStatus` alongside it.
### `declined` / `error`
`declined` means the person was not accepted, either because the checks did not pass or because a reviewer declined them. `error` means a system problem occurred on our side and you were not charged. Both include a human-readable `reason` and a stable `reasonCode` you can branch on. See [failure reason codes](https://trust.myaza.co/documentation/verifications/markdown#failure-reason-codes).
A `reason` is present whenever the checks did not pass, even on a verification a reviewer later approved.
```json
{
"verificationId": "ver_01j9...",
"status": "declined",
"checkStatus": "failed",
"reason": "The selfie does not match the photo on the government record for this ID. Take it again in good light, looking straight at the camera. If it still does not match, check that the ID number entered is the right one.",
"reasonCode": "selfie_mismatch",
"createdAt": "2026-04-27T12:00:00.000Z",
"completedAt": "2026-04-27T12:00:05.000Z"
}
```
### `declined` with `identity_not_found`
An ID number the government database does not hold is a decline like any other; the `reasonCode` is what tells it apart from a failed face match. There is no separate `not_found` status.
```json
{
"verificationId": "ver_01j9...",
"status": "declined",
"checkStatus": "not_found",
"reason": "The BVN number was not found in the government database.",
"reasonCode": "identity_not_found",
"createdAt": "2026-04-27T12:00:00.000Z",
"completedAt": "2026-04-27T12:00:05.000Z"
}
```
## Errors
| Status | Body | Cause |
|---|---|---|
| `404` | `{ "error": "Verification not found" }` | Unknown ID, or it belongs to another organisation. |
| `401` | `{ "error": "Invalid API key" }` | Auth failed. |
## Polling guidance
If you must poll, do so with backoff (e.g. every 2–3 seconds, widening over time) and stop once `status` is no longer `pending`. Prefer [webhooks](https://trust.myaza.co/documentation/webhooks/markdown) for production. Once complete, fetch the [full result](https://trust.myaza.co/documentation/api-verification-result/markdown) from your backend.
---
# Get verification result
```
GET /api/kyc/verifications/:id
```
Returns the **full** result of a verification, including the extracted biodata, ID
number, and facial-match score. This is the backend counterpart to the minimal
[status endpoint](https://trust.myaza.co/documentation/api-verification-status/markdown).
**Authentication:** `Authorization: Bearer sk_…`, a **secret key only**. Calling
this with a publishable (`pk_`) key returns `403 secret_key_required`. The
verification is scoped to the key's organisation **and** environment (a sandbox
secret key cannot read a production verification).
> **Never call this from client code.** Identity data must only be fetched from
> your backend with a secret key.
## Request
```bash
curl "https://trust.myaza.app/api/kyc/verifications/ver_01j9..." \
-H "Authorization: Bearer $MYAZA_SECRET_KEY"
```
## Responses
Every response includes `verificationId`, `status`, `country`, `idType`, `createdAt`,
and `workflow`, which [workflow](https://trust.myaza.co/documentation/workflows/markdown) was responsible for the submission.
### `approved`
```json
{
"verificationId": "ver_01j9...",
"status": "approved",
"checkStatus": "verified",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" },
"country": "NG",
"idType": "bvn",
"createdAt": "2026-04-27T12:00:00.000Z",
"completedAt": "2026-04-27T12:00:05.000Z",
"workflow": {
"id": "wf_AbC123dEf456",
"name": "Standard KYC",
"version": 3
},
"result": {
"idNumber": "12345678901",
"idNumberMasked": "1234•••901",
"firstName": "JOHN",
"lastName": "DOE",
"middleName": "A",
"dateOfBirth": "1990-01-01",
"gender": "Male",
"dataMatch": true,
"facialMatch": { "match": true, "confidence": 85 },
"facialMatchSource": "gov_record"
}
}
```
`facialMatch` is `null` when no liveness/selfie check was part of the flow. `facialMatchSource` says which photo the selfie was compared with: `gov_record`, `chip`, or `document` when the workflow [allows the photo printed on the document](https://trust.myaza.co/documentation/workflows/markdown#matching-the-selfie-to-the-photo-on-a-document) and no stronger photo existed.
`workflow` is `null` when the SDK was configured with plain props instead of a
workflow. Its `version` is the published version that **actually ran**: publishing
overwrites a workflow's live configuration in place, so `id` alone stops describing
what happened after your next publish. Inspect the exact configuration a version
used under **Workflows → Version history**.
### `failed` / `error` / `not_found`
Carries the human-readable `reason` and stable `reasonCode` (same values as the
[status endpoint](https://trust.myaza.co/documentation/api-verification-status/markdown) and [webhooks](https://trust.myaza.co/documentation/webhooks/markdown)).
```json
{
"verificationId": "ver_01j9...",
"status": "declined",
"checkStatus": "failed",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" },
"country": "NG",
"idType": "bvn",
"createdAt": "2026-04-27T12:00:00.000Z",
"completedAt": "2026-04-27T12:00:05.000Z",
"reason": "The selfie does not match the photo on the government record for this ID. Take it again in good light, looking straight at the camera. If it still does not match, check that the ID number entered is the right one.",
"reasonCode": "selfie_mismatch"
}
```
### After a decision
When the verification has been decided, by a reviewer in your dashboard, by your backend through [`POST /verifications/:id/review`](https://trust.myaza.co/documentation/api-review-verification/markdown) or by your workflow, `review` holds the current decision a person made and `statusHistory` lists every change, newest first:
```json
{
"verificationId": "ver_01j9...",
"status": "declined",
"checkStatus": "verified",
"review": {
"decision": "DECLINED",
"reviewedAt": "2026-04-28T09:14:00.000Z",
"reviewedById": "api_key:ak_01j9..."
},
"statusHistory": [
{
"status": "declined",
"previousStatus": "in_review",
"source": "api",
"changedBy": { "type": "api_key", "id": "ak_01j9..." },
"changedAt": "2026-04-28T09:14:00.000Z",
"decision": "DECLINED",
"outcome": null,
"runId": null,
"resubmitSteps": []
},
{
"status": "in_review",
"previousStatus": "processing",
"source": "workflow",
"changedBy": { "type": "workflow", "id": "wf_AbC123dEf456" },
"changedAt": "2026-04-27T12:00:07.000Z",
"decision": null,
"outcome": "review",
"runId": "wfr_01j9...",
"resubmitSteps": []
}
]
}
```
Each entry has the same fields as one [`verification.status_updated`](https://trust.myaza.co/documentation/webhooks/markdown#status-changes) webhook, so what you store from the webhook and what you read here agree. The reviewer's internal note is never included.
### Attempts
A verification you [send back or verify again](https://trust.myaza.co/documentation/api-verify-again/markdown) keeps its id. Once the applicant resubmits, the result describes their latest attempt, `attempt` counts up, and each earlier attempt is listed under `attempts`, oldest first:
```json
{
"verificationId": "ver_01j9...",
"status": "approved",
"attempt": 2,
"submittedAt": "2026-04-29T10:02:00.000Z",
"completedAt": "2026-04-29T10:02:06.000Z",
"attempts": [
{
"attempt": 1,
"checkStatus": "failed",
"reason": "The selfie does not match the photo on the government record for this ID. Take it again in good light, looking straight at the camera.",
"reasonCode": "selfie_mismatch",
"assuranceLevel": null,
"decision": "RESUBMISSION",
"resubmitSteps": ["liveness"],
"submittedAt": "2026-04-27T12:00:00.000Z",
"completedAt": "2026-04-27T12:00:05.000Z",
"resubmittedAt": "2026-04-29T10:02:00.000Z"
}
]
}
```
An earlier attempt carries what it concluded and why, never its biodata or media. The current result is the one to act on, and `statusHistory` still lists every decision across all attempts.
## Result fields
| Field | Type | Description |
|---|---|---|
| `externalUserId` | string \| null | Your reference for the subject, exactly as supplied: the top-level `externalUserId` on the submission (or the legacy `userId` / `metadata.userId`), or the session it was minted with. |
| `metadata` | object \| null | Your customer metadata, echoed unchanged. |
| `workflow` | object \| null | `{ id, name, version }`: the workflow that drove the submission, its current name, and the published version that ran. `null` for prop-configured SDK mounts. |
| `review` | object \| null | The current decision a person or your backend made: `{ decision, reviewedAt, reviewedById }`. `reviewedById` is a user id, or `api_key:` for a decision made through the API. `null` when nobody has decided. |
| `statusHistory` | array | Every change to `status`, newest first, whoever made it. Empty until something decides the verification. See [After a decision](#after-a-decision). |
| `attempt` | number | Which attempt this result describes. `1` until the verification is sent back and the applicant resubmits. |
| `submittedAt` | string | When this attempt was submitted. `completedAt` is timed from it. |
| `attempts` | array | The earlier attempts, oldest first. Empty until the verification is redone. See [Attempts](#attempts). |
| `result.idNumber` | string \| null | The plaintext ID number. |
| `result.idNumberMasked` | string | The ID number, masked (e.g. `1234•••901`). |
| `result.firstName` | string \| null | Given name from the source record. |
| `result.lastName` | string \| null | Family name from the source record. |
| `result.middleName` | string \| null | Middle name, if available. |
| `result.dateOfBirth` | string \| null | Date of birth (`YYYY-MM-DD`). |
| `result.gender` | string \| null | Gender from the source record. |
| `result.dataMatch` | boolean \| null | Whether submitted biodata matched the source record. |
| `result.facialMatch` | object \| null | `{ match: boolean, confidence: number }`, or `null` if no facial check ran. |
| `result.facialMatchSource` | string \| null | Which photo the selfie was compared with: `gov_record`, `chip` or `document` (the photo printed on the ID, weaker evidence). `null` if no facial check ran. |
| `result.facialMatchSkipped` | string \| null | Why no facial check ran when one was expected: `no_face_on_document` when the photo printed on the document was the only photo to compare with and no face could be read on it (your workflow kept the verification for review). `null` otherwise. |
## Captured media
The images and videos captured during the flow are fetched per-kind from
`GET /api/kyc/verifications/:id/media/:kind` (`selfie`, `document-front`,
`document-back`, `liveness-video`, `document-front-video`, `document-back-video`,
and `document-portrait`, the portrait cut from the document when the selfie was matched to it),
also **secret key only**. These same URLs are delivered in the `media` object of
verification [webhooks](https://trust.myaza.co/documentation/webhooks/markdown).
## Errors
| Status | Body | Cause |
|---|---|---|
| `403` | `{ "error": "secret_key_required" }` | A publishable (`pk_`) key was used. Use a secret key. |
| `404` | `{ "error": "Verification not found" }` | Unknown ID, wrong environment, or another organisation's verification. |
| `401` | `{ "error": "Invalid API key" }` | Auth failed. |
---
# Face match
```
POST /api/kyc/face/compare
POST /api/kyc/face/upload
GET /api/kyc/face/checks
GET /api/kyc/face/checks/:id
```
Compare two photos of a face and learn, synchronously, whether they show the same person. This is the server-to-server counterpart of the dashboard's Face match tab: same engine, same verdicts, same history (API checks appear there labelled "API").
**Authentication:** `Authorization: Bearer sk_…` (secret keys only, for the whole surface). The inputs are biometric data and the verdict is a compliance record, so this never belongs in client-side code.
**Production note:** `sk_live_` keys require an [approved business](https://trust.myaza.co/documentation/environments/markdown#production).
## Compare
```
POST /api/kyc/face/compare
```
One call: send both photos inline and get the verdict back. Each photo is **exactly one** of an image field (URL, data URI, or base64) or a `mediaId` from the optional upload endpoint below.
| Field | Required | Description |
|---|---|---|
| `imageA` | one of | The first photo, inline: an `https://` URL we fetch, a `data:` URI, or base64-encoded bytes. JPEG, PNG or WebP; the format is detected from the bytes, never from a label. |
| `imageB` | one of | The second photo, same forms. |
| `mediaAId` | one of | Alternative to `imageA`: a `mediaId` from `POST /face/upload`. |
| `mediaBId` | one of | Alternative to `imageB`. |
| `consent` | yes | Must be `true`: you attest the people shown have consented to the comparison. |
| `sandboxOutcome` | no | Sandbox only, see below. Ignored on live keys. |
```bash
curl -X POST https://trust.myaza.app/api/kyc/face/compare \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"imageA": "https://cdn.example.com/enrolment/selfie-42.jpg",
"imageB": "data:image/jpeg;base64,/9j/4AAQ…",
"consent": true
}'
```
**Size limits.** URLs and uploads accept photos up to 25 MB. Base64 travels inside the JSON body, which is capped at 10 MB, so base64 photos top out around 7 MB of image; send anything bigger by URL or upload.
**URL fetching.** Image URLs must be http(s) and resolve to a public address; anything that points into a private network is refused with `image_url_not_allowed`. Redirects are followed (up to 3), each hop checked the same way.
The response:
```json
{
"faceCheck": {
"id": "fc_…",
"verdict": "match",
"match": true,
"confidence": 91,
"threshold": 80,
"reviewThreshold": 60,
"chargedAmount": "0.0300",
"createdAt": "2026-08-13T12:00:00.000Z"
}
}
```
### The verdict
`verdict` is three-way, judged against your organisation's thresholds (the pass mark and review floor, managed on the dashboard's Face match tab):
| Verdict | Meaning |
|---|---|
| `match` | Similarity at or above your pass mark. |
| `review` | Similarity between your review floor and pass mark. The score alone cannot decide; a human should compare the photos. |
| `no_match` | Similarity below your review floor. |
| `no_result` | The comparison could not run (for example, no detectable face). Returned with HTTP 502 and **nothing is charged**. |
There is deliberately no per-request threshold: the lines that judge a verdict are your organisation's recorded policy, and every check records the lines that judged it. If part of your flow needs a stricter bar, branch on `confidence` in your own code; the recorded verdict stays consistent.
## Uploading first (optional)
```
POST /api/kyc/face/upload
```
When the bytes are already on your side and you would rather not inline them, or you want to reuse one photo across several checks, upload it once and pass the `mediaId`. Multipart form with a single `file` field (jpeg, png or webp, up to 25 MB); each call returns one `mediaId`.
```bash
curl -X POST https://trust.myaza.app/api/kyc/face/upload \
-H "Authorization: Bearer sk_live_…" \
-F "file=@photo-a.jpg"
# → { "mediaId": "med_…" }
```
## Re-read a check
```
GET /api/kyc/face/checks/:id
```
Returns the same `faceCheck` object for any past check belonging to your organisation and environment.
## History
```
GET /api/kyc/face/checks
```
Paginated face-check history for your organisation and the key's environment, newest first. Query parameters: `verdict` (`match` / `review` / `no_match` / `no_result`), `from` / `to` (ISO dates), `page`, `pageSize` (max 100). Rows carry the same fields as the compare response plus `initiatedVia` (`api` or `dashboard`).
## Deleting a check
```
DELETE /api/kyc/face/checks/:id
```
Removes a check from your history. It is a soft delete, with the same body and response shape as the other record deletions; see [Delete records & expire sessions](https://trust.myaza.co/documentation/api-manage-records/markdown#delete-a-face-check).
## Sandbox behaviour
Sandbox keys (`sk_test_`) always return **simulated** verdicts; the real comparison engine runs in production only. With no `sandboxOutcome` the verdict is a simulated `match`; pass `review`, `no_match` or `no_result` to see the others. Simulated scores are placed inside your organisation's own thresholds, so they behave exactly as a live score would. Sandbox checks are free, and sandbox photos are replaced with a placeholder after 3 days.
## Billing
Production checks are billed per comparison at your facial comparison price (see [your pricing](https://trust.myaza.co/documentation/environments/markdown)). A `no_match` still charges, because the check ran and answered. A `no_result` never charges.
## Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | `same_photo` | Both sides are the same photo (same media id, or identical inline images). |
| 400 | `media_unreadable` | One of the photos could not be read; send both again. |
| 400 | `invalid_image` | An inline image is neither an https URL, a data URI, nor valid base64. |
| 400 | `unsupported_media_type` | The bytes are not a JPEG, PNG or WebP photo. |
| 400 | `image_too_large` | The photo exceeds 25 MB. |
| 400 | `image_url_not_allowed` | The image URL is not http(s), or resolves to a non-public address. |
| 400 | `image_fetch_failed` | The image URL could not be fetched (unreachable, error status, empty body, or too many redirects). |
| 402 | `insufficient_credits` | Your balance cannot cover the check. |
| 403 | `spot_checks_disabled` | Face checks are not enabled for your organisation. |
| 403 | `secret_key_required` | A publishable key was used; this surface is secret-key only. |
| 429 | `rate_limited` | Hourly face-check limit reached; retry later. |
| 502 | `no_result` | The comparison could not run; nothing charged. |
---
# Create a session
```
POST /api/kyc/sessions
```
Mints a hosted verification link for **one applicant** from one of your published [workflows](https://trust.myaza.co/documentation/workflows/markdown), entirely server to server. You get back a URL that is theirs alone: send it by email, SMS, WhatsApp, or render it as a QR, and the applicant completes the flow on our hosted page with no SDK integration on your side.
The session **is** the link. It carries one verification, it is traceable to your own reference for the applicant, and once it is submitted it is spent. Forwarding it to a second person does not work. This is the API twin of the dashboard's "Create session" action; the two accept the same request and mint the same thing.
**Authentication:** `Authorization: Bearer sk_…` (**secret key required**). The response contains a live verification credential and the request may carry applicant details, so this endpoint is backend-only. A publishable key receives `403 secret_key_required`.
**Content type:** `application/json`.
**Production note:** `sk_live_` keys require an [approved business](https://trust.myaza.co/documentation/environments/markdown#production).
Looking for one URL many people can open, each visit starting its own verification? That is a workflow's [hosted link](https://trust.myaza.co/documentation/workflows/markdown#2-share-a-hosted-link). This endpoint is the opposite shape: you know the applicant, and you want a link that is theirs.
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `workflowId` | string | yes | The **published** workflow to run, e.g. `wf_AbC123dEf456`. Drafts and archived workflows are refused. |
| `externalUserId` | string | no | **Your** reference for the subject: your user id on an individual flow, the business on a KYB one. It is what links the resulting verification and [entity](https://trust.myaza.co/documentation/identity-hub/markdown) back to your records, and what a returning applicant resumes by. Strongly recommended. |
| `metadata` | object | no | Your own correlation fields for this applicant (a loan id, an account id). Bounded (16 KiB, four levels, 100 keys), stored on the session, inherited by the verification it produces and carried on every [webhook](https://trust.myaza.co/documentation/webhooks/markdown#your-reference-on-every-event) about it. |
| `userData` | object | no | What you already know about the subject, to prefill the flow: `firstName`, `lastName`, `dateOfBirth`, and for KYB flows `businessName`. Prefill, never assertion: the applicant confirms or corrects every value, so none of it is treated as verified. `email` is the applicant's address: nothing in the flow asks for it, and it is where we write when you have the applicant [emailed about a decision](https://trust.myaza.co/documentation/api-review-verification/markdown#emailing-the-applicant). When you leave it out, the `email` you send the link to stands in for it. |
| `business` | object | no | For KYB flows, the company where you already know it: `country` (ISO-2), `registrationNumber`, `registrationName`. The applicant lands on a picked-company card instead of a search box, and the register remains the source of truth. |
| `email` | string | no | Email the link to the applicant on your behalf. Optional: deliver it your own way and omit this. |
| `faceReferenceMediaId` | string | see note | Biometric-authentication workflows only: the mediaId of a reference face photo (uploaded via [`POST /api/kyc/upload`](https://trust.myaza.co/documentation/api-upload/markdown) with type `auth_reference`). The applicant's live selfie is matched against this photo instead of a stored enrolment, so you can authenticate somebody you never enrolled through KYC. |
A **biometric-authentication** workflow must name its subject at mint time: pass `externalUserId` for a user with an active biometric enrolment, or `faceReferenceMediaId` with a reference photo. Neither present is refused with `422 user_reference_required`; a reference without an enrolment with `422 not_enrolled`. A **biometric-enrolment** workflow always requires `externalUserId` (the template binds a face to a named record). This is checked when the session is created, so a link that could only fail at submit is never minted.
## Request
```bash
curl "https://trust.myaza.app/api/kyc/sessions" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"workflowId": "wf_AbC123dEf456",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" },
"userData": { "firstName": "John", "lastName": "Doe" },
"email": "john@example.com"
}'
```
## Response `201 Created`
```json
{
"session": {
"sessionId": "cmt1gfjce05g547grqbmjsiqf",
"url": "https://trust.myaza.co/verify/pXk3...",
"shortCode": "7K9F-2QHM",
"expiresAt": "2026-08-21T12:00:00.000Z",
"kind": "individual",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" },
"workflow": { "id": "wf_AbC123dEf456", "name": "Identity verification", "version": 3 }
},
"delivery": { "sent": true }
}
```
| Field | Description |
|---|---|
| `session.url` | The applicant's link. Treat it like a credential: whoever opens it verifies as this applicant. |
| `session.shortCode` | A human-typable code for when a camera cannot scan a QR. Display only, never a credential. |
| `session.expiresAt` | When the link stops accepting a **new** start: 24 hours for individual flows, 7 days for business (KYB) ones, unless the workflow sets its own session lifetime. A submitted verification is unaffected by expiry. |
| `session.kind` | `individual` or `business`, following the workflow's subject type. |
| `session.externalUserId`, `session.metadata` | Your reference and metadata, echoed exactly as stored. The verification this session produces, and every webhook about it, carry the same values. |
| `delivery` | `null` when no `email` was given; `{ "sent": true }` or `{ "sent": false, "reason": "send_failed" }` otherwise. A failed send never fails the mint; the link in `session.url` works regardless. |
The config the applicant walks is the workflow's published snapshot, frozen at mint time. Editing or re-publishing the workflow never changes a link that is already in someone's inbox.
## Tracking the session
The `sessionId` doubles as the verification's id once the applicant submits, so the reference you hold from the start resolves from the start:
- [`GET /status/:id`](https://trust.myaza.co/documentation/api-verification-status/markdown) reports the attempt live: `in_progress` with the current step and captured slots, then the full verification lifecycle after submission, and `abandoned` or `expired` if the applicant never finishes.
- [Session webhooks](https://trust.myaza.co/documentation/webhooks/markdown) (`session.started`, `session.resumed`, `session.abandoned`, `session.expired`) push the same lifecycle to your backend, and the verification events follow once they submit.
- Opening the `url` in a WebView or an iframe? The page tells its host what the applicant is doing, step by step, with nothing to install: see [Hosted link events](https://trust.myaza.co/documentation/hosted-events/markdown).
- Sending the `url` yourself over WhatsApp, SMS or email? It previews as your request: your organisation's name and logo, the workflow's primary colour and "Verify your identity with {Your organisation}" in the title, the same way a [hosted link](https://trust.myaza.co/documentation/workflows/markdown) does. A spent or expired session previews as a neutral Myaza Trust card.
## Ending a session early
A session you no longer want opened can be expired with `POST /api/kyc/sessions/:sessionId/expire`: the link stops working at once and you receive `session.expired`. See [Delete records & expire sessions](https://trust.myaza.co/documentation/api-manage-records/markdown).
## Errors
| Status | `error` | Meaning |
|---|---|---|
| `400` | `invalid_input` | The body failed validation; `message` names the first problem. |
| `403` | `secret_key_required` | A publishable key was used. Call this from your backend with a secret key. |
| `403` | `business_not_approved` | `sk_live_` before [KYB approval](https://trust.myaza.co/documentation/environments/markdown#production). Sandbox keys work from day one. |
| `422` | `workflow_not_available` | The workflow is not published (or is suspended), so it cannot start a verification. |
| `429` | `rate_limited` | Too many sessions minted this hour. Retry later, or contact support to raise your limit. |
---
# Errors
The API uses conventional HTTP status codes and returns a JSON body with an `error` field (and sometimes a `message`) on failure.
```json
{ "error": "business_not_approved", "message": "Your business must be approved …" }
```
## Status codes
| Status | Meaning | Typical causes |
|---|---|---|
| `200` | OK | Successful `GET`/`upload`. |
| `202` | Accepted | Verification queued (see [create verification](https://trust.myaza.co/documentation/api-create-verification/markdown)). |
| `400` | Bad request | Invalid body, bad/missing field, unsupported file type, file too large. |
| `401` | Unauthorized | Missing `Authorization` header, or invalid/revoked API key. |
| `403` | Forbidden | Production key without an approved business, a publishable key on a secret-only endpoint (`secret_key_required`), or a `requestId` owned by another org. |
| `404` | Not found | Verification ID does not exist (or isn't yours). |
| `429` | Too many requests | [Rate limit](https://trust.myaza.co/documentation/rate-limits/markdown) exceeded. |
| `500` | Server error | E.g. pricing not configured for the requested country/ID type. |
## Common error bodies
| `error` | Status | Meaning |
|---|---|---|
| `Missing or invalid Authorization header` | 401 | No Bearer token sent. |
| `Invalid API key` | 401 | Key is unknown or revoked. |
| `Invalid request body` | 400 | Verify payload failed validation; see `message` for details. |
| `Forbidden` | 403 | The `requestId` belongs to a different organisation. |
| `business_not_approved` | 403 | Production access requires an [approved business](https://trust.myaza.co/documentation/environments/markdown#production). |
| `secret_key_required` | 403 | A publishable (`pk_`) key was used on a secret-only endpoint (full result or media). Use a secret (`sk_`) key from your backend. See [authentication](https://trust.myaza.co/documentation/authentication/markdown). |
| `environment_mismatch` | 403 | The key's environment doesn't match the server it was sent to (e.g. a `pk_live_` key against the sandbox host). Use the base URL for the key's [environment](https://trust.myaza.co/documentation/environments/markdown). |
| `Verification not found` | 404 | Unknown verification ID. |
| `File too large (max 25MB)` | 400 | Upload exceeded the size cap. |
| `Too many requests, please try again later.` | 429 | Slow down; see [rate limits](https://trust.myaza.co/documentation/rate-limits/markdown). |
| `pricing_not_configured` | 500 | No price set for that country/ID type. Contact Myaza. |
## Asynchronous failures
A `202 Accepted` from [create verification](https://trust.myaza.co/documentation/api-create-verification/markdown) only means the request was queued. The verification can still finish as `failed`, `not_found`, or `error`. Those are **not** HTTP errors: read them from [`GET /status/:id`](https://trust.myaza.co/documentation/api-verification-status/markdown) or the [`verification.*` webhooks](https://trust.myaza.co/documentation/webhooks/markdown).
Each non-success outcome carries a human-readable `reason` plus a stable `reasonCode` you can branch on (e.g. `document_expired`, `selfie_mismatch`, `identity_not_found`). The full catalogue is in [failure reason codes](https://trust.myaza.co/documentation/verifications/markdown#failure-reason-codes).
## Health check
```
GET /api/kyc/health
```
A public, unauthenticated endpoint for uptime monitoring.
```json
{ "status": "ok" }
```
## Handling errors well
- Retry `429`, `502`, `503`, `504` and connection failures with backoff and the
same idempotency key. Do not blindly retry every `500`: preserve its request ID
and contact support if it persists.
- Treat `4xx` (except `429`) as **non-retryable**: fix the request; retrying unchanged will fail again.
- Always read the `error`/`message` fields; don't rely on status code alone.
## Trust SDK errors
`@myazahq/trust-sdk` exposes typed errors so your backend can branch without
parsing messages:
```js
import {
AuthenticationError,
IdempotencyConflictError,
InsufficientCreditError,
MyazaError,
RateLimitError,
ValidationError,
} from '@myazahq/trust-sdk';
try {
await myaza.transactions.retrieve('activity_123');
} catch (error) {
if (error instanceof RateLimitError) {
console.log('Retry after seconds:', error.retryAfterSeconds);
} else if (error instanceof ValidationError || error instanceof IdempotencyConflictError) {
console.error(error.code, error.details, error.requestId);
} else if (error instanceof InsufficientCreditError) {
console.log({ pending: error.pending, statusUrl: error.statusUrl });
} else if (error instanceof AuthenticationError) {
console.error('Check the key type and environment.');
} else if (error instanceof MyazaError) {
console.error(error.status, error.code, error.requestId);
}
}
```
Transaction and activity decisions fail closed when credit is unavailable; a
late record is not an authorisation response. A paused customer risk assessment
sets `InsufficientCreditError.pending` or returns `pending_credit`, keeps the same
resource and resumes after funding. Do not submit a duplicate.
---
# Rate limits
The API is rate limited to protect platform stability. When you exceed the limit you receive `429 Too Many Requests`:
```json
{ "error": "Too many requests, please try again later." }
```
## The limit
| Scope | Limit | Window |
|---|---|---|
| Per client | 100 requests | 15 minutes |
Responses include standard [`RateLimit`](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/) headers (draft-7), so you can read your remaining quota and reset time programmatically rather than guessing:
```
RateLimit-Limit: 100
RateLimit-Remaining: 84
RateLimit-Reset: 540
```
## Handling 429
- **Back off and retry.** Wait until the window resets (use the `RateLimit-Reset` seconds value), then retry.
- **Use exponential backoff with jitter** if you don't read the headers.
- **Reuse the same `requestId`** when retrying a verification so the retry is idempotent and never double-charges.
- **Batch and pace** bulk work instead of bursting.
> Limits may differ between sandbox and production and can be adjusted for your account. Contact Myaza if your production volume needs a higher ceiling.
---
# Verify again
```
POST /api/kyc/verifications/:id/rerun
```
Asks the applicant to redo a **finished** verification: the same workflow, the same person reference, and a link to send them. Use it when a document has expired, a check failed on something fixable, or your own policy asks for periodic re-verification. This is the API twin of the dashboard's "Verify again" button, and the same operation as sending a verification back with [Decide a verification](https://trust.myaza.co/documentation/api-review-verification/markdown#sending-it-back). All of them run one core.
**The verification keeps its id.** When the applicant resubmits, what they send becomes the next **attempt** of this same verification, not a second verification. What the earlier attempt found is kept rather than overwritten: it is listed under `attempts` on the [result](https://trust.myaza.co/documentation/api-verification-result/markdown#attempts) and shown on the verification's timeline in your dashboard. You hold one id per applicant per check, and your webhook handler keeps updating the record it already has.
Calling it sets the verification's status to `awaiting_resubmission` and sends [`verification.status_updated`](https://trust.myaza.co/documentation/webhooks/markdown#status-changes). Any link sent earlier for the same verification stops working, so the applicant can only ever use the latest one.
**Authentication:** `Authorization: Bearer sk_…` (**secret key required**). The response contains a live verification credential. A publishable key receives `403 secret_key_required`.
**Content type:** `application/json`.
**Production note:** `sk_live_` keys require an [approved business](https://trust.myaza.co/documentation/environments/markdown#production).
## Redo only what failed
By default the applicant walks the whole flow again. Pass `steps` to narrow the redo to specific sections instead: a selfie that did not match does not require the applicant to re-photograph a perfectly good document.
```json
{ "steps": ["liveness"], "message": "Your selfie was too dark to match. Please retake it in good light." }
```
The applicant then walks **only** the ticked steps, with your message shown when they open the link. Everything they are not asked to redo is kept from the earlier attempt. Valid step names:
| Step | What the applicant redoes |
|---|---|
| `id-type` | ID type selection |
| `id-input` | Typing their ID number |
| `document-capture` | Document photos |
| `liveness` | Selfie and liveness |
| `proof-of-address` | Proof of address |
| `nfc` | Chip scan |
| `email-verification` | Email confirmation |
| `phone-verification` | Phone confirmation |
| `business-details` | Business details (KYB) |
| `business-key-people` | Directors and owners (KYB) |
| `business-documents` | Business documents (KYB) |
| `questionnaire` | Questionnaire |
An unknown step name is refused with `400 invalid_steps` rather than ignored. A silently dropped typo would turn a targeted redo into a full one, which is the one failure you could not see happening.
## Email the link
Pass `email` and we send the link to the applicant for you, in your organisation's name. The link is still returned in the response, and `delivery` tells you whether the email went out. Leave `email` out to deliver the link yourself: we hold no contact details for your users.
```json
{ "steps": ["liveness"], "email": "ada@example.com" }
```
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `steps` | string[] | no | Sections to redo, from the table above. Absent or empty means the whole flow. |
| `message` | string | no | Shown to the applicant on a narrowed flow, up to 500 characters. Write it for them, not for your logs. |
| `policy` | string | no | `original` runs the workflow version the applicant walked the first time; `latest` runs today's published version. Absent follows the workflow's own setting, which is `original` unless you changed it. |
| `email` | string | no | Send the link to this address as well as returning it. |
## Request
```bash
curl "https://trust.myaza.app/api/kyc/verifications/ver_abc123/rerun" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "steps": ["liveness"], "email": "ada@example.com" }'
```
## Response `201 Created`
```json
{
"session": {
"sessionId": "cmt1gfjce05g547grqbmjsiqf",
"url": "https://trust.myaza.co/verify/pXk3...",
"shortCode": "7K9F-2QHM",
"expiresAt": "2026-08-22T12:00:00.000Z",
"kind": "individual",
"workflow": { "id": "wf_AbC123dEf456", "name": "Identity verification", "version": 3 }
},
"retryOf": "ver_abc123",
"attempt": 1,
"steps": ["liveness"],
"full": false,
"delivery": { "emailed": true, "reason": null }
}
```
| Field | Description |
|---|---|
| `session` | The link to send the applicant, the same shape [creating a session](https://trust.myaza.co/documentation/api-create-session/markdown) returns. `session.url` is a credential: whoever opens it verifies as this applicant. |
| `retryOf` | The verification being redone. The applicant's resubmission is recorded against this id. |
| `attempt` | The attempt the verification holds now. The applicant's resubmission becomes attempt `attempt + 1`. |
| `steps` | The plan the link was minted with, in flow order. |
| `full` | `true` when the whole flow will run, which is what absent or empty `steps` produces. |
| `delivery` | When you passed `email`: `{ emailed, reason }`. `emailed: false` with `reason: "send_failed"` means the email could not be sent. The link still works, so send it yourself. Null when you passed no `email`. |
## When the applicant resubmits
Their resubmission is checked like any submission and completes the **same** verification. `verification.completed` (or `verification.failed`) arrives with the same `verificationId` and `attempt` counted up, and [Get verification status](https://trust.myaza.co/documentation/api-verification-status/markdown) reports the new result. You are charged for the steps the applicant redoes, not for the ones they keep.
A link stops working once it has been used, or once something newer replaces it. The applicant sees why when they submit:
| Code | When |
|---|---|
| `verification_in_progress` | A resubmission for this verification is still being checked. |
| `resubmission_superseded` | This verification has already been resubmitted. |
| `resubmission_withdrawn` | The verification was decided, or sent back again, after this link was sent. Only the newest link works. |
## Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_input` | The body is malformed, for example `email` is not a valid address. |
| 400 | `invalid_steps` | A step name is not in the table above. The message lists the valid names. |
| 404 | `verification_not_found` | No verification with that id in your organisation and environment. |
| 409 | `verification_in_progress` | The verification has not finished. A pending check may still come back clean. |
| 422 | `no_workflow` | The verification did not run a workflow, so there is no flow to repeat. Start the person again from your own integration. |
| 422 | `workflow_not_available` | The workflow behind the verification is no longer published, so it cannot be redone. |
| 429 | `rate_limited` | Too many sessions started this hour. Try again shortly. |
Verifying again is allowed from **any** finished status, including a passed verification. Re-verifying someone whose document is about to expire is the same operation as retrying a failure.
---
# Decide a verification
```
POST /api/kyc/verifications/:id/review
```
Approves, declines or sends back a **finished** verification, or changes a decision made earlier. This is the API twin of the review controls in your dashboard. The two run the same core, so a decision made here does exactly what one made there does:
- the person's record moves to approved, rejected or under review
- declining pauses their ongoing screening, and approving resumes it
- the change is added to the verification's timeline in your dashboard, and to `statusHistory` on the [result endpoint](https://trust.myaza.co/documentation/api-verification-result/markdown#after-a-decision)
- your webhook endpoints receive [`verification.status_updated`](https://trust.myaza.co/documentation/webhooks/markdown#status-changes) with `source: "api"`
- the applicant is emailed about it when you ask, or when the workflow does (see [Emailing the applicant](#emailing-the-applicant))
The verification itself is never rewritten. `checkStatus` keeps saying what the checks found, so an approval granted despite a failed check stays visible.
**Authentication:** `Authorization: Bearer sk_…` (**secret key required**). A publishable key ships in a browser, and a browser must never be able to approve its own applicant, so it receives `403 secret_key_required`.
**Content type:** `application/json`.
**Production note:** `sk_live_` keys require an [approved business](https://trust.myaza.co/documentation/environments/markdown#production).
## Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `decision` | string | yes | `APPROVED`, `DECLINED` or `RESUBMISSION`. |
| `note` | string | no | Why, up to 2,000 characters. Kept in your audit log and on the verification's timeline in your dashboard. It is never sent to your webhook endpoints or returned by the API. |
| `steps` | string[] | no | `RESUBMISSION` only. The steps to redo, named as in [Verify again](https://trust.myaza.co/documentation/api-verify-again/markdown#redo-only-what-failed). Absent or empty means the whole flow. |
| `message` | string | no | `RESUBMISSION` only. Shown to the applicant when they open the link, up to 500 characters. Write it for them, not for your logs. |
| `policy` | string | no | `RESUBMISSION` only. `original` runs the workflow version the applicant walked; `latest` runs today's. Absent follows the workflow's own setting. |
| `notifyApplicant` | boolean | no | `APPROVED` or `DECLINED` only. `true` emails the applicant about the decision and `false` does not. Absent follows the workflow: an approval is emailed when its **Email the applicant when approved** setting is on, and a decline never is. |
| `email` | string | no | On a `RESUBMISSION`, send the link to the applicant at this address as well as returning it. On an approval or a decline sent with `notifyApplicant: true`, the address to email instead of the one the verification holds. |
A field sent with a decision it does not apply to is refused with `400 invalid_input` rather than ignored, because a caller who sent it has misunderstood something: `steps`, `message` or `policy` with an approval or a decline, `email` with one that does not also send `notifyApplicant: true`, or `notifyApplicant` with a `RESUBMISSION`.
## Request
```bash
curl "https://trust.myaza.app/api/kyc/verifications/ver_abc123/review" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "decision": "DECLINED", "note": "Duplicate of an existing customer" }'
```
## Response `200 OK`
```json
{
"review": {
"verificationId": "ver_abc123",
"decision": "DECLINED",
"status": "declined",
"previousStatus": "approved",
"checkStatus": "verified",
"reviewedAt": "2026-09-13T09:00:00.000Z",
"entityId": "ent_01j9def789",
"attempt": 1,
"delivery": null,
"applicantEmail": null,
"resubmission": null
}
}
```
| Field | Description |
|---|---|
| `status` | The verification's top-line status now, the same value [Get verification status](https://trust.myaza.co/documentation/api-verification-status/markdown) returns. |
| `previousStatus` | The top-line status immediately before this decision. |
| `checkStatus` | What the checks found. No decision changes it. |
| `entityId` | The person's record the decision was applied to. Null when the checks never created one, for example on a failed check. |
| `attempt` | The attempt the verification holds now. A verification you send back keeps its id, and the applicant's resubmission becomes the next attempt. |
| `delivery` | On a `RESUBMISSION` with `email`: `{ emailed, reason }`. `emailed: false` with `reason: "send_failed"` means the email could not be sent, and the link still works. Null otherwise. |
| `applicantEmail` | On an approval or a decline where emailing the applicant was asked for, by `notifyApplicant` or by the workflow: `{ emailed, reason, recipient }`. `recipient` is `{ masked, source }`: the address masked (`a•••@example.com`) and where it came from, `email_verification`, `sdk`, `entity` or `provided`. When `emailed` is `false`, `reason` says why: `no_recipient` (there was no address to send to) or `send_failed`. The decision stands either way. Null when nobody asked. |
| `resubmission` | On a `RESUBMISSION`, the link to send the applicant: `sessionId`, `url`, `shortCode`, `expiresAt`, `steps` and `full`. `url` is a credential: whoever opens it verifies as this applicant. Null otherwise. |
## Emailing the applicant
We can tell the applicant about an approval or a decline, in your organisation's name:
```json
{ "decision": "APPROVED", "notifyApplicant": true }
```
The email says what was decided and who to contact. It never gives a reason: that is yours to give, in your own words.
We only write to an address the verification already holds, using the first of these that has one:
1. the email the applicant proved they own with a code, when the workflow runs email verification;
2. an email your integration passed, as `userData.email` on the SDK or on a [session you created](https://trust.myaza.co/documentation/api-create-session/markdown) (the address you emailed the session link to counts too);
3. the email on the person's entity record.
To use another address, send it as `email` beside `notifyApplicant: true`. When there is no address, nothing is sent and `applicantEmail.reason` is `no_recipient`.
Leave `notifyApplicant` out and the workflow decides: when it has **Email the applicant when approved** switched on, an approval is emailed however it was made. A decline is only ever emailed when you ask. Every email is added to the verification's timeline in your dashboard.
## Sending it back
```json
{
"decision": "RESUBMISSION",
"steps": ["document-capture"],
"message": "Your document photo was too blurry to read. Please retake it in good light."
}
```
The applicant gets a link on the same workflow and walks only the steps you name. Their resubmission becomes the next attempt of **this** verification, under the same id, so you never have a second verification to reconcile. This is the same operation as [Verify again](https://trust.myaza.co/documentation/api-verify-again/markdown).
Add `"email": "ada@example.com"` and we send the link for you. Otherwise delivering it is yours to do. Either way it is in this response and in the webhook, and any link sent earlier for the same verification stops working.
## Changing a decision
Call it again with a different `decision`. The latest decision is the verification's status, each earlier one stays on record, and every change sends its own `verification.status_updated`. Read the whole trail back with [Get verification result](https://trust.myaza.co/documentation/api-verification-result/markdown#after-a-decision), whose `statusHistory` lists every change; in your dashboard it is on the verification's timeline.
A decision can be made on any finished verification: one your workflow sent for review, one it approved or declined automatically, or one a person has already decided.
## Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_input` | The body is malformed, `decision` is not one of the three values, or a field came with a decision it does not apply to. |
| 400 | `invalid_steps` | A step name is not a valid step. The message lists the valid names. |
| 403 | `secret_key_required` | A publishable key was used. |
| 404 | `verification_not_found` | No verification with that id in your organisation and environment. |
| 409 | `verification_in_progress` | The checks have not finished. Wait for the result before deciding it. |
| 422 | `no_workflow` | A `RESUBMISSION` on a verification that did not run a workflow, so there is no flow to send the applicant back into. Approve or decline it instead. |
| 429 | `rate_limited` | Too many sessions started this hour. A send-back starts one. Try again shortly. |
---
# List verifications
```
GET /api/kyc/verifications
```
Your organisation's verification history, paginated and filterable — reconcile your records without keeping your own ledger of ids. Scoped to the key's environment, so a sandbox key never lists production traffic.
Records you have [deleted](https://trust.myaza.co/documentation/api-manage-records/markdown) are not listed and are not counted in `total`.
**Authentication:** `Authorization: Bearer sk_…` (**secret key required**), like the [per-id result read](https://trust.myaza.co/documentation/api-verification-result/markdown) beside it.
## Query parameters
| Parameter | Description |
|---|---|
| `status` | One value from the [status vocabulary](https://trust.myaza.co/documentation/api-verification-status/markdown) — the same words `/status/:id` and webhooks use, so the filter and the labels can never disagree. An unknown word is a `400 invalid_status` naming the vocabulary. |
| `channel` | `sdk` (capture-flow verifications) or `dashboard` (spot checks). |
| `subjectType` | `individual` or `business`. |
| `country` | ISO-2 country code. |
| `idType` | ID type (or the business product, which rides the same field). |
| `workflowId` | The `wf_…` public id — every submission a workflow drove. |
| `externalUserId` | Your reference for the subject; matches sessions, entities and webhook payloads. |
| `from` / `to` | ISO dates bounding `createdAt`. |
| `page` / `pageSize` | Pagination; `pageSize` max 100, default 25. |
## Response
```json
{
"verifications": [
{
"verificationId": "cmt…",
"requestId": "order_1001",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" },
"channel": "sdk",
"subjectType": "individual",
"status": "approved",
"checkStatus": "verified",
"reasonCode": null,
"country": "NG",
"idType": "bvn",
"workflowId": "wf_AbC123dEf456",
"workflowVersion": 3,
"createdAt": "2026-08-20T12:00:00.000Z",
"completedAt": "2026-08-20T12:00:04.512Z"
}
],
"total": 812,
"page": 1,
"pageSize": 25
}
```
Rows are deliberately light: identifiers and states, no biodata. `metadata` is your own customer metadata echoed unchanged, not something Myaza derived. `status` is the merged top-line (what a decision graph or reviewer concluded); `checkStatus` is what the checks themselves found — approving a failed check (a policy override) stays distinguishable from approving a passing one. The full result, PII included, lives on [`GET /verifications/:id`](https://trust.myaza.co/documentation/api-verification-result/markdown).
Business rows additionally carry `businessName`.
---
# Spot checks
```
POST /api/kyc/checks/individual
POST /api/kyc/checks/business
GET /api/kyc/checks/individual
GET /api/kyc/checks/business
GET /api/kyc/checks/individual/:id
GET /api/kyc/checks/business/:id
```
Run a one-off check without the SDK capture flow: verify a person's ID number against the government database, or a business registration number against the registry. This is the server-to-server counterpart of the dashboard's Spot Checks tabs — the same gates, pricing and sandbox behaviour, and the same history (API checks appear there labelled "API").
**Authentication:** `Authorization: Bearer sk_…` (**secret keys only**, for the whole surface). Results carry PII, so this never belongs in client-side code.
**Production note:** `sk_live_` keys require an [approved business](https://trust.myaza.co/documentation/environments/markdown#production). Sandbox and development checks are free and serve [test data](https://trust.myaza.co/documentation/sandbox-testing/markdown).
Checks run **asynchronously**: the submit returns `202` with a `verificationId` within milliseconds, and the result is ready seconds later on the detail endpoint.
## Individual checks
```
POST /api/kyc/checks/individual
```
| Field | Required | Description |
|---|---|---|
| `country` | yes | `NG`, `GH`, `KE`, `ZA` or `CI` (the government-database markets). |
| `idType` | yes | The ID type, e.g. `bvn`, `nin`, `passport`. See [Countries & ID types](https://trust.myaza.co/documentation/id-types/markdown). |
| `idNumber` | no* | The ID number. Required for number-based IDs; a document ID may instead send the document front for extraction. |
| `userData` | no | Details to validate against the government record: `firstName`, `lastName`, `dateOfBirth`. |
| `mediaIds` | no | Pre-uploaded photos from [`POST /upload`](https://trust.myaza.co/documentation/api-upload/markdown): `documentFront`, `documentBack`, `selfie`. A selfie adds the facial comparison against the record photo. |
| `images` | no | The same three slots **inline** instead of pre-uploaded: each an https URL, a data URI, or base64 — the same forms the [face match](https://trust.myaza.co/documentation/api-face-match/markdown) endpoint takes. Send each slot as `mediaIds` **or** `images`, never both. |
| `consent` | yes | Must be `true`: you attest the person consented to the check. |
| `requestId` | no | Your idempotency key. A retry with the same value returns the same check instead of running (and billing) a second one. Recommended. |
```bash
curl -X POST https://trust.myaza.app/api/kyc/checks/individual \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"country": "NG",
"idType": "bvn",
"idNumber": "12345678901",
"userData": { "firstName": "John", "lastName": "Doe" },
"consent": true,
"requestId": "check-user-42-2026-08-20"
}'
# → 202 { "verificationId": "cmt…", "status": "pending" }
```
Then read the result:
```
GET /api/kyc/checks/individual/:id
```
```json
{
"check": {
"id": "cmt…",
"status": "verified",
"country": "NG",
"idType": "bvn",
"idNumber": "12345678901",
"result": {
"firstName": "JOHN",
"lastName": "DOE",
"dateOfBirth": "1990-01-01",
"dataMatch": true,
"facialMatch": { "match": true, "confidence": 85 }
},
"govRecord": { "…": "customer-safe record extract" },
"media": { "selfie": "https://trust.myaza.app/api/kyc/verifications/…/media/selfie" },
"chargedAmount": "0.0500",
"createdAt": "…",
"completedAt": "…"
}
}
```
`status` is `pending` until processing finishes, then `verified`, `failed`, `not_found` or `error` with `reason` and a stable [`reasonCode`](https://trust.myaza.co/documentation/errors/markdown). `result` is present on `verified` only.
## Business checks
```
POST /api/kyc/checks/business
```
| Field | Required | Description |
|---|---|---|
| `country` | yes | ISO-2 country code (about 48 registry countries are supported). |
| `registrationNumber` | yes | The registration number (e.g. `RC123456`). |
| `registrationName` | no | The registered name, cross-checked against the registry when given. |
| `subdivisionCode` | maybe | ISO 3166-2 region (e.g. `US-DE`) — **required** for the countries whose registry is split by region (US, IN, CA, AE) and rejected elsewhere. |
| `product` | no | The check product; defaults to the standard business check. Nigeria adds tax-related products. |
| `consent` | yes | Must be `true`. |
| `requestId` | no | Your idempotency key, as above. |
| `sandboxOutcome` | no | `verified` or `not_found` — pins the canned result outside production. Silently ignored on live keys. |
The detail (`GET /api/kyc/checks/business/:id`) returns the registry's answer as `businessRecord`: company particulars, status, share capital and key personnel in a customer-safe extract, plus `businessName`, the `product`, and the region the registry was asked about.
## History
```
GET /api/kyc/checks/individual
GET /api/kyc/checks/business
```
Paginated, newest first, scoped to your organisation and the key's environment. Query parameters: `status` (`pending` / `verified` / `failed` / `not_found` / `error`), `country`, `idType` (individual) or `product` (business), `from` / `to` (ISO dates), `page`, `pageSize` (max 100). Rows are light — identifiers, status, cost, timestamps and `initiatedVia` (`api` or `dashboard`) — with the full result on the detail endpoints.
A check you no longer want in your history can be removed with `DELETE /api/kyc/checks/individual/:id` or `DELETE /api/kyc/checks/business/:id`. See [Delete records](https://trust.myaza.co/documentation/api-manage-records/markdown): the delete is soft, and a check that is still running is refused with `409 check_in_progress`.
## Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_input` | The body failed validation, or a slot was sent both as `mediaIds` and `images`. |
| 400 | `product_unsupported` | That product is not offered for the country. |
| 400 | `subdivision_required` / `subdivision_unsupported` / `subdivision_unknown` | The registry-region rule above. |
| 400 | `invalid_image` / `unsupported_media_type` / `image_too_large` / `image_url_not_allowed` / `image_fetch_failed` | An inline image problem — same rules as [face match](https://trust.myaza.co/documentation/api-face-match/markdown). |
| 402 | `insufficient_credits` | Your balance cannot cover the check. |
| 403 | `spot_checks_disabled` / `business_checks_disabled` | The module is not enabled for your organisation. |
| 403 | `id_type_not_enabled` | The ID type is not granted to your organisation, or the required feature is disabled. |
| 403 | `spot_check_unavailable` | The ID type is temporarily switched off for spot checks. |
| 403 | `secret_key_required` | A publishable key was used; this surface is secret-key only. |
| 403 | `business_not_approved` | A live key before KYB approval. |
| 422 | `only_test_ids_allowed` | A sandbox key sent a real ID number; use the published [test IDs](https://trust.myaza.co/documentation/sandbox-testing/markdown). |
| 429 | `rate_limited` | The hourly verification cap was reached; retry later. |
---
# Delete records & expire sessions
Four server-to-server endpoints for tidying your own records: delete a verification or an unfinished attempt, delete a spot check, delete a face check, and end a live verification session before its clock runs out.
**Authentication:** `Authorization: Bearer sk_…` (**secret key required**). A publishable key receives `403 secret_key_required`. Removing records is a backend act, and a publishable key ships in a browser.
**Content type:** `application/json`.
## What a delete does
Deleting is **soft**. The record leaves every surface your organisation can read: the dashboard, the [list](https://trust.myaza.co/documentation/api-list-verifications/markdown), the [result](https://trust.myaza.co/documentation/api-verification-result/markdown) and [status](https://trust.myaza.co/documentation/api-verification-status/markdown) endpoints, the media URLs in earlier webhooks, and your exports. Your analytics stop counting it. Nothing is refunded, because the work it describes was done.
The record is not destroyed. Myaza keeps it, marked as deleted, so a compliance history cannot be silently rewritten. **A deleted record cannot be restored through the API.** If something was deleted by mistake, contact support; a restore arrives as a `verification.restored` or `entity.restored` [webhook](https://trust.myaza.co/documentation/webhooks/markdown).
A verification that is still `processing` cannot be deleted. Its checks may still come back, and a system error may still refund the charge. Wait for the result.
## Delete a verification or attempt
```
DELETE /api/kyc/verifications/:id
```
`:id` is the id you already hold. A verification adopts its session's id, so this works from the moment a session is created: a finished verification is deleted, and a session nobody has submitted is deleted as an attempt.
```bash
curl -X DELETE "https://trust.myaza.app/api/kyc/verifications/ver_abc123" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Test data from the integration rehearsal" }'
```
| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | no | Up to 500 characters, kept in your organisation's audit log. It is never sent to your webhook. |
### Response `200 OK`
```json
{
"verificationId": "ver_abc123",
"kind": "verification",
"deleted": true,
"deletedAt": "2026-09-05T10:12:00.000Z"
}
```
`kind` is `verification` for a submitted record and `attempt` for a session that never submitted. Your endpoints receive `verification.deleted` with the same two fields.
## Delete a spot check
```
DELETE /api/kyc/checks/individual/:id
DELETE /api/kyc/checks/business/:id
```
The same body and the same soft-delete semantics, for the [spot checks](https://trust.myaza.co/documentation/api-spot-checks/markdown) run server to server or from the dashboard. Spot checks never fire webhooks, so nothing is sent when one is deleted.
```json
{ "id": "chk_9f1…", "deleted": true, "deletedAt": "2026-09-05T10:12:00.000Z" }
```
## Delete a face check
```
DELETE /api/kyc/face/checks/:id
```
`:id` is the `faceCheck.id` returned by [`POST /api/kyc/face/compare`](https://trust.myaza.co/documentation/api-face-match/markdown) or listed by `GET /api/kyc/face/checks`. A face check is synchronous, so there is no processing state to wait for: any check you can read can be deleted. The photos stay under their existing retention policy, and nothing is refunded.
```bash
curl -X DELETE "https://trust.myaza.app/api/kyc/face/checks/fc_abc123" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Uploaded the wrong photo pair" }'
```
| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | no | Up to 500 characters, kept in your organisation's audit log. |
### Response `200 OK`
```json
{
"id": "fc_abc123",
"deleted": true,
"deletedAt": "2026-09-05T10:12:00.000Z"
}
```
Face checks never send webhooks, so no event follows. A deleted check disappears from `GET /api/kyc/face/checks`, cannot be re-read at `/checks/:id`, and is no longer offered as a rerun source in the dashboard.
## Expire a session
```
POST /api/kyc/sessions/:sessionId/expire
```
Ends a live session early. The link stops working at once, the applicant cannot continue, and anything they had entered is discarded. Your endpoints receive `session.expired` with the next `sequence` number, exactly as when a session times out on its own, so nothing about your webhook handling changes. Use it when a link went to the wrong person, or when you have replaced it with a new one.
```bash
curl -X POST "https://trust.myaza.app/api/kyc/sessions/cmt1gfjce05g547grqbmjsiqf/expire" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY"
```
### Response `200 OK`
```json
{
"sessionId": "cmt1gfjce05g547grqbmjsiqf",
"status": "expired",
"changed": true,
"expiresAt": "2026-09-05T10:12:00.000Z"
}
```
`changed` is `false` when the session had already ended on its own; the call is safe to repeat. A session the applicant has submitted cannot be expired, because it has a verification now.
## Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_input` | The reason is longer than 500 characters. |
| 404 | `verification_not_found` / `not_found` / `session_not_found` | Nothing with that id in your organisation and environment. A record that is already deleted answers the same way. |
| 409 | `verification_in_progress` / `check_in_progress` | Still processing. Wait for the result before deleting it. |
| 409 | `session_already_submitted` | The applicant finished; there is nothing left to expire. |
| 403 | `secret_key_required` | A publishable key was used. |
---
# Email & SMS verification
```
POST /api/kyc/contact/verifications
POST /api/kyc/contact/verifications/:id/check
GET /api/kyc/contact/verifications/:id
```
Prove that someone controls an email address or a phone number. Send a one-time code, then check the code they typed. Both calls answer synchronously, so there is nothing to poll and no webhook to wait for.
This is the server-to-server form of the contact step the SDK runs inside a verification flow. Use it when you want the possession check on its own: at sign-up, before a payout, when a customer changes their number.
**Authentication:** `Authorization: Bearer sk_…` (secret keys only). The response describes a real person's contact details and the call spends your credits on a message, so it never belongs in client-side code.
**Production note:** `sk_live_` keys require an [approved business](https://trust.myaza.co/documentation/environments/markdown#production).
## Send a code
```
POST /api/kyc/contact/verifications
```
| Field | Required | Description |
|---|---|---|
| `channel` | yes | `email` or `phone`. |
| `destination` | yes | The address or number to verify. A phone number in national format resolves against `country`; one written with a dial code resolves on its own. |
| `country` | no | ISO-2. Resolves national phone formats, and selects the price row for markets you have priced separately. It is stored on the challenge and reused when the check is charged, so the amount you are quoted here is the amount you are billed. |
| `via` | no | `sms` (default) or `whatsapp`. WhatsApp is accepted and currently delivers by SMS; the response says which channel actually carried the code. |
| `codeLength` | no | Digits in the code, 4 to 8. Default 6. |
| `maxAttempts` | no | Wrong codes allowed before the challenge dies, 1 to 5. Default 3. |
| `externalUserId` | no | Your own reference for this person. When you already hold a compliance entity under that reference, a verified contact is recorded against it as proved evidence. |
```bash
curl -X POST https://trust.myaza.app/api/kyc/contact/verifications \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"channel": "phone",
"destination": "08031234567",
"country": "NG",
"externalUserId": "user_8f21"
}'
```
```json
{
"contactVerification": {
"id": "clx8s0a4p0001qw9k2m7f3xyz",
"status": "pending",
"channel": "phone",
"destination": "+2348031234567",
"deliveryChannel": "sms",
"country": "NG",
"signals": {
"country": "NG",
"valid": true,
"callingCode": "234",
"lineType": "mobile",
"virtual": false
},
"externalUserId": "user_8f21",
"attemptsUsed": 0,
"chargedAmount": null,
"verifiedAt": null,
"expiresAt": "2026-09-12T10:05:00.000Z",
"createdAt": "2026-09-12T10:00:00.000Z"
}
}
```
`destination` comes back normalised: an email is lowercased, a phone number is E.164. Store that form, because it is what the check and the entity record use.
Codes are valid for five minutes.
## Check the code
```
POST /api/kyc/contact/verifications/:id/check
```
| Field | Required | Description |
|---|---|---|
| `code` | yes | The code the person typed. |
```bash
curl -X POST https://trust.myaza.app/api/kyc/contact/verifications/clx8s0a4p0001qw9k2m7f3xyz/check \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "code": "418305" }'
```
```json
{
"contactVerification": {
"id": "clx8s0a4p0001qw9k2m7f3xyz",
"status": "verified",
"channel": "phone",
"destination": "+2348031234567",
"deliveryChannel": "sms",
"country": "NG",
"signals": { "country": "NG", "valid": true, "callingCode": "234", "lineType": "mobile", "virtual": false },
"externalUserId": "user_8f21",
"attemptsUsed": 1,
"chargedAmount": "0.0500",
"verifiedAt": "2026-09-12T10:01:12.000Z",
"expiresAt": "2026-09-12T10:05:00.000Z",
"createdAt": "2026-09-12T10:00:00.000Z"
}
}
```
A wrong code answers `400 invalid_code` with `attemptsRemaining`, so you can tell the person how many tries are left. When the budget runs out the challenge is dead and you send a new code.
Re-checking a verified challenge is safe. It returns the same record and does not charge again.
## Read a challenge
```
GET /api/kyc/contact/verifications/:id
```
The same record, at any point in its life. `status` is `pending`, `verified` or `expired`. Useful for reconciling your own state after a dropped response.
## What the signals tell you
Every challenge carries the intelligence computed when the code was sent, at no extra cost. Branch on it however your risk policy needs.
**Email**
| Field | Meaning |
|---|---|
| `domain` | The mail domain. |
| `disposable` | The domain is a known throwaway mailbox service. |
| `freeProvider` | A consumer email service such as Gmail or Outlook. |
| `mxFound` | The domain publishes mail servers. `null` means the lookup could not answer, which is not the same as no. |
**Phone**
| Field | Meaning |
|---|---|
| `country` | ISO-2 the number belongs to, from its dial code. |
| `callingCode` | Country calling code, digits only. |
| `valid` | The number is a possible, valid number for that country. |
| `lineType` | `mobile`, `fixed_line`, `voip`, `toll_free` and so on. `null` when the range cannot be classified. |
| `virtual` | `true` only for a known VOIP range, `false` for any other known type, `null` when the type is unknown. Fire rules on `true`, never on a gap. |
## Pricing
A check bills the same component your workflows already bill for the same work: `email_verification` or `phone_verification` on the `sdk_verification` module. One rate card governs both doors, and Billing in your dashboard shows the rate.
Three rules follow from that:
- **A code that is never checked costs nothing.** Billing happens when the check completes and returns an answer, not when the message goes out.
- **We refuse before we send.** If your balance cannot cover the check the send answers `402 insufficient_credits` with the required amount, rather than spending a message you cannot pay for.
- **Sandbox and development are free**, and `chargedAmount` is `null` there.
`chargedAmount` on a completed check is what it cost, in USD, as a decimal string.
## Sandbox
On `sk_test_` and `sk_dev_` keys nothing is sent to anyone. The code is all zeros at the length you asked for, so `000000` by default, `0000` at `codeLength: 4`. `deliveryChannel` comes back as `test`. Everything else behaves exactly as it does live, so you can build and test the whole flow at no cost.
## Rate limits
Two caps protect your account and ours, both counted per rolling hour:
- **Five codes per destination.** Hammering one number is stopped before it starts.
- **A per-organisation ceiling across all destinations**, 500 by default. This is what bounds a leaked key rotating through numbers. Contact support if your volume needs more.
Over either cap, the send answers `429 send_rate_limited`.
## Using a verified contact in a KYC flow
A contact proved here is deliberately **not** carried into a `POST /verify` submission. That submission prices its own contact steps, so honouring a proof from this API would bill the same check twice. If you want the contact verified as part of a verification, use the SDK's contact step, which is configured per workflow.
What this API does record, when you send an `externalUserId` and an entity already exists under it, is the proved contact on that entity's [compliance record](https://trust.myaza.co/documentation/identity-hub/markdown) as `otp_verified` evidence, which is the strongest source there is. It never creates an entity that does not exist.
## Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_request` | The body failed validation. `message` says what. |
| 400 | `invalid_destination` | The phone number could not be parsed. Send `country` for national formats. |
| 400 | `invalid_code` | Wrong code. `attemptsRemaining` says how many tries are left. |
| 402 | `insufficient_credits` | Balance too low for the check. `required` and `balance` are decimal strings. |
| 403 | `secret_key_required` | A publishable key was used. This surface is secret keys only. |
| 403 | `business_not_approved` | Production access needs an approved business. |
| 404 | `challenge_not_found` | Unknown id, or one that belongs to another organisation, another environment, or the SDK's contact step rather than this API. |
| 410 | `challenge_expired` | The five-minute window closed. Send a new code. |
| 429 | `send_rate_limited` | A send cap was reached. See Rate limits. |
| 429 | `too_many_attempts` | The attempt budget is spent. Send a new code. |
| 502 | `send_failed` | The message could not be handed to its delivery channel. Nothing was charged. |
---
# Verification lifecycle
A verification is **asynchronous**. Creating one returns instantly with `processing`; the platform then runs the checks, a [decision graph](https://trust.myaza.co/documentation/decisioning/markdown) may route it to a person, and it settles. You learn the outcome via [webhooks](https://trust.myaza.co/documentation/webhooks/markdown) or [polling](https://trust.myaza.co/documentation/api-verification-status/markdown).
## States
Every surface reports state with the same vocabulary, and each row carries two fields: `status` (what happened) and `checkStatus` (what the checks found). They match unless a person overrode the automated result.
```
not_started ──► in_progress ──► processing ──┬──► approved
│
├──► declined
│
├──► in_review ──┬──► approved
│ ├──► declined
│ └──► awaiting_resubmission ──► processing
│
└──► error
not_started ──► expired (nobody ever opened the link)
in_progress ──► abandoned (opened, then left unfinished)
```
| Status | Meaning | Settled? |
|---|---|---|
| `not_started` | The link exists but nobody has opened it. | No |
| `in_progress` | The applicant is part-way through. | No |
| `processing` | Submitted. Checks are running, or a decision has not landed. | No |
| `in_review` | Your workflow asked a person to decide this one. | No |
| `awaiting_resubmission` | A reviewer sent it back to redo some steps. | No |
| `approved` | Accepted, automatically or by a person. | Yes |
| `declined` | Rejected, automatically or by a person. | Yes |
| `abandoned` | Opened, then left unfinished past its deadline. | Yes |
| `expired` | Timed out without ever being opened. Send a new link. | Yes |
| `error` | A fault on our side. You were not charged. | Yes |
`checkStatus` is the narrower question of what the checks themselves found: `pending`, `verified`, `failed`, `not_found` or `error`. It never moves once they finish.
A verification whose checks did not pass always carries a human-readable `reason` (a full sentence, safe to show a user) **and** a stable `reasonCode` you can branch on. Both stay populated even if a reviewer later approves it, because they describe what was overridden. See [failure reason codes](#failure-reason-codes) below.
> **An ID the government database does not hold is a `declined`**, not a status of its own. `reasonCode: "identity_not_found"` is what distinguishes it from a failed face match, which is the level of detail worth branching on.
## Failure reason codes
`reasonCode` is a stable machine token paired with `reason` on every non-success outcome. Branch on the code to drive tailored messaging or retry logic. It appears in [status](https://trust.myaza.co/documentation/api-verification-status/markdown) responses and [webhook](https://trust.myaza.co/documentation/webhooks/markdown) payloads (`data.reasonCode`).
**`reason` is written for the applicant, on every `failed` and `not_found` outcome.** It is plain language you can forward to the person as it stands: it names what did not match and what to do next, and it deliberately carries no score, no threshold and nothing read back out of the government record. The numbers are not lost, they are structured fields on the result (`facialConfidence`, `dataValidationDetails`) where your support team reads them.
**`reason` on an `error` outcome is written for YOU, not for them.** Those say things like "top up your balance" or "contact Myaza", because nothing the applicant does will fix a verification service outage or an empty credit balance. Show the person a generic "something went wrong, please try again" and route the `reason` to your own team. The `checkStatus` column below tells the two apart: `error` is ours, `failed` and `not_found` are theirs.
The middle column is `checkStatus`, not `status`: a reason code describes what the CHECKS found, which is why it survives a reviewer later approving the verification.
New codes may be added over time, so **treat an unrecognised code as a generic failure**.
| `reasonCode` | `checkStatus` | Meaning | Retry? |
|---|---|---|---|
| `document_unreadable` | `failed` | No text could be read from the document photo. | New photo |
| `document_blurry` | `failed` | The document photo was too blurry to read. | New photo |
| `document_type_mismatch` | `failed` | The document doesn't match the selected ID type. | Fix input |
| `id_number_not_found` | `failed` | No ID number could be read from the document. | New photo |
| `document_data_mismatch` | `failed` | Submitted name/DOB don't match the document. | Fix input |
| `document_expired` | `failed` | The document has expired. | New document |
| `selfie_mismatch` | `failed` | The selfie doesn't match the photo it was compared with: the government record, the chip, or the photo printed on the document (`facialMatchSource` says which). | New selfie |
| `document_photo_no_face` | `failed` | The photo printed on the document was the only photo the selfie could be compared with, and no face could be seen on it. Your workflow declines that case rather than sending it to review. | New photo |
| `gov_data_mismatch` | `failed` | Submitted details don't match the government record. | Fix input |
| `gov_validation_failed` | `failed` | The government database flagged the ID as failed. | No |
| `identity_not_found` | `not_found` | The ID number wasn't found in the government database. | Fix input |
| `age_restricted` | `failed` | The holder's age is outside the workflow's age limits for their country, and the workflow declines that case rather than sending it to review. | No |
| `age_unverified` | `failed` | The workflow restricts by age, but no date of birth could be read from the ID. | New photo or ID |
| `unsupported_id_type` | `error` | This ID type isn't supported for the country. | No |
| `provider_error` | `error` | A temporary verification service error occurred; not charged. | Yes |
| `media_not_found` | `error` | An uploaded file expired before processing; not charged. | Yes |
| `insufficient_credits` | `error` | Your credit balance was too low; not charged. Only reached when credit pause is not active for your organisation, otherwise the verification pauses instead. | After top-up |
| `credit_hold_expired` | `error` | A paused verification reached the end of its retention window before funding arrived; the captured documents were deleted. | New capture |
| `system_error` | `error` | An unexpected server error occurred; not charged. | Yes |
| `id_type_not_enabled` | `error` | This ID type isn't enabled for your organisation. | No |
| `document_verification_disabled` | `error` | Document verification is disabled for your organisation. | No |
| `gov_db_check_disabled` | `error` | Government database checks are disabled. | No |
| `sandbox_not_found` | `not_found` | Sandbox test ID returned the canned `not_found` outcome. | — |
| `sandbox_failed` | `failed` | Sandbox test ID returned the canned `failed` outcome. | — |
> When more than one check fails (e.g. selfie **and** data), `reasonCode` reflects the primary failure (the selfie, before the data) while `reason` names every leg that failed in one sentence. Retries must reuse the same [`requestId`](#idempotency).
## End-to-end flow
1. **Configure**: `GET /config` to learn enabled ID types and their features.
2. **Capture & upload**: for document/liveness flows, `POST /upload` each file → `mediaId`.
3. **Create**: `POST /verify` with `country`, `idType`, optional `idNumber`, `userData`, `mediaIds`, and a unique `metadata.requestId`. Returns `202` + `verificationId`, status `pending`.
4. **Settle**: the platform fires `verification.started`, then exactly one terminal event (`verification.completed`, `.failed`, `.not_found`, or `.error`).
5. **Reconcile**: update your records from the webhook (or a `GET /status/:id` poll).
## Idempotency
`metadata.requestId` is your idempotency key. Submitting the same `requestId` again returns the existing verification rather than creating a new one (and never double-charges). Always:
- Generate one stable `requestId` per logical verification.
- Reuse it verbatim on any retry (timeout, `5xx`, `429`).
See [create verification](https://trust.myaza.co/documentation/api-create-verification/markdown#idempotency).
## Designing your handler
- **Treat results as eventual.** Never block a user flow on a synchronous result; show a "verifying…" state and resolve on webhook/poll.
- **Be idempotent on your side too.** The same terminal event may be delivered more than once; key your processing on `verificationId`.
- **Branch on `status`, store `checkStatus` alongside it.** `approved` → pass; `declined` → reject or request a retry; `error` → retry or escalate; `in_review` and `awaiting_resubmission` → keep waiting, a person is involved. Storing `checkStatus` too is what lets you answer why an exception was allowed.
- **Branch on `reasonCode`** to tailor the user's next step (e.g. "your ID has expired" on `document_expired`), or show the `reason` as it stands on a `failed` or `not_found` outcome. On an `error` outcome the `reason` is addressed to you, so show the person a generic message instead. See [failure reason codes](#failure-reason-codes).
## Data & privacy notes
- ID numbers are stored as plaintext (`idNumber`) so they appear unmasked in your dashboard and in [webhook](https://trust.myaza.co/documentation/webhooks/markdown) payloads (`data.idNumber`). The only place they're masked is the [`GET /status/:id`](https://trust.myaza.co/documentation/api-verification-status/markdown) response, which returns `result.idNumberMasked` (e.g. `1234•••901`); that endpoint is designed for safe polling from frontends, so it never echoes the full number back to whoever holds the API key.
- If an ID type or feature is not enabled for your org, the attempt is recorded as `error` **without** persisting the submitted PII, and any uploaded media is discarded immediately.
## Location evidence and the verification map
When device or IP-location evidence is available, the verification detail page shows the masked IP, approximate place and a map when valid coordinates are available. A map is intentionally not shown when the result contains only a country, city or IP address without usable coordinates; the page explains the missing evidence instead of placing a misleading marker.
The same observation is connected to the verified person or business under **Entities → Connected records → Locations & IPs**. This gives investigators one history across identity verification and later monitored activity. The entity view groups repeated observations, keeps source and enrichment provenance, labels test evidence, and highlights useful signals such as new IP, rapid location change, shared IP or datacentre use.
Locations are approximate risk evidence, not proof of where a person was physically present. IP addresses stay masked by default; revealing a full address requires sensitive-case permission and creates an audit record.
---
# Countries & ID types
When creating a verification you pass a `country` and an `idType`. Coverage comes in two tiers:
- **Government-database markets**: `NG`, `GH`, `KE`, `ZA`, `CI`. Full catalogue, **including number-only IDs** (BVN, NIN, …), validated against the source government database. Strongest assurance.
- **Global documents**: **any other ISO-3166 country** verifies via document capture (Document Intelligence): `passport`, `drivers-license`, and `national-id`. No government database sits behind these, so they reach the `document` assurance tier (a passport can still reach the stronger `chip` tier via its NFC chip).
For the full geographic list, including the 48 [business (KYB)](https://trust.myaza.co/documentation/business-verification/markdown) registry countries, see [Supported countries](https://trust.myaza.co/documentation/countries/markdown).
> The **authoritative** list for your organisation comes from [`GET /config`](https://trust.myaza.co/documentation/api-config/markdown); it returns only the countries and ID types **enabled for your account** and their feature flags. New countries and ID types reach your organisation only when Myaza grants them. Use the catalogue below for reference; use `/config` at runtime.
> **SDK availability.** Global-document countries are available via the API and **all three SDKs** (web, React Native, Flutter). See [Feature availability](https://trust.myaza.co/documentation/sdks/markdown#feature-availability).
## Assurance levels
Every passing verification carries an **`assuranceLevel`** telling you *how strongly* the identity was proven, surfaced on the [status](https://trust.myaza.co/documentation/api-verification-status/markdown) response, the [webhook](https://trust.myaza.co/documentation/webhooks/markdown) payload, and workflow [decisioning](https://trust.myaza.co/documentation/decisioning/markdown):
| `assuranceLevel` | How it passed |
|---|---|
| `chip` | The document's [NFC chip](https://trust.myaza.co/documentation/nfc-chip/markdown) (eMRTD) was read and passively authenticated. Strongest. |
| `gov_db` | Matched against the source government database (the five markets below). |
| `document` | Verified from the document alone (Document Intelligence): the global-document tier. |
Branch on it to, say, auto-approve `chip` and route `document`-only to review.
## Government-database markets
| Country | `idType` values |
|---|---|
| `NG` Nigeria | `bvn`, `bvn-premium`, `nin`, `vnin`, `drivers-license`, `passport`, `pvc` |
| `GH` Ghana | `ghana-card`, `voters`, `drivers-license`, `ssnit`, `passport` |
| `KE` Kenya | `national-id`, `passport` |
| `ZA` South Africa | `national-id`, `passport` |
| `CI` Côte d'Ivoire | `cni`, `residence-card` |
ID type identifiers are lowercase and hyphenated (e.g. `drivers-license`, not `drivers_license`).
## Global documents (any other country)
For any ISO-3166 country outside the five above, pass its two-letter code with one of the generic document types:
| `idType` | Capture | Chip (NFC) |
|---|---|---|
| `passport` | Front only (MRZ) | ✅ eMRTD, reaches `chip` assurance |
| `drivers-license` | Front **and** back | — |
| `national-id` | Front **and** back | — |
> A country whose national ID is already covered by a database-backed key uses **that** instead of the generic `national-id`: `NG` → `nin`, `GH` → `ghana-card`, `CI` → `cni`. Kenya and South Africa's `national-id` *is* the database-backed key.
## Capture requirements
ID types fall into two groups, which determines what you must capture and upload.
### Number-only IDs
Verified from the ID number alone; no document scan required. You may still run a liveness/selfie step if enabled.
| Country | Number-only IDs |
|---|---|
| `NG` | `bvn`, `bvn-premium`, `nin`, `vnin` |
For these, send `idNumber` and (optionally) a `selfie` / `livenessVideo` media ID.
### Document IDs
Every other ID type requires a document scan. Some need both sides:
| Capture | IDs |
|---|---|
| Front only | `passport` (every country) · all document IDs not listed below |
| Front **and** back | Global `drivers-license`, `national-id` · `NG`: `drivers-license`, `pvc` · `GH`: `ghana-card`, `voters`, `drivers-license` · `KE`/`ZA`: `national-id` · `CI`: `cni`, `residence-card` |
For document IDs, [upload](https://trust.myaza.co/documentation/api-upload/markdown) the relevant files (`documentFront`, `documentBack`, `selfie`, …) and reference their `mediaId`s in the verify request.
## Feature flags
Each enabled ID type from [`GET /config`](https://trust.myaza.co/documentation/api-config/markdown) carries three feature flags:
| Feature | Meaning |
|---|---|
| `documentVerification` | A document image is captured and checked. |
| `livenessCheck` | A selfie/liveness step runs and is face-matched to the document. |
| `govDbCheck` | The ID is validated against the government database. **Always `false` for global-document countries** (no live government source is available there), so those verify by document capture alone. |
Branch your capture flow on these flags so you never ask the user for something the org hasn't enabled. Manage which ID types are enabled under **Settings → Organization → ID Types**.
---
# NFC chip verification
Modern passports (every country) and some national ID cards carry an **eMRTD chip** following the ICAO 9303 standard. On NFC-capable phones, the mobile SDKs read the chip over its secure channel and submit the data with the verification. The server then runs **passive authentication**: it re-computes each data group's hash against the chip's signed security object and verifies the issuing state's Document Signer signature. The client never decides authenticity; a chip that positively fails authentication is proven tampered and the verification fails with `chip_not_authentic`.
A chip-verified document earns the strongest [assurance level](https://trust.myaza.co/documentation/id-types/markdown#assurance-levels), `chip`.
> **Native SDKs only.** Reading a chip requires low-level NFC access (ISO-DEP) that only native apps get, so chip verification is available in the **[React Native](https://trust.myaza.co/documentation/sdk-react-native/markdown)** and **[Flutter](https://trust.myaza.co/documentation/sdk-flutter/markdown)** SDKs and is **not supported in the web SDK**: no browser API can talk to a passport chip. Web integrations that want `chip` assurance should route users through one of the mobile SDKs. Phones without an NFC radio skip the step automatically, and skipping never fails a verification.
## Supported documents
Chip capability is a property of the document, not the country: **a passport reads everywhere**, while national identity cards depend on whether that country issues an ICAO-compliant chip card. Driver's licences never carry a readable chip. The authoritative list for your account is [`GET /config`](https://trust.myaza.co/documentation/api-config/markdown), whose ID types each carry `supportsNfc`.
```nfc-countries
```
Some well-known ID cards carry chips that are **not** ICAO eMRTDs and cannot be read this way, by any vendor: the South African smart ID card, the Emirates ID, Malaysia's MyKad, and the smart cards of Singapore, Hong Kong, Japan and China all use proprietary chip applications. Those documents still verify through Document Intelligence; they simply cannot reach `chip` assurance.
Three things to know:
- The chip's secure channel is unlocked with a key derived from the document's **MRZ** (BAC or PACE), so the chip step always runs after the document scan that reads it.
- **Chip capability follows each document's current issuance generation.** Older chipless generations still in circulation pass through unaffected: chip presence is a bonus signal, and a chipless card simply completes on the document checks at the assurance tier it can reach.
- Which chip-capable IDs actually offer the step, whether users may skip it manually, and whether the chip portrait is face-matched are all configured per workflow in the builder's NFC panel.
## Data extraction
The system reads and verifies the following data groups:
| Data group | Contents | What we do |
|---|---|---|
| `SOD` | Document Security Object | Read in full and verified server-side: the signed hash of every data group plus the Document Signer signature, optionally chained to the issuing state's CSCA certificate. This is what makes everything else trustworthy. |
| `DG1` | Personal data (MRZ) | Name, document number, nationality, birth date, expiry date. Hash-verified against the SOD, every ICAO check digit validated, and cross-checked against the details submitted with the verification. |
| `DG2` | Facial image | The government-issued portrait stored on the chip. Hash-verified against the SOD and, on document-only verifications, face-matched against the live selfie. |
| `DG7` | Signature | The holder's displayed signature image. Read when the chip carries one and hash-verified against the SOD. |
| `DG11` | Additional personal details | Full name, personal number, full date of birth, place of birth, address, telephone and profession, where the issuer populated them. Hash-verified against the SOD. |
| `DG12` | Additional document details | Issuing authority and date of issue. Hash-verified against the SOD. |
Two honesty notes, because chip capabilities are widely overstated:
- **DG3 (fingerprints) and DG4 (iris) cannot be read by any commercial system.** They sit behind Extended Access Control: only government inspection systems holding terminal-authentication certificates issued by each state can decrypt them. A vendor claiming to extract fingerprints or iris scans from a passport chip is describing something the standard does not permit them to do.
- **DG7, DG11 and DG12 are optional by standard.** Many issuers do not write them at all, so their absence is normal and never a risk signal. The SDK checks the chip's own table of contents (EF.COM) and reads only the groups the document declares.
## Where the results surface
The full result lands on the verification record and the [webhook](https://trust.myaza.co/documentation/webhooks/markdown) payload as `data.nfc`:
- `status` (`verified` / `not_authentic` / `data_mismatch` / `inconclusive` / `unreadable`), `chipAuthentic`, `dataMatch`, `checkDigitsValid` and the parsed `mrz`.
- `sod`, `dg2`, `dg7`, `dg11`, `dg12` sub-objects, each carrying `hashValid`: whether that group's bytes match the signed hash in the SOD. Parsed DG11/DG12 fields are included alongside, with `hashValid` telling you how much to trust them.
- On document-only verifications, `dg2.faceMatch` and `dg2.faceMatchConfidence` report the live selfie against the authenticated chip portrait.
In workflow [decisioning](https://trust.myaza.co/documentation/decisioning/markdown) you can branch on `verification.nfcStatus`, `verification.nfcAuthentic`, `verification.nfcDataMatch`, `verification.nfcFacialMatch` and `verification.assuranceLevel`.
## Behaviour and availability
- A missing, unreadable or inconclusive chip **never fails a verification**; it simply falls back to the document checks. Only a proven-tampered chip (`not_authentic`) hard-fails.
- The optional detail groups (DG7/DG11/DG12) are read by the React Native SDK today, with Flutter following; the core groups (DG1, DG2, SOD) are read by both mobile SDKs.
- On [sandbox keys](https://trust.myaza.co/documentation/sandbox-testing/markdown), a chip payload on a test ID returns a canned authentic result, so you can exercise the full contract at no cost.
---
# Supported countries
Coverage depends on what you're verifying: a **person** (KYC) or a **business** (KYB).
> Your organisation's *actual* availability is gated by the grants Myaza enables for you. For KYC, [`GET /config`](https://trust.myaza.co/documentation/api-config/markdown) returns your enabled countries and ID types at runtime; for KYB, the [business verification](https://trust.myaza.co/documentation/business-verification/markdown) flow exposes the registry countries you can use.
## Individual verification (KYC)
Two tiers, with full detail (ID types, capture, assurance) in [Countries & ID types](https://trust.myaza.co/documentation/id-types/markdown):
- **Government-database markets**: validated against the source government database, the strongest assurance. These support **number-only IDs** (BVN, NIN, …) as well as document types:
🇳🇬 Nigeria (`NG`) · 🇬🇭 Ghana (`GH`) · 🇰🇪 Kenya (`KE`) · 🇿🇦 South Africa (`ZA`) · 🇨🇮 Côte d'Ivoire (`CI`)
- **Global documents**: **any other ISO-3166 country** verifies by document capture (`passport`, `drivers-license`, `national-id`). Passports can reach the stronger `chip` assurance tier via NFC. No government-database check runs outside the five markets above.
## Business verification (KYB)
Businesses are verified against the official company registry by registration number, available in **48 countries** (fetched live from registry sources; the list below is current coverage). See [Business verification](https://trust.myaza.co/documentation/business-verification/markdown) for how to run one.
**Africa**: 🇨🇮 Côte d'Ivoire · 🇰🇪 Kenya · 🇾🇹 Mayotte · 🇲🇦 Morocco · 🇳🇬 Nigeria · 🇿🇦 South Africa · 🇹🇿 Tanzania · 🇹🇳 Tunisia
**Europe**: 🇧🇾 Belarus · 🇧🇪 Belgium · 🇧🇬 Bulgaria · 🇨🇾 Cyprus · 🇨🇿 Czechia · 🇩🇰 Denmark · 🇪🇪 Estonia · 🇫🇮 Finland · 🇫🇷 France · 🇩🇪 Germany · 🇬🇷 Greece · 🇬🇬 Guernsey · 🇮🇪 Ireland · 🇱🇻 Latvia · 🇲🇹 Malta · 🇲🇩 Moldova · 🇳🇱 Netherlands · 🇳🇴 Norway · 🇵🇱 Poland · 🇷🇴 Romania · 🇸🇰 Slovakia · 🇸🇮 Slovenia · 🇨🇭 Switzerland · 🇺🇦 Ukraine · 🇬🇧 United Kingdom
**Asia & Middle East**: 🇮🇳 India · 🇮🇱 Israel · 🇯🇵 Japan · 🇰🇿 Kazakhstan · 🇳🇵 Nepal · 🇸🇦 Saudi Arabia · 🇹🇷 Turkey · 🇦🇪 United Arab Emirates · 🇺🇿 Uzbekistan
**Americas**: 🇦🇷 Argentina · 🇨🇦 Canada · 🇵🇷 Puerto Rico · 🇺🇸 United States
**Oceania**: 🇦🇺 Australia · 🇳🇿 New Zealand
> Nigeria additionally supports Tax-ID and TIN lookups; every listed country supports the standard registration-number lookup. See [Business verification → Products](https://trust.myaza.co/documentation/business-verification/markdown#products).
---
# Business verification (KYB)
KYB (Know Your Business) verifies a **company** instead of a person. It looks the business up in the official company registry by registration number and, optionally, collects company documents, compliance declarations, and runs **customer due diligence (CDD)** on the people who own or control it (directors, beneficial owners, the applicant).
Available in 48 countries. See [Supported countries](https://trust.myaza.co/documentation/countries/markdown#business-verification-kyb).
> KYB is enabled per organisation. If business verification isn't switched on for your account, ask Myaza to enable it.
## How to run a KYB verification
A live business verification **requires a published KYB [workflow](https://trust.myaza.co/documentation/workflows/markdown)**: the workflow defines what you verify (country, product, application steps, and the decision graph). You run it exactly like individual KYC, two ways:
1. **Embed**: mount an [SDK](https://trust.myaza.co/documentation/sdks/markdown) with the KYB workflow's `workflowId`. The SDK collects the registration number (and any application steps you configured) and submits.
2. **Hosted link**: send the workflow's [hosted link](https://trust.myaza.co/documentation/workflows/markdown#2-share-a-hosted-link); the business completes it in the browser, no integration.
Build the workflow under **Dashboard → Workflows** (choose **Business** when creating it) and start from a [template](https://trust.myaza.co/documentation/workflows/markdown#templates): **Standard KYB**, **KYB + AML screening**, or **Enhanced KYB with declarations**.
## Products
Each verification runs a **product** (what gets looked up):
| Product | Availability | Input |
|---|---|---|
| `business` | Every supported country | Registration number |
| `business-tax` | Nigeria | Registration number (returns the business + its tax ID) |
| `business-taxid` | Nigeria | Registration number |
| `business-tin` | Nigeria | TIN |
The default is `business`: a registry lookup by registration number.
## The application (optional layers)
A registry lookup proves a business *exists*. It doesn't prove the submitter controls it. A KYB workflow can therefore collect a full application; each layer is a toggle in the builder:
- **Company profile**: registered address, business email, phone, website.
- **Company documents**: certificate of incorporation, MEMART, proof of address, etc., OCR-read and cross-checked against the registry record.
- **Key people (CDD)**: the directors and beneficial owners (UBOs) discovered from the registry are **screened** (sanctions / PEP / adverse media) and can be required to complete their **own KYC** via a per-person invite link. Corporate shareholders are recognised and their ownership chains can be followed to the people behind them. See [Key people & ownership](https://trust.myaza.co/documentation/key-people/markdown).
- **Applicant verification**: the person filling in the form verifies their own identity in-flow and declares their role.
- **Declarations**: a compliance [questionnaire](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) (source of funds, expected volume, …).
The decision graph can wait for the key people's due diligence before approving. See [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown).
## The result
The business result arrives on your backend by [webhook](https://trust.myaza.co/documentation/webhooks/markdown), the same way KYC results do. A business event's `data` carries a `subjectType` of `"business"`, a `business` object, and a `null` `userData`:
```json
{
"subjectType": "business",
"status": "approved",
"checkStatus": "verified",
"country": "NG",
"business": {
"product": "business",
"registrationNumber": "RC0000000",
"businessName": "ACME LIMITED",
"record": { "companyStatus": "active", "registrationDate": "2018-04-01", "…": "…" }
},
"userData": null
}
```
- `status` is `verified` when the registry returns a match, or `not_found` (reason code `business_not_found`) when the registration number isn't in the registry.
- `record` is an organisation-safe extract of the registry data (company particulars, share ownership, key personnel), never the raw internal response.
- The **onboarding decision** (approve / decline / review), including any key-people gate, arrives separately in a [`workflow.run.completed`](https://trust.myaza.co/documentation/decisioning/markdown#the-workflowruncompleted-webhook) event. The business verification's own status is terminal the moment the registry lookup finishes; the decision can still be pending on the people.
## Decisioning fields
Business runs expose `business.*` fields to [decisioning](https://trust.myaza.co/documentation/decisioning/markdown): `business.companyStatus`, `business.nameMatch`, `business.addressMatch`, `business.ageYears`, `business.keyPersonnelCount`, `business.hasForeignPersonnel`, plus the key-people roll-up (`keyPeople.anyFlagged`, `keyPeople.cddComplete`, `keyPeople.kycComplete`, `keyPeople.corporate`, `keyPeople.ownershipUnresolved`, …). The full roll-up is listed in [Key people & ownership](https://trust.myaza.co/documentation/key-people/markdown#what-you-receive).
## Next steps
- [Workflows](https://trust.myaza.co/documentation/workflows/markdown): build and publish a KYB workflow.
- [Supported countries](https://trust.myaza.co/documentation/countries/markdown#business-verification-kyb): where KYB is available.
- [Key people & ownership](https://trust.myaza.co/documentation/key-people/markdown): due diligence on the owners, corporate shareholders, ownership chains.
- [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown): gate approval on registry status and key-people due diligence.
---
# Key people & ownership
A registry lookup proves a business exists. Regulators (FATF, the EU AML directives, FinCEN's CDD rule) additionally require identifying, and in many cases verifying, the natural persons who **own or control** it: directors, beneficial owners (UBOs), and signatories. A KYB [workflow](https://trust.myaza.co/documentation/workflows/markdown) does this for you, and this page explains who ends up on the list, how deeply each person is checked, and what happens when a shareholder turns out to be another company.
Everything here is configured per workflow under **Dashboard → Workflows → [your KYB workflow] → Key People**.
## Where the list comes from
Two sources, reconciled against each other:
1. **The registry.** The lookup's own record of officers and shareholders. One person often appears several times in a register (as a director, again as a shareholder, again as a person with significant control); we merge those into one party carrying every role they hold.
2. **The applicant.** When *Collect from the applicant* is on, the form asks the business to list their own directors and owners, with contact emails.
After the lookup, the two lists are compared by name. A registry person the applicant did not list is flagged **undisclosed**; one who was shown the register's list and removed a name is flagged more strongly (`keyPeople.removedFromRegistry`); an applicant entry the registry does not corroborate is kept but marked unrecognised for review.
## Roles
A party can hold several roles at once: `director`, `beneficial_owner`, `signatory`, `shareholder`. The webhook and API carry the full set as `roles` alongside the single headline `role` (the strongest one, beneficial owner first). Where the workflow asks different things of different roles, a party is held to the **strongest** requirement among the roles they hold, so a director who is also a beneficial owner clears the owner's bar.
## Beneficial owners and the threshold
A shareholder whose stake reaches the workflow's **beneficial-owner threshold** is treated as a UBO. The default is per register: 25% in most markets (the FATF / EU / FinCEN indicative line), **10% for Nigeria**, where CAMA 2020 requires disclosure of persons with significant control from 5%. Set your own threshold on the workflow; an explicit value always wins.
The test uses the party's **controlling stake: the larger of their economic ownership and their voting rights**. The two are equal until a company issues dual-class or preference shares, and testing ownership alone would file the holder of 5% of the equity and 60% of the votes as a minor shareholder, which is exactly the structure used to stay off a UBO register. Registers do not publish voting rights, so they are applicant-declared when they differ.
## Verification depth
Each role is set to one of three tiers:
| Tier | What it does |
|---|---|
| **Screening only** | AML screening: sanctions, PEP and adverse media. |
| **Identified** | Screening, plus the person must be corroborated by the register and carry a distinguishing detail (date of birth or nationality). No biometric check. |
| **Full KYC** | The person completes their own identity verification (document + selfie) via a personal invite link, valid for 14 days. |
Invites can be emailed automatically, handed to the applicant as copyable links on the success screen, or both; a hosted **key-people portal** page lets the business chase stragglers. Forwarded on WhatsApp or email, the portal link previews as "Verify the people behind {Business}" with your organisation's name and logo, so the business admin can see who is asking before they open it. Each role can run its invite through a different published individual workflow, so a UBO can face a stricter check (NFC, proof of address) than a signatory. When the form requires email addresses, it asks only for the people who will actually be sent a link; a company is never asked for one.
The decision graph can wait for the whole group: gate on `keyPeople.cddComplete` (everyone met their own tier's standard) or `keyPeople.kycComplete` (every full-KYC person finished). See [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown).
## Corporate shareholders
A shareholder is sometimes not a person but **another company**. A beneficial owner is by definition a natural person, so a corporate shareholder is never classified as a UBO, is never sent an identity check (there is no selfie a company can take), and never blocks the decision on its own. It is screened against sanctions lists as an entity, recorded with its registration number, and surfaced as an **unresolved branch of the ownership chain**, because the real owners sit further up.
Two decisioning fields carry this:
- `keyPeople.corporate`: how many in-scope shareholders are companies.
- `keyPeople.ownershipUnresolved`: true while any corporate shareholder has nobody recorded above it. Route these applications to a person; every other check can pass while the actual owners remain unknown.
The applicant can also mark a party as a company themselves and, where they know them, declare the people who own it. Declared owners are recorded as exactly that: disclosed, corroborated by nothing.
## Following the chain (look-through)
Optionally, the workflow can **look a corporate shareholder up in its own register** and pull in the people it finds, up to three levels deep (default one). Each person found this way joins the same list, indented under the company they were found through, and is screened and verified like anyone else.
- **Effective ownership multiplies down the chain.** Someone holding 75% of a company that holds 60% of the applicant owns 45% of the applicant, and 45% is what the beneficial-owner threshold tests.
- **Chains cross borders.** The company search resolves which register a parent is filed in, including region-split registers such as US states, so a Delaware parent of a Nigerian company is looked up in Delaware.
- **Each step up is billed** as a registry lookup, at the same rate as the workflow's own business lookup, which is why the feature is off by default. A branch that is already below the threshold is not looked up (nothing above it can cross the line), a company with no resolvable registration number is left unresolved, and a register outage refunds the lookup. A genuine "not found" keeps its charge: the register answered, and that answer is a finding about the chain.
- Once a branch has been walked, `keyPeople.ownershipUnresolved` stops flagging it.
In sandbox and development the chain never calls a real register. The standard sandbox company includes a corporate shareholder (`Sandbox Holdings Ltd`, RC0000900) with a person and a second company above it, so the whole feature, including the depth limit, is testable at no cost. See [Sandbox testing](https://trust.myaza.co/documentation/sandbox-testing/markdown).
## What you receive
- Each `key_person.completed` [webhook](https://trust.myaza.co/documentation/webhooks/markdown) fires as a person's check resolves, carrying their `role` and full `roles`.
- The verification detail (dashboard and API) lists every party with their roles, economic and voting percentages, corporate flag, registration number, and, for people found up a chain, the parent company and their effective share of the applicant business.
- The decisioning roll-up: `keyPeople.status`, `anyFlagged`, `allCleared`, `total`, `inScope`, `waived`, `undisclosed`, `removedFromRegistry`, `unrecognized`, `kycRequired`, `kycVerified`, `kycFailed`, `kycPending`, `kycComplete`, `dataRequired`, `dataConfirmed`, `dataMissing`, `cddComplete`, `corporate`, `ownershipUnresolved`.
## Next steps
- [Business verification (KYB)](https://trust.myaza.co/documentation/business-verification/markdown): the full KYB guide.
- [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown): gate approval on the key-people roll-up.
- [Screening](https://trust.myaza.co/documentation/screening/markdown): what the AML screen checks and how matches are adjudicated.
---
# 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.
---
# Background Location Declarations
Enabling the background presence tier means your app declares background
location, and both stores treat that declaration as a review event. The SDK
deliberately never adds these entries for you, because the declaration
changes your app's review posture and the decision belongs to you. This page
is the paperwork, ready to paste.
You only need any of this if you call `enableBackgroundPresence()` (React
Native) or `MyazaBackgroundPresence.enable()` (Flutter). The foreground tier
needs no store declaration at all: it uses ordinary while-in-use location,
and that is the default for a reason.
## What your app actually does (the honest summary)
Reviewers approve declarations that are specific and verifiable. This is the
truthful description of the SDK's behaviour, and every text below is built
from it:
- The app registers one operating-system geofence around an address the user
confirmed on a map, with their consent, during identity verification.
- The OS wakes the app on entry and exit. The SDK computes, on the device,
which calendar days the user was at the address and whether the night hours
were touched.
- Only those per-day summaries (a date, minutes of dwell, a night yes/no)
are transmitted. Raw coordinates never leave the device after capture, and
no continuous tracking or location history exists anywhere.
- Monitoring stops when the user is verified, when they revoke it, or when
your organisation stops the check.
## Google Play: the location permissions declaration
Play Console asks every app declaring `ACCESS_BACKGROUND_LOCATION` to
complete the App content → Sensitive app permissions → Location permissions
declaration, including a feature description and a short demo video.
**Feature title** (pick the one matching your product):
> Address verification for account opening
**Declaration text** (paste into "Describe the feature that uses background
location"):
> This app verifies a customer's residential address as part of regulated
> financial-services onboarding (know-your-customer compliance). With the
> customer's explicit in-app consent, the app registers a single geofence
> around the address the customer confirmed on a map and receives
> operating-system entry and exit events for that one location. Location is
> evaluated on the device: the app transmits only daily summaries (date,
> minutes present, whether night hours were touched) to confirm the customer
> lives at the address they declared. No continuous tracking takes place, no
> location history is collected, raw coordinates are never transmitted after
> the initial address capture, and monitoring ends when verification
> completes or the customer withdraws consent. Background access is required
> because residency can only be demonstrated across several days, including
> times the app is not open.
**The demo video** must show, in order: your in-app disclosure screen, the
system permission prompt, and the feature working. Record the SDK's address
step: the presence explainer and consent accordion, then the two-step
permission request, then the success screen's monitoring card.
**Prominent disclosure requirement.** Play policy requires an in-app
disclosure before the runtime permission prompt, stating the data, the
purpose, and that collection happens in the background. The SDK's presence
intro screen and consent accordion are that disclosure; do not suppress them
in a custom flow. If a reviewer asks where the disclosure is, it is the
screen titled "How address confirmation works" shown before any permission
prompt.
## The Android foreground service
If you also enable the foreground-service tier (`enableForegroundService()` on React Native, `MyazaPresenceService.enable()` on Flutter), your app runs a location-typed foreground service with a persistent notification. Two more things follow.
**Manifest.** React Native hosts on `{ "location": "always" }` get the permissions from the config plugin, and expo-location declares its own service. Flutter hosts declare both themselves:
```xml
```
The plugin refuses to start an undeclared service (`notDeclared`), so a missing block is a clean refusal, never a crash.
**Play Console.** From API 34, an app that declares `FOREGROUND_SERVICE_LOCATION` completes the foreground service permissions declaration (App content, Foreground service permissions), which asks for the service type, a description and a demo video. The type is Location. Use this description:
> The app runs a location foreground service, with a visible persistent notification, only while an address verification the customer explicitly consented to is in progress. It takes a low-power location fix every few minutes, evaluates on the device whether the phone is at the single address the customer confirmed, and transmits daily summaries only (date, minutes present, whether night hours were touched). The service exists because the check must keep running when the app is closed, including on devices whose power management stops background work; it stops when verification completes, when the customer withdraws consent, or when the organisation ends the check.
**The notification.** Word it for the person, not the reviewer, and remember it is on screen for days: "Address verification in progress" with "Open the app to see your progress" is the SDK's default and a fair model. Give it its own channel (the SDK does) so your other notifications are unaffected.
## Apple App Store: purpose strings and review notes
iOS has no separate declaration form; the review runs on your purpose
strings and the App Review notes. Use purpose strings that state the purpose,
not the mechanism:
```xml
NSLocationWhenInUseUsageDescriptionUsed to confirm your address during identity verification.NSLocationAlwaysAndWhenInUseUsageDescriptionLets your address stay confirmed automatically, so you do not have to
prove it again. Only daily summaries are used, never your movements.
```
**App Review notes** (paste into the version's Review Notes):
> This app performs regulated identity verification (KYC) for financial
> services. The "Always" location authorisation powers address verification:
> with the user's explicit consent, one geofence is registered around the
> address the user confirmed on a map, and the app receives region entry and
> exit events for that single location. Evaluation happens on the device;
> only per-day summaries (date, minutes present, night yes or no) are
> transmitted. There is no continuous tracking and no location history. To
> test: [describe your demo account path to the verification flow]. The
> consent screen appears in the flow's address step before any permission
> prompt.
## The checklist
Android (host app manifest):
```xml
```
iOS (host app Info.plist): the two purpose strings above. React Native hosts
using the config plugin get all of these from `{ "location": "always" }`.
Before submitting: the workflow's address step has presence enabled, the
in-app disclosure screens are not customised away, the demo video shows
disclosure then prompt then feature, and your data-safety form (Play) or
privacy nutrition label (Apple) declares location collected for app
functionality, not advertising.
---
# Hosted link events
A hosted verification page (`https://trust.myaza.co/verify/…`) tells whatever is hosting it what the applicant is doing: when the flow is ready, which step they are on, when they submit, and when something goes wrong. Nothing to install. Open the link in a WebView (or an iframe) and listen.
The events are deliberately light. They carry ids, step names and error codes, never anything the applicant typed or captured. The result itself still arrives by [webhook](https://trust.myaza.co/documentation/webhooks/markdown) and [`GET /status/:id`](https://trust.myaza.co/documentation/api-verification-status/markdown), exactly as before.
## Which link to open
Mint a **per-applicant session** from your backend with [`POST /api/kyc/sessions`](https://trust.myaza.co/documentation/api-create-session/markdown) and open its `url`. That link belongs to one person and carries your `externalUserId`, so every event you receive is already about a user you know. The `sessionId` it returns is also the verification's id once the applicant submits.
A shared hosted link (`/verify/workflow/`) emits the same events, but a shared link is one URL for many people, so the events cannot tell you who is verifying.
## The events
Every message is a JSON object with `source: "myaza-kyc"` and a `type`:
| `type` | When | Fields |
|---|---|---|
| `ready` | The page loaded the session and is about to show the first step. | `sessionId`, `environment`, `subjectType`, `scope` |
| `started` | The flow mounted. | `sessionId` |
| `step` | The applicant reached a step, including the one the flow opened on. | `sessionId`, `step` |
| `submitted` | The applicant submitted. Processing continues on the server. | `sessionId`, `verificationId`, `status` (`pending`) |
| `completed` | A returning applicant opened a link whose verification was already submitted. | `sessionId` |
| `error` | A technical error (network, camera permission, expired link). | `sessionId`, `code`, `message` |
| `closed` | An embedded flow's Done or close was pressed. | `sessionId` |
`step` values are the SDK's step names: `consent`, `email-verification`, `phone-verification`, `country-select`, `id-type`, `id-input`, `document-capture`, `liveness`, `proof-of-address`, `address-search`, `address-collection`, `address-entrance`, `address-review`, `questionnaire`, `business-details`, `business-key-people`, `business-documents`, `applicant-role`, `submitted`. `error` codes are the SDK's `KYCError` codes (`network_error`, `camera_permission_denied`, `upload_failed`, `feature_disabled`, `insufficient_credits`, `invalid_api_key`, `unknown`).
New event types may be added; existing ones are never renamed. Ignore types you do not recognise.
## React Native (`react-native-webview`)
The page posts to the WebView's own channel, so there is nothing to configure on the page side:
```tsx
import { WebView } from 'react-native-webview';
{
const message = JSON.parse(event.nativeEvent.data);
if (message.source !== 'myaza-kyc') return;
if (message.type === 'submitted') {
// message.verificationId is the id to poll or match against your webhook.
navigation.replace('VerificationPending', { id: message.verificationId });
}
if (message.type === 'error') console.warn('KYC error', message.code);
}}
/>
```
The camera works inside `react-native-webview` on iOS 14.3+ and on Android once your app grants the WebView's camera permission request (`onPermissionRequest`). NFC chip reading, background presence checks and native liveness are not available inside a WebView; if your workflow needs them, use the [React Native SDK](https://trust.myaza.co/documentation/sdk-react-native/markdown).
## Flutter (`webview_flutter`)
Register a JavaScript channel named `MyazaKYC`; the page posts to it when it exists:
```dart
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('MyazaKYC', onMessageReceived: (message) {
final event = jsonDecode(message.message) as Map;
if (event['source'] != 'myaza-kyc') return;
if (event['type'] == 'submitted') {
// event['verificationId']
}
})
..loadRequest(Uri.parse(session.url));
```
## In an iframe
Append your page's origin to the link as `origin`, and the page posts to that window only when the browser confirms it really is the embedder:
```html
```
```js
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trust.myaza.co') return;
if (event.data?.source !== 'myaza-kyc') return;
// event.data.type, event.data.step, event.data.verificationId …
});
```
An `origin` that does not match the page's actual parent is ignored: no events are posted at all, rather than to a window that should not read them.
## What the events are not
They are a courtesy to the host's UI, not the record. Treat `submitted` as "the applicant is done with the flow"; the verdict still comes from your [webhook](https://trust.myaza.co/documentation/webhooks/markdown) or from polling [`GET /status/:id`](https://trust.myaza.co/documentation/api-verification-status/markdown) with the `sessionId` you minted.
---
# Webhooks
Webhooks let you receive verification outcomes (and other events) in real time instead of polling. Myaza sends a signed HTTP `POST` to each endpoint you register whenever a subscribed event occurs.
The runtime catalogue is the source of truth, and the dashboard derives the current canonical event count from it rather than copying a number into this guide.
Register and manage endpoints under **Settings → Organization → Developers → Webhooks**. Endpoints are environment-scoped; test your integration with sandbox endpoints before enabling production.
## Request format
Each delivery is a `POST` with a JSON body and these headers:
| Header | Example | Purpose |
|---|---|---|
| `Content-Type` | `application/json` | — |
| `X-Myaza-Signature` | `sha256=` | HMAC-SHA256 of the raw body. **Verify this.** |
| `X-Myaza-Event` | `verification.completed` | The event type. |
| `X-Myaza-Delivery` | `` | Unique per delivery; use it to deduplicate. |
| `User-Agent` | `Myaza-Webhooks/1.0` | Identifies the sender. |
## Complete public catalogue
| Event | When it fires |
|---|---|
| `verification.started` | The applicant pressed submit. Fires before any check has run. See [When the applicant submits](#when-the-applicant-submits). |
| `verification.completed` | Verification succeeded: identity confirmed. |
| `verification.failed` | Completed but validations did not pass (e.g. face mismatch, insufficient credit). |
| `verification.not_found` | The ID number was not found in the government database. |
| `verification.error` | A system error occurred during verification. |
| `verification.status_updated` | A verification's top-line status changed: a reviewer decided it in the dashboard, your backend decided it [through the API](https://trust.myaza.co/documentation/api-review-verification/markdown), or your workflow reached its verdict. See [Status changes](#status-changes). |
| `session.started` | An applicant opened a resumable verification session. |
| `session.resumed` | An applicant returned to an existing verification session. |
| `session.abandoned` | An opened verification session passed its completion window. |
| `session.expired` | An unopened verification session passed its completion window. |
| `verification.deleted` | You deleted a verification or an unfinished attempt (`kind` says which). See [Delete records](https://trust.myaza.co/documentation/api-manage-records/markdown). |
| `verification.restored` | Myaza restored a verification you had deleted, at your request. |
| `session.started` | An applicant opened a verification session for the first time. |
| `session.resumed` | An applicant returned to a verification session they had left. |
| `session.abandoned` | An applicant opened a verification session and left without submitting it. |
| `session.expired` | A verification session expired before the applicant ever opened it. |
| `api_key.created` | An API key was created (security audit). |
| `api_key.revoked` | An API key was revoked (security audit). |
| `credits.deducted` | Credit was deducted from the wallet for a verification. |
| `credits.low` | Wallet balance dropped below the configured threshold. |
| `credits.topped_up` | The wallet was topped up. |
| `workflow.run.completed` | A [workflow](https://trust.myaza.co/documentation/workflows/markdown) decision run finished; carries the `outcome` (`approve`/`decline`/`review`). See [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown#the-workflowruncompleted-webhook). |
| `workflow.action` | A `webhook` action node fired mid-graph. |
| `workflow.run.failed` | A decision run could not finish (engine fault, **not** a decline). |
| `entity.created` | A completed verification created a monitored [entity](https://trust.myaza.co/documentation/identity-hub/markdown). |
| `entity.imported` | An entity was created via the direct-create or bulk-import API. |
| `identity.resolved` | An entity was linked to a global identity. |
| `entity.updated` | You edited an entity; carries the changed field names, never values. |
| `entity.deleted` | You deleted an entity. Its screening pauses. |
| `entity.restored` | Myaza restored an entity you had deleted, at your request. |
| `event.flagged` | A monitored [event](https://trust.myaza.co/documentation/monitoring-events/markdown) scored to a non-`ALLOW` decision. |
| `screening.match` | A sanctions / PEP / adverse-media / crypto-wallet [screen](https://trust.myaza.co/documentation/screening/markdown) returned a non-clear result (the payload's `type` field says which; `WALLET` for wallet hits). |
| `alert.created` | Event monitoring opened an [alert](https://trust.myaza.co/documentation/investigations/markdown). |
| `alert.updated` | A repeat firing rolled up into an existing open alert. |
| `entity.reverification_due` | An entity's perpetual-KYC renewal is due. |
| `entity.address_presence_started` | Presence monitoring began for a customer. Fires once, when the watch is minted, and carries the policy it has to clear plus the deadline. The verdict follows later. See [Knowing a check is running](https://trust.myaza.co/documentation/address-intelligence/markdown#knowing-a-check-is-running). |
| `entity.address_verified` | A presence watch confirmed the person lives at their claimed address. See [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification). |
| `entity.address_presence_failed` | A presence watch resolved without confirming: `verdict` is `failed` (an integrity contradiction was caught) or `inconclusive` (the window lapsed, unbilled). |
| `case.overdue` | An investigation [case](https://trust.myaza.co/documentation/investigations/markdown) blew past its SLA. |
| `key_person.completed` | A KYB [key person](https://trust.myaza.co/documentation/key-people/markdown) resolved (their KYC finished, or a reviewer attested them). Carries their headline `role` and the full `roles` set. |
The `data` payloads for the compliance events (`entity.created`, `identity.resolved`, `event.flagged`, `screening.match`, `alert.*`) are documented with each feature. See [Identity Hub API](https://trust.myaza.co/documentation/identity-hub/markdown#webhooks) and [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown#the-eventflagged-webhook). The `workflow.*` events are documented under [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown#the-workflowruncompleted-webhook).
## When the applicant submits
`verification.started` is the submission event. It fires the moment the applicant presses submit, before any check has run, and it is the only event that marks that moment. The name describes what happens next on our side (processing begins), but the trigger is the applicant finishing the flow.
This catches people out because the event family changes at exactly that point:
| Moment | Event |
|---|---|
| The applicant opens your link, or the SDK is mounted | `session.started` |
| They return to a flow they had left | `session.resumed` |
| **They press submit** | **`verification.started`** |
| The checks finish | one of `verification.completed`, `verification.failed`, `verification.not_found`, `verification.error` |
An attempt that never reaches submission ends in the session family instead: `session.abandoned` if they opened it and walked away, `session.expired` if they never opened it at all.
A session and the verification it becomes share one id, so `verificationId` is the same value on every event in that table. Correlate the whole attempt on it.
If what you want is the event that tells you somebody completed one of your [workflows](https://trust.myaza.co/documentation/workflows/markdown), this is the one. The payload carries `workflowId` and `workflowVersion`, so you know which flow and which published version they walked.
## Payload structure
Events share the same envelope, and the `data` object varies by event type. The one exception is [`verification.status_updated`](#status-changes), which is sent flat.
```json
{
"id": "evt_01j9abc123",
"event": "verification.completed",
"createdAt": "2026-04-27T12:00:00.000Z",
"data": {
"verificationId": "ver_01j9xyz456",
"requestId": "order_1001",
"externalId": "prov_rec_01j9",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" },
"workflowId": "wf_AbC123dEf456",
"workflowVersion": 3,
"attempt": 1,
"submittedAt": "2026-04-27T12:00:00.000Z",
"status": "approved",
"checkStatus": "verified",
"reason": null,
"reasonCode": null,
"idType": "bvn",
"country": "NG",
"idNumber": "12345678901",
"userData": {
"firstName": "JOHN",
"lastName": "DOE",
"dateOfBirth": "1990-01-01"
},
"facialMatch": { "match": true, "confidence": 85 },
"facialMatchSource": "gov_record",
"media": {
"selfie": "https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/selfie",
"livenessVideo": "https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/liveness-video"
},
"environment": "PRODUCTION",
"createdAt": "2026-04-27T12:00:00.000Z"
}
}
```
On a non-success event (`verification.failed`, `.not_found`, `.error`) the `data` carries a human-readable `reason` and a stable `reasonCode` you can branch on; both `null` above:
```json
{
"status": "declined",
"checkStatus": "failed",
"reason": "The document expired on 2020-01-01. A current, non-expired document is required.",
"reasonCode": "document_expired"
}
```
See the full [failure reason codes](https://trust.myaza.co/documentation/verifications/markdown#failure-reason-codes) catalogue.
`facialMatchSource` says which photo the selfie was compared with: `gov_record` (the government record), `chip` (the verified photo on the document's chip) or `document` (the photo printed on the document, used only when the workflow allows it and neither stronger photo exists). It is `null` when no facial comparison ran. A printed photo is weaker evidence, so you may want to treat a `document` match with more care, for example by sending it to review.
`facialMatchSkipped` says why no facial comparison ran when one was expected. It is `no_face_on_document` when the photo printed on the document was the only photo the selfie could be compared with and no face could be read on it, and `null` otherwise. Your workflow either declines that verification (`reasonCode: document_photo_no_face`) or keeps it for review.
`workflowId` is the [workflow](https://trust.myaza.co/documentation/workflows/markdown) that drove the verification (`null` for SDK mounts configured with plain props). When the workflow has a [decision graph](https://trust.myaza.co/documentation/decisioning/markdown), the pass/fail result above arrives first, and the approve / decline / review **decision** follows in a separate `workflow.run.completed` event.
`workflowVersion` is the **published version that actually ran**. Publishing a workflow overwrites its live config in place and bumps the version, so the id alone stops describing what happened as soon as you publish again; store the pair. Look up the exact configuration a version used in the dashboard under **Workflows → Version history**. It is `null` when the verification had no workflow, predates this field, or attributed to a workflow that was never published.
`attempt` says which attempt of the verification the event describes. A verification you [send back or verify again](https://trust.myaza.co/documentation/api-verify-again/markdown) keeps its id, so when the applicant resubmits you receive another `verification.completed` (or `verification.failed`) with the same `verificationId`, `attempt` counted up, and `submittedAt` set to when they resubmitted. Update the record you already hold rather than creating a new one.
An `api_key.*` event's `data` instead looks like:
```json
{
"id": "evt_01j9abc789",
"event": "api_key.created",
"createdAt": "2026-04-27T12:00:00.000Z",
"data": {
"apiKeyId": "key_01j9abc000",
"name": "Mobile app production key",
"environment": "PRODUCTION",
"createdBy": "user@example.com"
}
}
```
## Your reference on every event
You never need a mapping table to know which of your users an event is about. Every event that originates from an entity, a session or a verification carries two fields beside Myaza's own ids:
| Field | Meaning |
|---|---|
| `externalUserId` | **Your** stable reference for the person or business: what you passed as `externalUserId` when you [created the session](https://trust.myaza.co/documentation/api-create-session/markdown), [submitted the verification](https://trust.myaza.co/documentation/api-create-verification/markdown) (or its legacy `userId` / `metadata.userId`) or [registered the entity](https://trust.myaza.co/documentation/identity-hub/markdown). |
| `metadata` | Your own correlation fields (a loan id, an account id), echoed exactly as you sent them. Bounded at 16 KiB, four levels and 100 keys; Myaza's request controls (`requestId`, `userId`, `device`, sandbox options) are never part of it. |
Myaza's ids (`verificationId`, `entityId`, `sessionId`, `alertId`, ...) stay exactly where they are; the two fields are added, never substituted. Both are resolved once, when the event is created, and persisted with it, so a retry or a manual redelivery replays the same values even if you later changed the entity. `externalId` on verification events is the verification source's own record id, kept for compatibility; correlate on `externalUserId`.
Events about your organisation rather than one of your users carry neither field: `api_key.*`, `credits.low`, `credits.topped_up`, `credits.granted`, `credits.promo_expired` and `screening.adjudication_overdue`. A KYB key person's own verification events resolve to the business's reference (the applicant you know), while events about that person's entity keep the `kp_` id you received in `keyPeopleInvites`.
## Status and checkStatus
Every verification payload carries two status fields, and the difference between them matters.
**`status`** is what happened. It is the single value to drive your own record off, and it is the same vocabulary the dashboard shows and `GET /api/kyc/status/:id` returns, so polling and listening can never disagree.
| `status` | Meaning |
|---|---|
| `not_started` | The link exists but nobody has opened it. |
| `in_progress` | The applicant is part-way through. |
| `processing` | Submitted. Checks are running, or a decision has not landed yet. |
| `in_review` | Your workflow asked a person to decide this one. |
| `awaiting_resubmission` | A reviewer sent it back for the applicant to redo some steps. |
| `approved` | Accepted, automatically or by a person. |
| `declined` | Rejected, automatically or by a person. |
| `abandoned` | Opened, then left unfinished past its deadline. |
| `expired` | Timed out without ever being opened. Send a new link. |
| `error` | A fault on our side. You were not charged. |
**`checkStatus`** is what the checks found: `pending`, `verified`, `failed`, `not_found` or `error`. It never moves once the checks finish, whatever anybody decides afterwards.
### Why both
The two are usually the same thing said twice. They come apart exactly when a person overrides the automated result, and that is the case worth being able to see.
Take an applicant whose selfie scored just under the pass mark. The checks fail. Your workflow routes it to review rather than declining outright, a compliance officer looks at the photos, recognises a lighting problem rather than a different face, and approves.
That verification is now:
```json
{
"status": "approved",
"checkStatus": "failed",
"reasonCode": "selfie_mismatch",
"decision": "APPROVED",
"reviewedById": "usr_01j9abc123",
"reviewedAt": "2026-04-28T09:14:00.000Z"
}
```
Both are true. You onboarded them, and you onboarded them despite a failed face match. If your record only stored `approved`, you could not answer why that was allowed, which is the question your own regulator asks about every exception. `reason` and `reasonCode` stay populated for the same purpose: they describe what was overridden.
Branch on `status`. Store `checkStatus` alongside it.
### Status changes
`verification.status_updated` fires every time a verification's top-line status changes because somebody, or something, decided it. `source` says which:
| `source` | What happened |
|---|---|
| `dashboard` | A reviewer approved, declined or sent it back from your dashboard, or changed an earlier decision. |
| `api` | Your backend did the same through [`POST /verifications/:id/review`](https://trust.myaza.co/documentation/api-review-verification/markdown). |
| `workflow` | Your [workflow](https://trust.myaza.co/documentation/decisioning/markdown) reached its verdict (`approve`, `decline` or `review`), and that moved the status. |
Unlike the other events, this one is sent **flat**: the request body is the object itself, not wrapped in `{ id, event, createdAt, data }`. The event name is in the `X-Myaza-Event` header, as for every event, and in the body as `event` beside `createdAt`, so a body you store describes itself. When the verification has them, `externalUserId` and `metadata` are included as well.
A decision made by a person:
```json
{
"event": "verification.status_updated",
"createdAt": "2026-04-28T09:14:00.000Z",
"verificationId": "ver_01j9xyz456",
"subjectType": "individual",
"attempt": 1,
"status": "awaiting_resubmission",
"previousStatus": "in_review",
"checkStatus": "failed",
"summary": {
"reason": "The document photo is too blurry to read. Take it again in good light.",
"reasonCode": "document_blurry",
"assuranceLevel": null,
"facialMatch": null,
"dataMatch": null
},
"source": "dashboard",
"changedBy": { "type": "user", "id": "usr_01j9abc123" },
"changedAt": "2026-04-28T09:14:00.000Z",
"decision": "RESUBMISSION",
"outcome": null,
"reviewedById": "usr_01j9abc123",
"reviewedAt": "2026-04-28T09:14:00.000Z",
"entityId": "ent_01j9def789",
"workflowId": "wf_AbC123dEf456",
"workflowVersion": 3,
"runId": null,
"resubmission": {
"url": "https://trust.myaza.co/verify/hs_live_abc123",
"steps": ["document-capture"],
"full": false,
"expiresAt": "2026-05-05T09:14:00.000Z"
},
"environment": "PRODUCTION"
}
```
A workflow's verdict:
```json
{
"event": "verification.status_updated",
"createdAt": "2026-04-28T08:02:11.000Z",
"verificationId": "ver_01j9xyz456",
"attempt": 1,
"status": "in_review",
"previousStatus": "processing",
"checkStatus": "failed",
"summary": {
"reason": "The selfie does not match the photo on the government record for this ID. Take it again in good light, looking straight at the camera.",
"reasonCode": "selfie_mismatch",
"assuranceLevel": null,
"facialMatch": false,
"dataMatch": true
},
"source": "workflow",
"changedBy": { "type": "workflow", "id": "wf_AbC123dEf456" },
"changedAt": "2026-04-28T08:02:11.000Z",
"decision": null,
"outcome": "review",
"reviewedById": null,
"reviewedAt": null,
"runId": "wfr_01j9xyz789",
"resubmission": null
}
```
| Field | Description |
|---|---|
| `event`, `createdAt` | The event name and when the change happened, carried in the body too. `createdAt` equals `changedAt`. |
| `attempt` | Which attempt of the verification the change is about. A verification you send back keeps its id, and the applicant's resubmission is the next attempt. |
| `summary` | What the checks concluded, so you can act on the change without a second request: `reason`, `reasonCode`, `assuranceLevel`, `facialMatch` and `dataMatch`. Conclusions only: no names, dates of birth or ID numbers, which stay on the [secret-key result](https://trust.myaza.co/documentation/api-verification-result/markdown). |
| `status` | The top-line status after the change. |
| `previousStatus` | The top-line status immediately before it. Null on the occasional older change whose prior status was never recorded. |
| `source` | `dashboard`, `api` or `workflow`. More sources may be added, so treat an unfamiliar value as a status change like any other. |
| `changedBy` | Who made the change. `type` is `user` (a member of your team; `id` is their user id), `api_key` (`id` is the key's id) or `workflow` (`id` is the workflow's id). |
| `decision` | What a person decided: `APPROVED`, `DECLINED` or `RESUBMISSION`. Null when the workflow made the change. |
| `outcome` | What the workflow decided: `approve`, `decline` or `review`. Null when a person made the change. |
| `reviewedById`, `reviewedAt` | The person or API key that decided, and when. Null when the workflow made the change. Kept for integrations built before `changedBy`. |
| `runId` | The [workflow run](https://trust.myaza.co/documentation/decisioning/markdown) that decided, when the workflow made the change. |
A workflow's verdict fires this event only when it moves the status. If a person already decided while the workflow was still waiting (on key people, for example), their decision stands, the verdict changes nothing, and you receive `workflow.run.completed` alone.
On a resubmission, `resubmission.url` is the link to send the applicant, unless the reviewer had us email it. They redo only the steps in `steps`; `full: true` means the reviewer asked for the whole flow. Their resubmission completes the **same** verification: the next `verification.completed` or `verification.failed` arrives with the same `verificationId` and `attempt` counted up. See [Verify again](https://trust.myaza.co/documentation/api-verify-again/markdown).
The reviewer's internal note is deliberately **not** included. It is free text somebody wrote about your applicant, so it stays in your dashboard: in the audit log and on the verification's timeline. The short message written *for* the applicant travels on the resubmission link instead.
Decisions can change. A reviewer or your backend may reverse an earlier decision, and each change arrives as its own `verification.status_updated`. Deliveries retry independently and can arrive out of order, so treat the latest `changedAt` as current. Every change also appears on the verification's **Timeline** in your dashboard, with who made it and why.
An endpoint only receives `verification.status_updated` when it is subscribed to it. Endpoints created before the event existed are not, so open the endpoint under **Developers → Webhooks** and choose **Edit events** to add it. The endpoint keeps its URL and signing secret.
## Captured media
Verification events carry a `media` object: a map of the images and videos captured during the flow. Each value is an **absolute URL** (not the bytes themselves). Fetch each one from your backend with a **secret (`sk_`) key** as a Bearer token (media is sensitive, so a publishable key returns `403 secret_key_required`):
```bash
curl -H "Authorization: Bearer sk_live_..." \
https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/selfie \
--output selfie.jpg
```
The URLs are scoped to the secret key's **organisation and environment** (a sandbox key cannot read production media) and do not expire. The object only contains the kinds that were actually captured (a number-only-ID flow has just `selfie` + `livenessVideo`), and is `null` when no media is associated with the event.
| Key | Description |
|---|---|
| `selfie` | Liveness selfie still. |
| `documentFront` | Front of the ID document. |
| `documentBack` | Back of the ID document, when captured. |
| `livenessVideo` | Recording of the liveness challenge. |
| `documentFrontVideo` | Recording captured while scanning the document front. |
| `documentBackVideo` | Recording captured while scanning the document back. |
| `documentPortrait` | The portrait cut from the document, present when the selfie was matched to the photo printed on it. |
## Verifying the signature
Compute `HMAC-SHA256(rawBody, endpointSecret)` and compare it to the `X-Myaza-Signature` header using a **constant-time** comparison. Always use the **raw request body**, not a re-serialised JSON object, or the signature won't match.
Your endpoint secret is shown when you create the endpoint in the dashboard.
```js
// Node.js / Express
const crypto = require('crypto');
app.post('/webhooks/myaza', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-myaza-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.MYAZA_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// process event.event / event.data …
res.status(200).send('OK');
});
```
```python
# Python / Flask
import hmac, hashlib
from flask import request, abort
@app.route('/webhooks/myaza', methods=['POST'])
def webhook():
sig = request.headers.get('X-Myaza-Signature', '')
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(), request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json()
return 'OK', 200
```
## Retries
A delivery that doesn't receive a `2xx` response (or times out) is retried with exponential backoff. After 5 failed attempts the delivery is marked `FAILED` and can be retried manually from the dashboard.
| Attempt | Delay after previous attempt |
|---|---|
| 1 | 30 seconds |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 24 hours |
## Best practices
- **Verify the signature before processing** the payload.
- **Respond `2xx` immediately**, then do heavy work asynchronously to avoid timeouts and retries.
- **Be idempotent.** The same delivery may arrive more than once; deduplicate on `X-Myaza-Delivery` (or `verificationId`).
- **Store the secret** in an environment variable, never in source.
- **Test on sandbox** before enabling production endpoints.
---
# Sandbox & test IDs
Test your Myaza KYC integration end to end: webhooks, polling, the full result
payload (biodata, facial match, OCR data, captured-media URLs), and every failure
branch, all **without calling any government database or consuming paid verification credits**.
| Environment | Key prefix | Real IDs? | Test IDs? | Charged? |
|-------------|-----------|-----------|-----------|----------|
| **Sandbox** | `pk_test_` / `sk_test_` | rejected | canned result | never |
| Production | `pk_live_` / `sk_live_` | yes | treated as real | yes |
**Sandbox** accepts **only** published test IDs. A real (non-test) `idNumber` is rejected
immediately at `POST /verify` with `422 only_test_ids_allowed`.
All sandbox results are **fully fake**: fake biodata, fake government photos, fake OCR,
and fake gov-DB data. No external API is ever called.
## How test IDs work
Pass a test ID in the `idNumber` field of `POST /verify`. The **last one or two digits
encode the scenario**; everything before is zero-padding so the value stays format-valid
for its ID type. Example for NG BVN:
```
00000000001 → verified
00000000002 → not_found
00000000004 → selfie_mismatch
00000000007 → document_expired (document IDs only)
00000000012 → provider_error
```
### Scenario reference
Scenarios 5 and 6 follow your workflow's identity details policy. When the workflow records every field the scenario disagrees on (5: first and last name; 6: first name and date of birth) instead of failing on it, the test ID verifies with `dataMatch: false`, exactly as a real mismatch would.
| Idx | Scenario | Status | `reasonCode` | Applies to |
|----:|----------|--------|--------------|-----------|
| 1 | verified | verified | — | all |
| 2 | not_found | not_found | `identity_not_found` | all |
| 3 | gov_validation_failed | failed | `gov_validation_failed` | all |
| 4 | selfie_mismatch | failed | `selfie_mismatch` | all |
| 5 | gov_data_mismatch | failed | `gov_data_mismatch` | all |
| 6 | document_data_mismatch | failed | `document_data_mismatch` | document |
| 7 | document_expired | failed | `document_expired` | document |
| 8 | document_type_mismatch | failed | `document_type_mismatch` | document |
| 9 | document_unreadable | failed | `document_unreadable` | document |
| 10 | document_blurry | failed | `document_blurry` | document |
| 11 | id_number_not_found | failed | `id_number_not_found` | document |
| 12 | provider_error | error | `provider_error` | all |
| 13 | unsupported_id_type | error | `unsupported_id_type` | all |
| 14 | media_not_found | error | `media_not_found` | all |
| 15 | insufficient_credits | error | `insufficient_credits` | all |
| 16 | system_error | error | `system_error` | all |
| 17 | id_type_not_enabled | error | `id_type_not_enabled` | all |
| 18 | document_verification_disabled | error | `document_verification_disabled` | document |
| 19 | gov_db_check_disabled | error | `gov_db_check_disabled` | all |
| 20 | sandbox_not_found | not_found | `sandbox_not_found` | all |
| 21 | sandbox_failed | failed | `sandbox_failed` | all |
| 22 | age_restricted | failed | `age_restricted` | all |
| 23 | age_unverified | failed | `age_unverified` | all |
| 24 | document_photo_no_face | failed | `document_photo_no_face` | document |
Scenarios 22 and 23 follow your workflow's age restriction. Scenario 22 returns a date of birth just outside your own limits (a year under the minimum, or a year over the maximum) and scenario 23 returns none, so both fail exactly as a real holder would. On a workflow with no age restriction, both verify. If your workflow sends that case to review instead of declining it, scenario 22 verifies with the out-of-range date of birth and your decision rule sends it to review; scenario 23 always fails.
Scenario 24 follows your workflow's settings for the photo on the document. It stands for a document ID whose selfie could only be compared with the photo printed on the document, where no face could be read on that photo. If your workflow compares that ID with the photo on the document and declines the case, it fails with `document_photo_no_face`. If your workflow sends that case to review, it verifies with `facialMatchSkipped: "no_face_on_document"`, and your decision rule on `verification.documentPhotoNoFace` sends it to review. If your workflow does not compare that ID with the photo on the document, it verifies.
> Document-only scenarios (6–11, 18) are not published for number-only IDs (BVN/NIN/vNIN).
### The `verified` test ID per ID type
Change the trailing digits per the table above to pick a different scenario.
| Country | ID type | Format | `verified` test ID |
|---------|---------|--------|--------------------|
| NG | bvn / nin | 11 digits | `00000000001` |
| NG | vnin | 16 chars | `0000000000000001` |
| NG | passport | A + 8 digits | `A00000001` |
| NG | drivers-license | TST + 8 digits | `TST00000001` |
| NG | pvc | 19 digits | `0000000000000000001` |
| GH | ghana-card | GHA-NNNNNNNNN-N | `GHA-000000001-0` |
| GH | voters | 10 digits | `0000000001` |
| GH | ssnit | C + 12 digits | `C000000000001` |
| GH | passport | G + 8 digits | `G00000001` |
| KE | national-id | 8 digits | `00000001` |
| KE | passport | K + 8 digits | `K00000001` |
| ZA | national-id | 13 digits | `0000000000001` |
| ZA | passport | Z + 8 digits | `Z00000001` |
| CI | cni | CI + 9 digits | `CI000000001` |
| CI | residence-card | CR + 9 digits | `CR000000001` |
## Example: number-only ID
```bash
curl -X POST https://your-server/api/kyc/verify \
-H "Authorization: Bearer pk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"country": "NG",
"idType": "bvn",
"idNumber": "00000000001",
"userData": { "firstName": "Chidi", "lastName": "Okafor", "dateOfBirth": "1990-04-12" },
"metadata": { "requestId": "test-001" }
}'
```
## Document IDs
In **sandbox**, OCR is **never run**; a fake OCR result is returned instead. Either let
the SDK document flow default to the **verified** fixture (no ID number collected), or
pick a scenario by passing the document test ID in `idNumber` (e.g. `A00000007` for an
expired passport) or via the `sandboxOutcome` override below.
## Business (KYB)
Business verification never calls a real registry outside production: a registry lookup
incurs an external check cost in whichever environment requests it, so sandbox and development
serve canned data instead.
- **Any registration number is accepted** and resolves to a verified fixture that echoes
the name you searched for. The catalogue values still pick their scenario:
`RC0000001` is verified, `RC0000002` is not found. A business picked from the real
company search (which does work in every environment) can therefore be checked on
sandbox against fixture data.
- **`sandboxOutcome`** on the submit (`"verified"` or `"not_found"`) pins the result for
any number, and is ignored silently in production.
- **The sandbox company carries a full key-people set** for exercising [due
diligence](https://trust.myaza.co/documentation/key-people/markdown): a 60% beneficial owner, directors, a signatory, a persona
named "Pep" that the sandbox screening stub flags (set `SCREENING_PROVIDER=stub`),
and a **corporate shareholder**, `Sandbox Holdings Ltd` (RC0000900), whose own
register resolves to a person majority-owner and a second company above it
(RC0000901), so ownership-chain look-through, effective percentages, and the depth
limit are all testable at no cost.
## Address presence
Presence verification has its own personas and a compressed clock so a multi-day watch
is testable in minutes; they are documented with the feature on the [Address
Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#sandbox-and-billing) page. In short: outside production the
watch window runs in **minutes**, and an `externalUserId` containing
`presence_verified`, `presence_fail` or `presence_inconclusive` seeds a canned
observation stream through the real scorer. A plain id gets no seed, so you can drive
the ingest endpoint yourself.
## Overrides
Both go in `metadata` and are ignored in production.
- **`sandboxOutcome`**: force any scenario regardless of the test ID, e.g.
`"sandboxOutcome": "selfie_mismatch"`.
- **`sandboxDelayMs`**: how long `pending → completed` takes (0–60000 ms). Defaults to
~1200 ms; set `0` for an instant result.
## Captured media
Media uploaded under a test ID is kept for **3 days**, then the bytes are replaced with a
placeholder (an image for stills, a sample clip for videos). The `data.media` URLs in
webhooks keep working; they just serve the placeholder after the sweep.
## Webhook simulator
From a webhook endpoint's detail page (or `POST /api/dashboard/webhooks/:id/simulate`
with `{ "event": "verification.completed" }`), fire a realistic, correctly-signed sample
payload of any event at your endpoint to test signature verification and your handler,
without running a verification. It uses the real delivery path (HMAC signing + retry).
## Notes
- Sandbox verifications are **never charged**, even for the `insufficient_credits` scenario.
- Test IDs bypass the ID-type allowlist, so a new organisation can test immediately. Use
the `id_type_not_enabled` scenario to simulate a revoked allowlist.
- Branch on the stable `reasonCode`, not the human-readable `reason`.
---
# Workflows
A **workflow** is a verification flow you build once in the dashboard (the capture steps, the countries and ID types, the branding and copy, any extra checks, and an optional [decisioning](https://trust.myaza.co/documentation/decisioning/markdown) graph), and then reuse without touching your code. Instead of passing a dozen props to the [SDK](https://trust.myaza.co/documentation/sdks/markdown), you reference a workflow by its id (`wf_…`) and the server hands the SDK the whole configuration.
Because the configuration lives on the server, you can change the flow (add a step, tighten a rule, tweak the welcome copy) by **re-publishing in the dashboard**. Live integrations pick up the new version on their next verification. No SDK upgrade, no redeploy.
Build workflows under **Dashboard → Workflows**. Each is scoped to one [environment](https://trust.myaza.co/documentation/environments/markdown) (a sandbox workflow doesn't exist in production) and has a draft you edit and a published snapshot your integrations read.
## Three ways to run a workflow
### 1. Embed it in the SDK (`workflowId`)
Mount the [Web](https://trust.myaza.co/documentation/sdk-react/markdown), [React Native](https://trust.myaza.co/documentation/sdk-react-native/markdown), or [Flutter](https://trust.myaza.co/documentation/sdk-flutter/markdown) SDK with a `workflowId` instead of spelling out `country`, `idTypes`, and the step toggles:
> **Minimum SDK versions for `workflowId`.** Web `@myazahq/kyc-sdk-react` **≥ 2.2.0** · React Native `@myazahq/kyc-sdk-react-native` **≥ 2.1.0** · Flutter `kyc_sdk_flutter` **≥ 2.2.0**. Earlier versions can't resolve a workflow ID: the web and React Native SDKs silently ignore it (the flow runs on props alone), and Flutter below 2.2.0 won't compile a workflow mount without a placeholder `country`. If a workflow embed seems to "do nothing", check the installed SDK version first.
```tsx
console.log(s.verificationId)}
/>
```
The SDK resolves the workflow on mount and uses it as the source of truth: **the workflow's configuration wins over any overlapping props** (country, ID types, step toggles, appearance, copy). Runtime data (`userId`, `userData`, `metadata`, and your callbacks) always comes from your code. With a `workflowId`, `country` becomes optional (the workflow carries it).
An unknown, unpublished, or wrong-environment workflow surfaces as a blocking error via `onError`, never a silently different flow.
### 2. Share a hosted link
Every published workflow can also be turned into a **hosted link**: a public URL you send directly to a person (WhatsApp, email, SMS) with zero integration:
```
https://trust.myaza.co/verify/workflow/
```
The person opens the link, completes the flow in their browser, and you receive the result by [webhook](https://trust.myaza.co/documentation/webhooks/markdown) exactly as you would from an embedded SDK. Desktop visitors are offered a "continue on your phone" QR hand-off for the camera steps. Enable, disable, or regenerate the link from the workflow's page. Regenerating immediately kills the old URL. A link opened inside a WebView or an iframe reports the applicant's progress to its host as [hosted link events](https://trust.myaza.co/documentation/hosted-events/markdown).
**How the link previews when shared.** Pasted into WhatsApp, iMessage, Slack or an email client, the link unfurls as your request, not as Myaza's website: the title reads "Verify your identity with {Your organisation}" (or "Verify your business with", or the scope's own title for an address, contact or face check), the description is the Consent step's description when you have written one, and the preview card carries your logo (or your initial when none is uploaded), your organisation name and the workflow's primary colour from its Appearance settings, with a small "Secured by Myaza Trust" footer. A link that is no longer available previews as a neutral Myaza Trust card, so a preview never reveals whether a token exists. The pages stay out of search results regardless.
### 3. Mint a per-applicant session from your backend
When you know exactly who should verify, [`POST /api/kyc/sessions`](https://trust.myaza.co/documentation/api-create-session/markdown) (secret key) mints a hosted link that belongs to **one applicant**: single use, traceable to your `externalUserId`, optionally prefilled with what you already know, and optionally emailed on your behalf. A hosted link is one URL for many people; this is one URL for one person. The dashboard's "Create session" action mints the same thing by hand.
## How the SDK resolves a workflow
When mounted with a `workflowId`, the SDK calls one endpoint (instead of [`GET /config`](https://trust.myaza.co/documentation/api-config/markdown)):
```
GET /api/kyc/workflows/:workflowId
Authorization: Bearer pk_…
```
```json
{
"workflow": { "id": "wf_AbC123dEf456", "name": "Standard KYC", "version": 3 },
"config": { "country": "NG", "idTypes": ["bvn", "passport"], "enableLiveness": true },
"environment": "PRODUCTION",
"idTypes": [ { "country": "NG", "idType": "bvn", "features": { "govDbCheck": true } } ],
"branding": { "companyName": "Acme Inc.", "logo": "https://…", "primaryColor": "#5645F5" }
}
```
The workflow must belong to the API key's organisation **and** environment and be **published**: anything else is an indistinguishable `404 workflow_not_found`, so ids can't be probed across organisations. The `config` is org-authored template data and contains no PII, so a publishable (`pk_`) key is enough. You normally never call this yourself; the SDK does.
## Capture add-ons
A workflow can turn on extra capture steps and checks beyond the core document + selfie + liveness flow. Each one is a step you toggle in the builder; most feed a soft signal into [decisioning](https://trust.myaza.co/documentation/decisioning/markdown) without ever changing the verification's own pass/fail status.
| Add-on | What it does |
|---|---|
| **Consent screen** | The welcome screen every flow opens on, where the person agrees to the verification. Its copy is editable, and the screen can be switched off on the Consent step (or by default for new workflows under Workflow Settings) when your own app has already asked: the flow then opens on its first real step. Consent remains your organisation's attestation to Myaza whichever way the switch is set. |
| **Contact verification** | An email and/or phone OTP right after consent: a cheap possession check that filters fake contacts before any gov-DB or liveness spend. Branch on `email.disposable`, `phone.countryMismatch`, and the like. |
| **Proof of Address** | Collects a utility bill / bank statement / tenancy agreement / government-issued document, then checks recency and, by the workflow's name rule, whether the applicant's name appears on it. The rule (Required / Optional / Not needed) is set per workflow with per-country, per-document exceptions, for markets where a bill names the meter or the landlord rather than the tenant. Branch on `verification.poaStatus` and `verification.poaNameMatch`. |
| **[Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown)** | A map pin (+ optional entrance photo and directions) corroborated against the government record, the PoA document and the IP country. On KYB flows the pin is the business premises. Branch on `address.tier` and the other `address.*` fields. |
| **NFC chip read** | Reads the eMRTD chip in a passport (mobile only) and runs server-side passive authentication, the strongest assurance tier. Branch on `verification.nfcStatus` / `verification.assuranceLevel`. |
| **Questionnaire** | Extra compliance declarations (source of funds, expected volume, …) asked before submission. Answers ride the webhook and can be branched on as `questionnaire.`. A question can map onto canonical profile fields via `mapsTo`, including `walletAddress`, which appends the answer to the entity's wallet set and enrols [WALLET screening](https://trust.myaza.co/documentation/screening/markdown#types). |
| **Device Intelligence** | Device + IP fraud analysis (multi-accounting, emulators, datacentre IPs, velocity), **on by default**. Branch on `device.*` and `ip.*`. |
| **Liveness mode** | Choose gesture challenges, the screen-flash sequence, or **both** for the strongest presence check. |
| **Multi-region** | List several countries; the flow opens with a country-select step and applies that country's ID types and validation rules. |
| **Multiple IDs** | Verify two or three IDs in one run (e.g. NIN **and** BVN): the applicant picks each from what you allow for their country, one selfie covers them all, and it lands as **one verification** whose result follows your pass policy (all must pass, at least two, or any one). Works across regions, each country choosing which IDs its verifications offer. Branch on the `multiId.*` fields for finer routing. |
The web SDK accepts these as props too, but a workflow is the recommended path: you compose the checks once in the dashboard, and every embed and hosted link inherits them, and you change them by re-publishing, with no redeploy.
## Emailing the applicant
Switch on **Success → Applicant email → Email the applicant when approved** and, once a verification is approved, whether by the workflow or by a reviewer, we send the applicant a short email in your organisation's name. Declines are never emailed automatically; a reviewer can choose to email one as they decide it.
No address is set on the workflow. We only write to one the verification already holds, using the first of these that has one:
1. the email the applicant verified with a code, when the workflow runs email verification;
2. an email your integration passes, as `userData.email` on the SDK or on a [session you create](https://trust.myaza.co/documentation/api-create-session/markdown);
3. the email on the person's entity record.
When none of these holds an address, nothing is sent. Every email is added to the verification's timeline. The setting is not offered on biometric re-authentication workflows, which run every time somebody signs in.
## Identity details that do not match
Pass the name and date of birth you hold (`userData`), and every verification compares them with the ID: the government record for a number-only ID such as a BVN, and the document itself for a passport or licence. By default a mismatch fails the verification.
Under **ID Verification → Advanced → Identity details** you can set names and the date of birth, independently, to **record** instead. The comparison still runs and still lands on the result (`dataMatch: false`, with the field marked `enforced: false` in `dataValidationDetails`), but the verification can pass. Route those results with a decision rule on `verification.nameMatch` or `verification.dateOfBirthMatch`, for example sending them to review, or handle them in your webhook.
Keep face matching on when you record names. The face is then the only thing tying the person to the ID number they entered, so publishing warns you when face matching is off for any ID.
## Matching the selfie to the photo on a document
The selfie is compared with the strongest photo a verification holds: the photo on the government record, or the verified photo on the document's chip when the [NFC step](https://trust.myaza.co/documentation/nfc-chip/markdown) reads one. When neither is available, a document ID can use the photo printed on the document.
New workflows enable this for every supported document ID, including IDs added later. Manage the workflow-wide setting under **ID Verification → Advanced**. To exclude one ID, open its country settings under **ID Verification → Countries** and switch off **Match the selfie to the photo on the document**. Where an ID also shows **Facial match**, that setting must stay on.
Over the API, set `documentPhotoMatch: true` on the workflow config for every document ID, or on `countries[].idOptions[idType]` for one ID. The most specific value wins, so `false` on an ID leaves it out.
- It runs only when the verification has no government record photo and no verified chip photo.
- A mismatch fails the verification with `reasonCode: selfie_mismatch`, and the reason names the photo on the document.
- A printed photo is weaker evidence than a government or chip photo, so it never raises the [assurance level](https://trust.myaza.co/documentation/id-types/markdown).
- It is billed as a facial comparison.
- If no face can be read on the photo (glare, a thumb over it, a photo too small to see), nothing is compared. Under **ID Verification → Rules** choose what happens. **Decline**, the default, fails the verification with `reasonCode: document_photo_no_face` and asks the person to photograph the document again. **Review** keeps the result the checks produced, and a decision rule on `verification.documentPhotoNoFace` sends it to a person to approve or decline. The tab writes that rule for you, and publishing warns you if no rule reads the field. Either way the result carries `facialMatchSkipped: "no_face_on_document"`.
The result says which photo was used: `facialMatchSource` is `gov_record`, `chip` or `document`, and `null` when no comparison ran. It rides the result and the [webhook](https://trust.myaza.co/documentation/webhooks/markdown), and a decision rule on `verification.facialMatchSource` can, for example, send document-photo matches to review.
## Age restrictions
Under **ID Verification → Age** you can limit a workflow to people of a certain age in each country. Once it is on, every verification compares the holder's age with the range for their country, using the date of birth the check established: the government record, the document, or its chip. A date of birth the applicant typed is never used for this.
- **Minimum age** follows each country's legal age of majority by default: 18 in most countries, between 16 and 21 elsewhere. The tab shows it beside every country, with a note where the age differs inside a country. You can set one minimum for every country instead.
- **Maximum age** is off by default, because no country sets a legal maximum. Set one if your policy needs it.
- **A country's own limits** override the defaults for that country alone.
Choose what happens to someone outside the range, separately for **under the minimum** and **over the maximum**:
- **Decline**, the default: the verification fails with `reasonCode: age_restricted`.
- **Review**: the verification keeps the result its checks produced, and a decision rule on `verification.ageLimit` (`under_minimum` or `over_maximum`) sends it to a person to approve or decline. The tab writes that rule for you, and publishing warns you if no rule reads the field.
A verification whose date of birth could not be read always fails with `age_unverified`, so an unreadable document is never waved through. Neither reason names an age. Test scenarios 22 and 23 exercise both against your own limits (see [Sandbox testing](https://trust.myaza.co/documentation/sandbox-testing/markdown)).
## Templates
New workflows start from a **template**: a pre-built flow you can use as-is or customise. Templates are country-agnostic (you pick the country and ID types in the builder) and ship with a sensible decision graph. The catalogue:
**Onboarding**: everyday signup shapes.
- **Standard KYC**: full capture, auto-approve on pass.
- **Low-friction signup**: number-only IDs (BVN/NIN) + selfie; failures go to review, not decline.
- **Hosted link collection**: tuned for sharing; can't be dismissed mid-way, desktop→phone hand-off.
- **Re-verification**: a light refresh for customers due for renewal.
- **High-assurance biometric**: camera-only, gesture + flash liveness, NFC chip read; chip-verified identities auto-approve, softer matches go to review.
- **Contact-verified onboarding**: email + phone OTP before capture; throwaway emails routed to review.
**Compliance**: screening, strict regimes, human-in-the-loop.
- **KYC + AML screening**: screen every verified user; watchlist hits open a case.
- **Strict compliance**: auto-decline confirmed matches, escalate the rest.
- **Manual review everything**: no auto-approvals; a human signs off each customer.
- **Geographic risk routing**: extra scrutiny for high-risk nationalities or IP-country mismatches.
- **Risk-tiered onboarding**: let the monitoring risk tier set the friction.
- **Crypto exchange onboarding**: full KYC plus AML screening that covers the customer's crypto wallet addresses; watchlist or wallet hits open a case, network-flagged identities are declined.
**Specialised**: targeted checks.
- **Age-gated service (18+)**: verify identity and age together.
- **Expired-document guard**: decline expired IDs, flag soon-to-expire ones.
- **Network trust fast-track**: trust identities already verified elsewhere on the Myaza network.
- **Strict data & face match**: both checks must explicitly pass.
- **Failure triage & retry**: give recoverable capture failures a second chance.
- **Device & IP fraud guard**: route multi-accounting, emulator, and bot signals to review.
- **Address-verified onboarding**: identity plus a proof-of-address check.
- **Presence-verified address**: smart-address capture plus a multi-day presence watch the decision holds on.
- **Address-only verification**: no identity step at all; the address capture is the whole flow, for books verified elsewhere. See [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#address-only-workflows-standalone-no-identity-step).
- **Biometric enrolment** and **Face re-authentication**: a liveness selfie into, then against, the customer's biometric template; the workflow-shaped face check with contact-code companions and a decision on the outcome.
- **Declarations refresh**: a questionnaire alone, for periodic attestations and source-of-funds updates.
- **Contact re-verification**: one-time codes to the email and phone on file, nothing else.
These five are **scoped workflows**: `scope` on the config names the one thing the flow verifies (`address`, `biometric-authentication`, `biometric-enrollment`, `questionnaire`, `contact`), publish rejects any stronger evidence, submissions carry the scope's marker `idType` and require the published workflow, and the capture lands on your existing customer record by `externalUserId` without ever touching its KYC status.
A workflow's scope is chosen with the **Scope** control at the top of the builder rail and is **editable until the first publish, then locked**: verifications, hosted links and embeds already reference what that workflow ID verifies, so a mid-life change would rewrite their meaning (the API returns `scope_locked`). To get a different scope from a live workflow, use **Scoped copy** on the workflows list. It creates a fresh draft under a new workflow ID that carries the parts that mean the same thing under any scope (branding, country, contact steps, session policy) and none of the identity evidence, seeded so the copy opens ready to publish.
**Business (KYB)**: registry lookups with due diligence.
- **Standard KYB**: registry lookup with a customer due-diligence gate for key people.
- **KYB + AML screening**: screen every verified business before approval.
- **Enhanced KYB with declarations**: registry check plus a compliance questionnaire.
## Business (KYB) workflows
A workflow can verify a **business** instead of a person. It looks the company up in the official registry, optionally collects company documents and declarations, and runs **customer due diligence on the people who own or control it**, screening directors, beneficial owners (UBOs), and the applicant, and optionally requiring each to complete their own KYC via a per-person invite link.
Live business submissions **require** a published KYB workflow; the server rejects a business verification with no workflow. See **[Business verification (KYB)](https://trust.myaza.co/documentation/business-verification/markdown)** for the full guide (products, the application layers, the result shape, and decisioning), [Key people & ownership](https://trust.myaza.co/documentation/key-people/markdown) for the due-diligence layer (roles, thresholds, corporate shareholders, ownership chains), and [Supported countries](https://trust.myaza.co/documentation/countries/markdown#business-verification-kyb) for registry coverage.
## Versioning
Editing a published workflow only changes its **draft**: live integrations keep seeing the last published snapshot until you publish again. Every publish bumps the version and appends an immutable history entry you can inspect or restore from. Each verification records the exact `workflowId` and version that drove it, and the [`data.workflowId`](https://trust.myaza.co/documentation/webhooks/markdown) field appears on every verification webhook so you can attribute results back to a flow.
## Next steps
- [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown): the rules engine that turns verification data into approve / decline / review.
- [Webhooks](https://trust.myaza.co/documentation/webhooks/markdown): [`verification.started`](https://trust.myaza.co/documentation/webhooks/markdown#when-the-applicant-submits) when an applicant submits your flow, the `workflow.run.completed` event, and workflow attribution on results.
- [Web SDK](https://trust.myaza.co/documentation/sdk-react/markdown): mounting with a `workflowId`.
---
# Decisioning
Every [workflow](https://trust.myaza.co/documentation/workflows/markdown) can carry a **decision graph**: server-side logic that runs *after* a verification reaches a terminal state, branches on the verification, entity, identity, screening, and fraud data, and lands on a decision: **approve**, **decline**, or **review**. It's the difference between "the ID checked out" and "this customer is onboarded."
You build the graph in the workflow builder (a simple ordered rule list, or an advanced canvas). Decisioning is optional: a workflow with no graph simply completes and reports the raw verification result.
## How a run works
When a verification finishes, the engine starts a **run**:
1. It gathers the run's data: the verification result, the resolved [entity](https://trust.myaza.co/documentation/identity-hub/markdown) and global identity, any [screening](https://trust.myaza.co/documentation/screening/markdown) results, and the [Device Intelligence](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) signals.
2. It walks the graph from the start node, evaluating each **branch** and executing each **action**.
3. It lands on a terminal **status** node, which sets the outcome.
4. It records the full path it took (the trace) and fires the [`workflow.run.completed`](#the-workflowruncompleted-webhook) webhook.
Runs are one-per-verification and idempotent, so a retried or re-processed verification never decides twice.
## Node types
| Node | What it does |
|---|---|
| **Branch** | Evaluates a condition over the [field namespace](#field-reference) and takes the `true` or `false` edge. |
| **Action** | A side effect: **tag** the run, fire a `workflow.action` **webhook**, or **open a case** for an investigator. Actions never stop the flow. |
| **Screening / key-people** | Waits for AML screening (or KYB key-people verification) to resolve before continuing, up to a timeout. |
| **Status** | Terminal. Sets the outcome: `approve`, `decline`, or `review`. |
## Outcomes
A status node's outcome writes the **entity's disposition** (the compliance decision on the person or business), but never overrides the verification's own result:
| Outcome | Entity disposition | Meaning |
|---|---|---|
| `approve` | `APPROVED` | Onboard the customer. |
| `decline` | `REJECTED` | Reject the customer. |
| `review` | `UNDER_REVIEW` | A human should look before you act. |
The outcome arrives on the [`workflow.run.completed`](#the-workflowruncompleted-webhook) webhook. What you *do* with it (grant access, hold the account, notify your ops team) is up to your backend.
Completion and its notification intent are committed together. Delivery retries
reuse the event ID and do not reapply an old disposition over a later decision.
Use `externalUserId` to correlate the customer, deduplicate the signed event and
read current state before lifting a restriction. Face-reuse findings arrive after
verification and can start a separate signal policy; see
[verification device and face findings](/documentation/webhook-risk-intelligence#verification-device-and-face-findings).
## Field reference
Branch conditions read a **closed namespace** of fields. Unknown paths are rejected when you publish (so a typo surfaces immediately rather than silently never matching), and a null value fails the comparison closed. Use the `exists` operator to test presence.
Operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `exists`. Conditions combine with `all` (AND), `any` (OR), and `not`.
**`verification.*`**: the check result
| Field | Example values |
|---|---|
| `verification.status` | `VERIFIED`, `FAILED`, `NOT_FOUND`, `ERROR` |
| `verification.reasonCode` | `selfie_mismatch`, `document_expired`, … (see [Errors](https://trust.myaza.co/documentation/errors/markdown)) |
| `verification.assuranceLevel` | `chip`, `gov_db`, `document` (how strongly it passed) |
| `verification.country` / `.idType` / `.nationality` / `.gender` | — |
| `verification.dataMatch` / `.facialMatch` | `true` / `false` |
| `verification.nameMatch` / `.dateOfBirthMatch` | `true` / `false`, null when not compared. Read these when your workflow records identity mismatches instead of failing on them |
| `verification.facialConfidence` | `0`–`100` |
| `verification.facialMatchSource` | `gov_record`, `chip`, `document`: which photo the selfie was compared with, null when no comparison ran. A `document` match used the photo printed on the ID, which is weaker evidence |
| `verification.documentPhotoNoFace` | `true` when the photo printed on the document was the only photo to compare the selfie with, no face could be read on it, and your workflow sends that case to review. Null unless the checks passed |
| `verification.age` | integer, when the date of birth is known |
| `verification.documentExpired` / `.documentExpiresInDays` | — |
| `verification.cameraSuspect` | `true` when capture-integrity signals flag injection |
| `verification.poaStatus` / `.poaNameMatch` / `.poaDocumentAgeDays` | Proof-of-Address result |
| `verification.nfcStatus` / `.nfcAuthentic` / `.nfcDataMatch` | NFC chip result |
**`entity.*` / `identity.*`**: the person across your org and the network
`entity.exists`, `entity.riskTier` (`LOW`/`MEDIUM`/`HIGH`), `entity.riskScore`, `entity.disposition`, `entity.status`, `entity.kycProvenance`, `identity.exists`, `identity.trustState` (`VERIFIED`/`FLAGGED`/`UNVERIFIED`, the cross-org network signal).
**`screening.*`**: sanctions / PEP / adverse media / crypto wallets
`screening.active`, `screening.pending`, `screening.anyMatch`, `screening.confirmed`, `screening.sanctions.status`, `screening.pep.status`, `screening.adverseMedia.status`, `screening.wallet.status` (null when no wallets are attached), `screening.maxMatchScore`.
**`device.*` / `ip.*`**: Device Intelligence
`device.matchedEntities` (multi-accounting count), `device.emulator`, `ip.country`, `ip.type`, `ip.datacenter`, `ip.countryMismatch`, `ip.velocity1h`.
**`email.*` / `phone.*`**: contact verification
`email.verified`, `email.disposable`, `email.freeProvider`, `email.domain`, `phone.verified`, `phone.country`, `phone.countryMismatch`, `phone.lineType` (`mobile`, `fixed_line`, `voip`, `toll_free`, and similar; null when the number range is unknown), `phone.virtual` (true only for a known VoIP range), `phone.matchedVerifications` (how many other verifications in your organisation verified the same number, a soft reuse signal) and `phone.attempts` (codes entered before the one that verified).
**`business.*` / `keyPeople.*`**: KYB
`business.companyStatus`, `business.nameMatch`, `business.addressMatch`, `business.ageYears`, `keyPeople.anyFlagged`, `keyPeople.allCleared`, `keyPeople.cddComplete`, `keyPeople.kycComplete`, `keyPeople.kycFailed`, `keyPeople.corporate`, `keyPeople.ownershipUnresolved`, and more, for [business workflows](https://trust.myaza.co/documentation/workflows/markdown#business-kyb-workflows). The key-people roll-up is listed in full in [Key people & ownership](https://trust.myaza.co/documentation/key-people/markdown#what-you-receive).
**`questionnaire.`**: the answers to any [questionnaire](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) the flow asks, keyed by question.
## The `workflow.run.completed` webhook
When a run reaches a terminal outcome, you receive:
```json
{
"event": "workflow.run.completed",
"data": {
"runId": "wfr_01j9xyz…",
"workflowId": "wf_AbC123dEf456",
"workflowVersion": 3,
"verificationId": "ver_01j9xyz456",
"entityId": "ent_01j9…",
"outcome": "approve",
"tags": ["network-verified"],
"environment": "PRODUCTION",
"completedAt": "2026-07-12T12:00:00.000Z"
}
}
```
Related events:
- **`workflow.action`**: fired by a `webhook` action node mid-graph (`{ runId, workflowId, verificationId, entityId, nodeId, note }`).
- **`workflow.run.failed`**: the engine could not finish a run (a broken graph or crash loop). This is an engine fault, **not** a decline, so never treat it as one.
- **`verification.status_updated`**: fires alongside `workflow.run.completed`, with `source: "workflow"`, whenever the verdict moves the verification's top-line status (for example from `processing` to `in_review`). The same event covers a decision made by a reviewer or by your backend, so one handler can keep your record current whoever decided. See [Status changes](https://trust.myaza.co/documentation/webhooks/markdown#status-changes).
See [Webhooks](https://trust.myaza.co/documentation/webhooks/markdown) for signing, retries, and the full event list.
## Notes
- The graph is **immutable per run**: a run is pinned to the workflow version and graph snapshot it started with, so re-publishing never changes an in-flight decision.
- A **screening** node waits only for the *first* resolution; later re-screens are handled by ongoing [monitoring](https://trust.myaza.co/documentation/monitoring-rules/markdown), not re-decisioning.
- For KYB, the key-people gate holds the **decision**, never the business verification itself. The registry result is terminal the moment it's done; the onboarding decision waits for the people.
---
# SDKs
## Server SDK for Risk Intelligence
Use `@myazahq/trust-sdk` in a Node.js backend for transaction and activity
decisions, customer risk assessment, monitoring, Travel Rule, Investigations and
webhooks. It requires a secret key and never belongs in browser or mobile code.
```bash
npm install @myazahq/trust-sdk
```
```js
import { Myaza } from '@myazahq/trust-sdk';
const myaza = new Myaza({
apiKey: process.env.MYAZA_SECRET_KEY,
environment: 'sandbox',
});
```
Follow the [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown) for a
copy-paste transaction decision and the supported customer Entity bootstrap.
## Client SDKs for identity verification
Myaza provides drop-in client SDKs that render the full verification UI (ID selection, document scan, and active liveness) and call the verification API for you. They are thin UI layers: they capture the user's data and media, [upload](https://trust.myaza.co/documentation/api-upload/markdown) it, and [create the verification](https://trust.myaza.co/documentation/api-create-verification/markdown). The SDK uses a **publishable (`pk_`) key**, safe to ship in client code. Results arrive **asynchronously** on your backend via [webhooks](https://trust.myaza.co/documentation/webhooks/markdown), or fetch the [full result](https://trust.myaza.co/documentation/api-verification-result/markdown) with a **secret (`sk_`) key** from your backend (the publishable key can only poll [minimal status](https://trust.myaza.co/documentation/api-verification-status/markdown)).
| SDK | Package | Platform |
|---|---|---|
| Web | `@myazahq/kyc-sdk-react` | React (≥ 18) |
| React Native | `@myazahq/kyc-sdk-react-native` | React Native / Expo (SDK ≥ 56) |
| Flutter | `myaza_kyc_sdk_flutter` | Flutter / Dart |
All three SDKs share the same **core flow** and call the same endpoints. The SDK detects the environment **automatically from the API key prefix** and resolves the base URL itself: `pk_test_*` → sandbox, `pk_live_*` → production. There is no `environment` option. Some newer capabilities are **web SDK only** for now; see [Feature availability](#feature-availability).
## Configure with a workflow first
There are two ways to tell an SDK what to run, and they are not equal:
1. **Mount a workflow (the recommended path).** Build the flow in the dashboard as a [workflow](https://trust.myaza.co/documentation/workflows/markdown), covering countries and ID types, capture steps, add-ons (contact OTP, proof of address, NFC, questionnaire), branding, copy, and server-side [decisioning](https://trust.myaza.co/documentation/decisioning/markdown), then mount it by id:
```tsx
```
The SDK resolves the whole configuration from the server on mount. Changing the flow is a **re-publish, not a redeploy**, and on mobile not an app-store release. Every published workflow can also be shared as a [hosted link](https://trust.myaza.co/documentation/workflows/markdown#2-share-a-hosted-link) with zero integration at all.
2. **Configure via props (secondary).** Spell the flow out in code (`country`, `idTypes`, step toggles, `appearance`, copy). Fine for a quick experiment or a single fixed flow, but every change means shipping code, and the add-on steps are far easier composed in the builder.
When both are present, **the workflow's configuration wins over any overlapping props**. Runtime data (`userId`, `userData`, `metadata`, and your callbacks) always stays in code: a workflow is a shared template and cannot carry per-user values. Everything below that describes a prop also exists as a builder setting on the workflow; treat the props as the code-side equivalents.
## Platform guides
Each SDK has its own integration guide. Pick your platform:
- **[Web SDK (React)](https://trust.myaza.co/documentation/sdk-react/markdown)**: `` button + modal flow for React (≥ 18).
- **[React Native SDK](https://trust.myaza.co/documentation/sdk-react-native/markdown)**: on-device native liveness; needs a custom dev build (**not Expo Go**).
- **[Flutter SDK](https://trust.myaza.co/documentation/sdk-flutter/markdown)**: `MyazaKYC.show()` modal sheet for Flutter / Dart.
The rest of this page covers what's **shared across all three**: supported countries, branding, consent/success copy, handling results, errors, and resilience.
> The React Native SDK ships native code (on-device liveness via Apple Vision + Google ML Kit), so it needs a custom dev/native build. **It does not run in Expo Go.** See the [React Native SDK](https://trust.myaza.co/documentation/sdk-react-native/markdown) guide.
## Feature availability
All three SDKs are at **full feature parity**: the core flow (document capture, selfie, active liveness, consent/success copy, branding, callbacks) and every capability on top of it:
| Capability | Web (`2.8.x`) | React Native (`2.1.x`) | Flutter (`2.2.x`) |
|---|---|---|---|
| Core flow (docs · selfie · liveness · branding · copy) | ✅ | ✅ | ✅ |
| [Workflows](https://trust.myaza.co/documentation/workflows/markdown): `workflowId` embed | ✅ | ✅ | ✅ |
| Capture add-ons: contact OTP, proof of address, questionnaire | ✅ | ✅ | ✅ |
| Business (KYB) verification | ✅ | ✅ | ✅ |
| Global & multi-region countries | ✅ | ✅ | ✅ |
| NFC chip read (eMRTD) | preview only | ✅ | ✅ |
The **NFC chip read** is an on-device capability: browsers can't talk to a passport chip, so the web SDK renders the chip screen only for the dashboard builder preview, while the native SDKs read chips for real. The read is best-effort by design: a phone without NFC, or a chip that won't read, continues the flow (see [ID types](https://trust.myaza.co/documentation/id-types/markdown)). [Hosted workflow links](https://trust.myaza.co/documentation/workflows/markdown#2-share-a-hosted-link) remain the zero-integration option on any platform.
## Supported countries & ID types
Mounting a [workflow](https://trust.myaza.co/documentation/workflows/markdown)? Pick the countries and ID types in the builder, with nothing to pass in code. For prop-configured mounts, pass a subset via `idTypes` to limit what the user can pick, or omit it to offer everything enabled for your organisation in that country. Only types valid for the selected country are accepted.
All three SDKs use the **same** kebab-case `idTypes` keys (Flutter takes them as plain strings; there is no enum):
| Country | `idTypes` keys (all SDKs) |
|---|---|
| `NG` Nigeria | `bvn`, `nin`, `vnin`, `passport`, `drivers-license`, `pvc` |
| `GH` Ghana | `ghana-card`, `voters`, `drivers-license`, `ssnit`, `passport` |
| `KE` Kenya | `national-id`, `passport` |
| `ZA` South Africa | `national-id` |
| `CI` Côte d'Ivoire | `cni`, `residence-card` |
See [ID types](https://trust.myaza.co/documentation/id-types/markdown) for the document requirements (capture vs. number-only, scan sides) of each.
> The five countries above are the **government-database-backed** markets. All three SDKs additionally verify **any ISO country** via document capture ([Global Documents](https://trust.myaza.co/documentation/id-types/markdown)): pass any two-letter country code, and the ID types your organisation is granted for it are offered automatically (grants are enforced server-side).
---
## Branding & theming
In a [workflow](https://trust.myaza.co/documentation/workflows/markdown), set all of this in the builder's **Appearance** panel. Every embed and hosted link inherits it, and a re-publish updates it everywhere with no redeploy. The `appearance` object below is the code-side equivalent for prop-configured mounts (a workflow's values win on overlap).
All three SDKs accept an `appearance` object to match the flow to your brand. The same fields exist on each platform: React and React Native take CSS-style colour strings (e.g. `"#5645F5"`); Flutter takes `Color` values. On the web, the colours are injected as CSS variables **scoped to the SDK** (they never leak into your page); setting one colour cascades to all of its shades. React Native uses the identical field names and string values.
| Field | React type | Flutter type | Description |
|---|---|---|---|
| `primaryColor` | `string` | `Color?` | Brand colour: buttons, selected states, focus rings, the shield hero. |
| `primaryTextColor` | `string` | `Color?` | Text/icons rendered on top of `primaryColor` (e.g. button labels). |
| `accentColor` | `string` | `Color?` | Subtle hover/active surfaces. |
| `backgroundColor` | `string` | `Color?` | Modal/sheet background. |
| `surfaceColor` | `string` | `Color?` | Cards & panels. |
| `borderColor` | `string` | `Color?` | Borders and input outlines. |
| `textColor` | `string` | `Color?` | Primary text colour. |
| `companyName` | `string` | `String` | Shown on the trigger button and the persistent header. |
| `logo` | `string` | `String?` | Image URL, or `'default'` for your dashboard logo (see below). |
| `theme` | `'light' \| 'dark'` | `MyazaThemeMode` | Initial light/dark mode. When `showThemeToggle` is enabled, users can flip it; otherwise the flow stays on this value. |
Flutter also accepts `logoAsset` (a bundled `Image.asset` path) as a fallback when `logo` is not set.
### Logo
The logo renders as a small circular avatar in the header (top-left), persistent on every step, next to `companyName`.
- **An image URL** (`"https://…/logo.png"`) is used directly.
- **`'default'`** uses the logo configured for your organisation under **Settings → Organization** in the dashboard. The SDK fetches it from [`GET /config`](https://trust.myaza.co/documentation/api-config/markdown) (`branding.logo`) on mount. If your org has no logo set, or the image fails to load, it falls back to the built-in shield icon.
- **Omitted** shows no header logo.
```tsx
// React — brand with your dashboard logo and a custom palette
appearance={{
primaryColor: "#0F7B6C",
primaryTextColor: "#FFFFFF",
surfaceColor: "#F4F7F6",
logo: "default",
theme: "light",
}}
```
## Consent screen copy
The first screen of the flow (the welcome/consent step) shows a heading and a short description. Both default to Myaza's built-in copy. In a [workflow](https://trust.myaza.co/documentation/workflows/markdown), override them in the builder's **Consent** panel; the `consent` object below is the code-side equivalent, with identical fields on every SDK.
| Field | React type | Flutter type | Description |
|---|---|---|---|
| `title` | `string` | `String?` | Heading. Defaults to `Welcome, {firstName}` when a first name is known, otherwise `Identity Verification`. |
| `description` | `string` | `String?` | Sub-text under the heading. Defaults to the built-in regulatory copy. |
Both fields support `{firstName}` and `{lastName}` tokens, which are replaced with the values you pass in `userData` (an empty string when absent), so a custom title can still greet the user by name.
```tsx
// React
consent={{
title: "Welcome, {firstName}",
description: "We just need to confirm it's really you. This takes about a minute.",
}}
```
```dart
// Flutter
consent: const KYCConsentContent(
title: 'Welcome, {firstName}',
description: "We just need to confirm it's really you. This takes about a minute.",
),
```
Omit `consent` (or either field) to keep the defaults.
## Success screen copy
The final screen, shown after the user submits, displays a confirmation heading and a short description. Both default to Myaza's built-in copy. In a [workflow](https://trust.myaza.co/documentation/workflows/markdown), override them in the builder's **Success** panel; the `success` object below is the code-side equivalent, with identical fields on every SDK.
| Field | React type | Flutter type | Description |
|---|---|---|---|
| `title` | `string` | `String?` | Heading. Defaults to `Verification Submitted!`. |
| `description` | `string` | `String?` | Sub-text under the heading. Defaults to the built-in "submitted for review" copy. |
Both fields support the same `{firstName}` / `{lastName}` tokens as `consent`, which are replaced with the values you pass in `userData` (an empty string when absent).
```tsx
// React
success={{
title: "You're all set, {firstName}!",
description: "We'll email you once your verification is reviewed.",
}}
```
```dart
// Flutter
success: const KYCSuccessContent(
title: "You're all set, {firstName}!",
description: "We'll email you once your verification is reviewed.",
),
```
Omit `success` (or either field) to keep the defaults. This screen is purely cosmetic; the verification outcome still arrives asynchronously (see [Handling results](#handling-results)).
## Handling results
The SDK finishing only means the verification was **created**, never that it passed. Treat the outcome as eventual:
1. The SDK uploads media and creates the verification, then invokes `onSubmit` with a `KYCSubmission`.
2. Your **backend** receives the terminal [`verification.*` webhook](https://trust.myaza.co/documentation/webhooks/markdown) (or fetches the [full result](https://trust.myaza.co/documentation/api-verification-result/markdown) with a secret key; the SDK's publishable key can only poll [minimal status](https://trust.myaza.co/documentation/api-verification-status/markdown)).
3. You update the user's state from that authoritative result.
The `onSubmit` payload (`KYCSubmission`) carries:
```jsonc
{
"verificationId": "ver_…", // use this to correlate the webhook / status poll
"status": "processing", // always 'processing' at this point
"metadata": { "requestId": "order_1001" },
"submittedAt": "2026-04-27T12:00:00.000Z"
}
```
> Always pass a stable `requestId` in `metadata` so retries are [idempotent](https://trust.myaza.co/documentation/verifications/markdown#idempotency).
## Errors
`onError` fires for **technical** failures only, never for a failed verification, which arrives asynchronously via webhook. The error carries a stable `code` (the same set across all three SDKs):
| Code | Meaning |
|---|---|
| `network_error` | Connection failure or timeout, **after automatic retries are exhausted**. |
| `invalid_api_key` | Server returned `401`; check the key (its prefix selects the environment automatically). |
| `insufficient_credits` | Server returned `402`. `details` includes `{ required, balance, currency }` for your own logging; the message shown to the end user deliberately names no figure and no cause, since your balance is not something they can act on. Verifications normally pause rather than reaching this. |
| `upload_failed` | Media [upload](https://trust.myaza.co/documentation/api-upload/markdown) failed, **after automatic retries are exhausted**. |
| `camera_permission_denied` | The user denied (or the OS/browser blocks) camera access. |
| `feature_disabled` | Server returned `403`; the ID type or a verification feature isn't enabled for your organisation. |
| `unknown` | Anything else. |
All SDKs deliver the same typed error: in React and React Native `onError` receives a `KYCError`, a real `Error` (so existing `(error: Error)` handlers keep working) that also carries `code` and optional `details`; in Flutter it receives a `KYCError` with `code`, `message`, and optional `details`. See [Errors](https://trust.myaza.co/documentation/errors/markdown) for the API-level error model.
> Voice guidance is text-to-speech **output**; it never records audio, so there is **no microphone permission** and no microphone error code.
## Resilience & capture quality
All three SDKs handle real-world conditions so the flow doesn't crash, hang, or capture unusable media:
- **Network retries.** Media uploads and the verify submission retry transient failures (network / timeout / `5xx`) with exponential backoff + jitter; terminal `4xx` surface immediately. The UI shows "Reconnecting… / retrying (n/3)…", and `onError` fires only **after** retries are exhausted.
- **Camera permission.** A denied camera shows a clear "camera access needed" screen (with how to re-enable it: an *Open Settings* action on Flutter & React Native) and reports `camera_permission_denied`. Document capture keeps a gallery-upload fallback on that screen as an escape hatch.
- **Multiple faces.** During liveness, if more than one face is in frame the challenge pauses ("Make sure only your face is visible") and resumes automatically when only one remains, guarding capture quality and a class of spoofing.
- **Lighting.** Too-dark *and* too-bright (glare) conditions are detected live and surfaced as guidance ("Move to a brighter area" / "Too bright, reduce glare"); auto-capture is discouraged until lighting is acceptable.
- **Liveness is on-device & native** on mobile: Apple Vision (iOS) + Google ML Kit (Android) on both React Native and Flutter; the web SDK uses MediaPipe in the browser. The selfie is **auto-captured** once the challenges pass (you can't trigger it on a static image), and a short liveness video is recorded for server-side review.
For the full prop reference and styling options, see each package's README.
---
# Web SDK (React)
The Web SDK is a drop-in React component that renders the full verification UI (ID selection, document scan, and active liveness) and calls the verification API for you. It uses a **publishable (`pk_`) key** and detects the environment automatically from the key prefix (`pk_test_*` → sandbox, `pk_live_*` → production). For shared concepts (supported countries, branding, results, and errors), see [Client SDKs](https://trust.myaza.co/documentation/sdks/markdown).
## Install
```bash
pnpm add @myazahq/kyc-sdk-react
```
```bash
yarn add @myazahq/kyc-sdk-react
```
```bash
npm install @myazahq/kyc-sdk-react
```
## Usage
`` renders a "Verify Identity" button plus the full modal flow. The trigger is a real `