# 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 NSLocationWhenInUseUsageDescription Used to confirm your address during identity verification. NSLocationAlwaysAndWhenInUseUsageDescription Lets 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 `; } export default () => ( ); ``` The flow advances through `KYCStep` values, reported via `onStepChange` / `currentStep`. The core path is `consent` → `id-type` → (`id-input` for number-only IDs, or `document-capture`) → `liveness` → `submitted`. Steps enabled by a [workflow](https://trust.myaza.co/documentation/workflows/markdown) slot in automatically: `email-verification` / `phone-verification` (right after consent), `country-select` (multi-region), `proof-of-address`, `questionnaire`, and the KYB steps (`business-details`, `business-key-people`, `business-documents`, `applicant-role`). --- # React Native SDK The React Native SDK mirrors the [web SDK](https://trust.myaza.co/documentation/sdk-react/markdown)'s **core** API (same props, same callbacks) but runs **on-device, native liveness**: Apple Vision on iOS and Google ML Kit on Android, via a [react-native-vision-camera](https://react-native-vision-camera.com) v5 (Nitro) frame processor. Because it ships native code, it needs a custom native build and **does not run in Expo Go**. For shared concepts (supported countries, branding, results, and errors), see [Client SDKs](https://trust.myaza.co/documentation/sdks/markdown). > **Feature availability.** This SDK is at **full parity** with the web and Flutter SDKs: [workflow](https://trust.myaza.co/documentation/workflows/markdown) embedding (`workflowId`), the capture add-ons (contact OTP, proof of address, questionnaire), business (KYB) verification, any [Global Documents](https://trust.myaza.co/documentation/id-types/markdown) country, plus **[NFC chip reading](https://trust.myaza.co/documentation/nfc-chip/markdown)**, which the web SDK can't do at all (browsers can't talk to a passport chip). See [Feature availability](https://trust.myaza.co/documentation/sdks/markdown#feature-availability). ## Requirements | Requirement | Minimum | |---|---| | **iOS** deployment target | **15.1** | | **Android** `minSdkVersion` | **24** (Android 7.0) · `compileSdk` 34 · NDK 27.1 | | **Expo SDK** | **56** (React 19, React Native 0.85) | | **React Native** | **0.83+**, with the **New Architecture enabled** (VisionCamera v5 / Nitro requires it; Expo SDK 56 enables it by default) | | **Build toolchain** | Xcode + CocoaPods (iOS) · **JDK 17** for Android Gradle builds | | **Runtime** | A **dev client** or bare build, **not Expo Go** | Peer dependencies to install in your app: `expo` (≥56), `react` (≥19), `react-native` (≥0.83), `react-native-vision-camera` (v5), `react-native-vision-camera-worklets` (≥5), `react-native-worklets` (≥0.8), `react-native-nitro-modules` (≥0.35), `react-native-nitro-image` (≥0.15), `react-native-safe-area-context` (≥4), `react-native-svg` (≥15). ## Install Pick the path that matches your project. ### Expo app (managed / prebuild, recommended) ```bash npx expo install @myazahq/kyc-sdk-react-native \ react-native-vision-camera react-native-vision-camera-worklets \ react-native-worklets react-native-nitro-modules react-native-nitro-image \ react-native-safe-area-context react-native-svg ``` Add the config plugin to `app.json` (it adds the iOS camera-usage string and Android `CAMERA` / `INTERNET` permissions), then build a dev client: ```jsonc // app.json { "expo": { "plugins": [ "@myazahq/kyc-sdk-react-native" ] } } ``` VisionCamera v5 ships **no config plugin** (v4 did), so it takes no `plugins` entry. Listing it makes `expo prebuild` fail with `Cannot find module '.../lib/VisionCamera'`, because Expo loads the package's main entry as a plugin. The camera permission and usage strings come from the Myaza plugin; pass `cameraPermission` to change the iOS wording. ```bash npx expo prebuild npx expo run:ios # or: npx expo run:android ``` The SDK plugin accepts an optional custom camera prompt: `["@myazahq/kyc-sdk-react-native", { "cameraPermission": "Your message…" }]`. It also adds the **location** permission strings by default (the [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown) step's "Use my current location" shortcut and attest fix; foreground only, and never required to finish the flow) — pass `{ "location": false }` to opt out if none of your workflows collect an address, or `{ "locationPermission": "Your message…" }` to customise the iOS prompt. ### Optional modules Four `expo-*` modules are **optional peers**. The SDK loads each one lazily and carries on without it, so nothing crashes if you skip them, but each one is a capability rather than a detail: | Module | What installing it buys | Without it | |--------|------------------------|-----------| | `expo-device` | Make, model, manufacturer and physical-vs-simulator in the device metadata | Those fields are omitted and the device class is guessed from the platform, so [Device Intelligence](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) has a weaker fingerprint and shared-device detection suffers | | `expo-application` | Your app's id, version and build number on the submission | The `app` block is omitted entirely, so a result cannot be traced to the build that produced it | | `expo-localization` | The device's region, explicitly | Country defaults and the reported locale fall back to the JS runtime's locale, which often carries no region at all (`en` rather than `en-NG`) | | `expo-document-picker` | "Choose a file" on the proof-of-address and KYB document steps | Those steps accept a camera capture only, so a PDF bank statement cannot be submitted at all | ```bash npx expo install expo-device expo-application expo-localization expo-document-picker ``` `react-native-webview` is an **optional** peer. With it installed, the address step renders the Google map and the Street View entrance framer inside your app; without it the built-in map and the entrance photo remain, and nothing else changes. Address fields the workflow marks required are enforced in the app (Continue opens the details sheet naming what is still needed), so a submission is never refused for a missing field at the end of the flow. ### Bare React Native app (no Expo prebuild) The SDK uses a few `expo-*` modules, so add the Expo module runtime (you don't need the managed workflow), then install the SDK and its peers: ```bash # 1. One-time: add Expo modules to a bare RN app npx install-expo-modules@latest # 2. Install the SDK + peer dependencies npm install @myazahq/kyc-sdk-react-native \ react-native-vision-camera react-native-vision-camera-worklets \ react-native-worklets react-native-nitro-modules react-native-nitro-image \ react-native-safe-area-context react-native-svg \ expo expo-image-manipulator expo-image-picker expo-speech expo-font \ expo-glass-effect expo-crypto expo-location # 3. iOS pods cd ios && pod install && cd .. ``` Then add the native permissions manually (the config plugin only runs under prebuild): ```xml NSCameraUsageDescription We use the camera to photograph your ID and capture a live selfie. ``` ```xml ``` If your workflows use the [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown) step, also add the location strings (iOS crashes on the permission request without the usage string): ```xml NSLocationWhenInUseUsageDescription Your location helps place the map pin on your address. ``` ```xml ``` The native face-detector module (a VisionCamera v5 **Nitro** HybridObject plus its Android lib loader) is **autolinked**, so no manual linking is needed. Ensure the **New Architecture** is enabled and the worklets/frame-processor build is set up per VisionCamera's [setup docs](https://react-native-vision-camera.com/docs/guides) (it relies on `react-native-worklets` / `react-native-vision-camera-worklets`, installed above). ### Presence reporting (Address Intelligence) When a workflow enables [presence verification](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification), the SDK stores the confirmed pin on-device at capture. Call the reporter from your app on a natural moment (app open works well): ```tsx import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native'; const result = await reportAddressPresence({ apiKey: 'pk_live_…', externalUserId: 'user_42', // the same userId the KYC flow ran with }); // result.reason: 'reported' | 'no_pin' | 'services_off' | 'no_fix' | 'outside_fence' | 'no_watch' | 'network_error' ``` You can also call it from `onSubmit`, the moment the flow finishes: the watch is created a few seconds after a submission is accepted, so when the pin was captured within the last 15 minutes the reporter waits for it (up to 90 seconds) before posting. `no_watch` means nothing is monitoring this user right now, so there was nothing to report to. It never throws and never blocks startup. The geofence is evaluated **on-device**: only the derived day + night flag is transmitted, never a coordinate. A fix outside the fence sends nothing; a mock-location fix is reported flagged. `clearPresencePin(externalUserId)` drops the stored pin (sign-out, or after the watch resolves). Stored pins self-expire after 45 days for bounded checks; a pin captured under [always-on monitoring](https://trust.myaza.co/documentation/address-intelligence/markdown#always-on-monitoring) never expires until revoked. ### Background monitoring (OS geofencing) The stronger tier: the OS wakes the SDK on fence crossings around the stored pin, app closed or not. Entries stamp a timestamp; exits fold the dwell into per-day aggregates on-device and flush them — the same privacy floor as the foreground tier. Three opt-ins, each deliberate: 1. Install the optional peer: `npx expo install expo-task-manager` (without it the background tier simply does not exist). 2. Declare background location via the config plugin — `["@myazahq/kyc-sdk-react-native", { "location": "always" }]`. This changes your app's store review posture; 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. 3. Register the task at your app's **root module** (before the component tree), then enable after capture: ```tsx // index.js import { registerBackgroundPresence } from '@myazahq/kyc-sdk-react-native'; registerBackgroundPresence(); // later, once the KYC flow has stored a pin: const result = await enableBackgroundPresence({ apiKey: 'pk_live_…', externalUserId: 'user_42' }); // result.reason: 'enabled' | 'module_missing' | 'no_pin' | 'foreground_denied' | 'background_denied' | 'start_failed' ``` `disableBackgroundPresence()` disarms the fence. A refusal at any step leaves the foreground tier working exactly as before: the tiers degrade, never break. ### The Android foreground service On phones whose battery managers drop geofence transitions (Tecno, Infinix, Xiaomi and friends), a persistent notification is what keeps the check alive. Opt-in, Android only, on the same `location: "always"` plugin setting, which also declares the `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_LOCATION` permissions. `registerBackgroundPresence()` at the root already defines its task: ```tsx const result = await enableForegroundService({ apiKey: 'pk_live_…', externalUserId: 'user_42', notification: { title: 'Address verification in progress', body: 'Open the app to see your progress' }, }); // result.reason: 'enabled' | 'unsupported_platform' | 'module_missing' | 'no_pin' | 'foreground_denied' | 'background_denied' | 'start_failed' ``` A low-power fix every ten minutes becomes the same enter/exit spans the geofence folds, on the same stored state; the queue flushes while the process is alive; a dropped fence is re-armed. `disableForegroundService()` stops it. See [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#the-android-foreground-service) for the reasoning. ### Which tier is running? Permissions get revoked in Settings and nothing tells the app. Ask, and offer the only honest road back: ```tsx import { presenceStatus, openLocationSettings } from '@myazahq/kyc-sdk-react-native'; const status = await presenceStatus('user_42'); // status.tier: 'background' | 'foreground' | 'none', plus pinStored, alwaysOn, // locationServicesEnabled, both permission states, geofenceArmed, foregroundServiceRunning if (!status.locationServicesEnabled) { await openLocationSettings('services'); // the phone's location toggle is off } else if (status.tier === 'none' && status.pinStored) { await openLocationSettings(); // no OS allows re-prompting in-app after a denial } ``` To show the person where the check stands, poll `GET /api/kyc/address/presence/:externalUserId` with the publishable key; see [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#showing-the-person-where-the-check-stands). > Voice guidance is text-to-speech **output**. The SDK never records audio, so **no microphone permission** is requested or required (`enableMicrophonePermission: false`). ## Usage `` renders a "Verify Identity" trigger plus the full-screen flow. The API is identical to the web SDK, with the same props and callbacks. Unlike the web SDK there is **no stylesheet to import**: styling is built in. ### Recommended: mount a workflow Build the flow once in the dashboard as a [workflow](https://trust.myaza.co/documentation/workflows/markdown), then mount it by id. The country, ID types, capture steps, branding and copy all come from the workflow, so changing the flow is a re-publish rather than an app release, which matters even more on mobile, where a redeploy means an app-store round trip. Requires **≥ 2.1.0** (the `2.0.x` line silently ignores the id). ```tsx import { MyazaKYC } from "@myazahq/kyc-sdk-react-native"; export default function VerifyScreen() { return ( console.log("submitted", submission.verificationId)} onError={(err) => console.error(err.code, err.message)} onClose={() => console.log("closed")} > Verify Identity ); } ``` **`userData` is worth passing.** It is the name you believe the user has, and it is compared against the name read off their document, and that comparison is what produces `dataMatch` on the verification. Leave it out and the check simply never runs: there is nothing to compare the document against, and `dataMatch` comes back `null`. It cannot live on the workflow. `userId`, `userData` and `metadata` are per-user runtime values, and a workflow is a template shared by every visitor, so these stay in code even when everything else moves to the dashboard. ### Or configure everything in code Skip the workflow and pass the flow's shape as props. Useful for a quick start or a single fixed flow; anything you'd change later means an app release. ```tsx import { MyazaKYC } from "@myazahq/kyc-sdk-react-native"; export default function VerifyScreen() { return ( { // The verification was created; status is always 'pending'. // Reconcile the final result on your backend via webhook or a secret-key // GET /verifications/:id call (never from the client). console.log("submitted", submission.verificationId); }} onError={(err) => console.error(err.code, err.message)} onClose={() => console.log("closed")} > Verify Identity ); } ``` The SDK accepts the **same props** as the [web SDK's props table](https://trust.myaza.co/documentation/sdk-react/markdown#props), including `workflowId`, `livenessMode`, `deviceIntelligence`, `consentStep` (skip the welcome screen when your app has already asked for consent), and the capture add-ons (contact OTP, proof of address, questionnaire, NFC). The one exception is `className` (React Native has no class names); style the trigger by passing `style`, or render your own trigger with the hook. > `disableClose` blocks user dismissal on native too: the iOS swipe-down and the Android back button. Because the built-in `` trigger has no external close handle, pair `disableClose` with the [`useMyazaKYC()` hook](#programmatic-control) and call its `close()` to dismiss the flow yourself. ### Biometric re-authentication A workflow on the **biometric-authentication** scope confirms it is really your user, matching a live selfie against the face you enrolled. Three options on the workflow, plus your own words for its screens, shape how the React Native flow behaves (the web and Flutter SDKs honour the same three). All three live on the workflow's **Presence Intelligence** panel in the builder, under **Flow**, so they need no code change; a prop-configured mount can pass the same `biometric` block. - **Where the verdict lands**: **In the app and on your webhook** (the default) keeps the person on one loading screen from the moment the selfie is taken until the check settles, polling the status endpoint for up to a minute, then shows whether it was them; your app hears the verdict in `onResult` and your webhook receives it as on every other flow. **In the app only** is the same wait, but the server sends no webhook for that check at all (`verification.started` and the terminal events included), so the verdict is your app's alone. **Webhook only** shows the ordinary submitted screen and leaves the verdict to your webhook. Enrolment workflows record the reference and have no verdict to deliver, so they never wait. - **Selfie review**: whether the captured selfie is shown back with Retake and Continue before it is sent. **Off by default** on the biometric scopes: a face check is a few seconds long, and a review screen is a stop in the middle of it. Turn it on when you want the person to approve the photo first. - **Done button**: whether the final screen carries a Done button. **On by default.** Turn it off when your app closes the flow itself from `onResult` (or from `onSubmit` on a webhook delivery); the screen then stays until your app dismisses the SDK. - **Screen copy**: your own title and description for the screens the person sees, on the workflow's Presence Intelligence panel under **Face check screens**: the loading screen while the check runs ("Checking it's you") and, on a verdict delivered in the app, the verified screen ("You're verified") and the not-verified screen ("We couldn't confirm it's you"). Leave a field empty to keep the default; `{firstName}` and `{lastName}` fill from the `userData` you pass, as on the welcome and success screens. A not-verified description replaces the reason the server sends. Enrolment offers the loading screen only. In code the same block is `biometric.copy` (`waiting`, `verified`, `declined`, each with `title` and `description`). When the flow waits, the SDK fires a callback with the verdict: ```tsx console.log("submitted", s.verificationId)} onResult={(r) => { // r.status is the same vocabulary GET /api/kyc/status/:id serves: // 'approved' | 'declined' | 'in_review' | 'error' | … if (r.status === "approved") unlockSensitiveAction(); }} > Confirm it is you ``` `onSubmit` still fires the moment the check is submitted. `onResult` fires once when the wait settles, never when it times out, and carries the state and reason pair only; scores and other result data stay behind your secret key. Treat it as a courtesy to your UI: the webhook (where the flow sends one) and `GET /api/kyc/verifications/:id` remain the record, and a decision your app enforces should rest on those, not on a value a client reported. ## App size The SDK adds native machine learning to your app, and that is where the weight sits. The defaults are already the small ones, but two of the four levers below are yours to pull and they are worth more than everything the SDK does on its own. **On-device models are fetched, not bundled.** Face detection and text recognition both run on Google ML Kit on Android, and the SDK depends on the Play Services variants, which download their models on first use. Measured on a real integrator's release APK, the bundled pair cost **18.5 MB per device** (arm64: text 10.55 MB, face 7.95 MB) plus `.tflite` files in `assets/`, which ship to every device because assets are not split by architecture. Fetched, that is about 0.4 MB of shims. The SDK starts both downloads the moment the flow opens, so they overlap the consent and ID-type screens. If a model has not arrived by the time it is needed, the step says so rather than failing quietly: liveness waits and explains, and the MRZ scanner tells the person the printed code cannot be read and lets them continue without the chip. The trade is real. The Play Services variants need Google Play Services, so they do not work on Huawei or bare AOSP devices. If you ship to those, put this in your root `build.gradle` and you get fully-offline models back, at 18.5 MB per device: ```gradle ext { myazaKycBundledMlKit = true } ``` **Ship an App Bundle, or filter your architectures.** Native libraries dominate the rest of the download, and a universal APK carries every architecture at once. An `.aab` lets Play deliver only the one a device needs. If you must ship an APK, name the architectures your users actually have: ```gradle android { defaultConfig { ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' } } } ``` **Turn on R8 and resource shrinking.** The SDK ships its own consumer rules, so you do not need to work out which of its classes are constructed by name: ```gradle android { buildTypes { release { minifyEnabled true shrinkResources true } } } ``` **Use Expo SDK 54 or newer.** Its default template builds smaller than earlier ones, and the SDK's peer range assumes it. ## Programmatic control For a custom trigger, drive the flow with the `useMyazaKYC()` hook: ```tsx import { useMyazaKYC } from "@myazahq/kyc-sdk-react-native"; import { Pressable, Text } from "react-native"; function Trigger() { const { open, close, isOpen, currentStep } = useMyazaKYC({ apiKey: "pk_test_…", country: "NG", onSubmit: (s) => console.log(s.verificationId), }); return ( Verify ({currentStep ?? "idle"}) ); } ``` The flow advances through the same `KYCStep` values as the web SDK: `consent` → `id-type` → `id-input` → `document-capture` → `liveness` → `submitted`. --- # Flutter SDK The Flutter SDK opens the full verification flow as a modal sheet via `MyazaKYC.show()`, with **on-device native liveness** (Apple Vision on iOS, Google ML Kit on Android). 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). > **Feature availability.** This SDK is at **full parity** with the web and React Native SDKs: [workflow](https://trust.myaza.co/documentation/workflows/markdown) embedding (`workflowId`, **≥ 2.2.0**; earlier versions need a placeholder `country` to compile a workflow mount), the capture add-ons (contact OTP, proof of address, questionnaire), business (KYB) verification, any [Global Documents](https://trust.myaza.co/documentation/id-types/markdown) country, plus **NFC chip reading**, which the web SDK can't do at all (browsers can't talk to a passport chip). See [Feature availability](https://trust.myaza.co/documentation/sdks/markdown#feature-availability). ## Install Add the dependency to your `pubspec.yaml`, then run `flutter pub get`: ```yaml dependencies: myaza_kyc_sdk_flutter: ^2.2.0 ``` ## Requirements | Requirement | Minimum | |---|---| | **Flutter** | **3.27** (Dart **3.6**) | | **iOS** deployment target | **13.0** | | **Android** `minSdkVersion` | **21** (Android 5.0) · `compileSdk` 34 | Face detection runs **on-device** (Apple Vision on iOS, Google ML Kit on Android: an Android-only Gradle dependency, so there's no cross-platform ML Kit iOS pod and the SDK still builds on Apple-Silicon iOS simulators). Add the **camera** permission on both platforms (there is **no** microphone permission: voice guidance is text-to-speech output only): ```xml NSCameraUsageDescription We use the camera to photograph your ID and capture a live selfie. ``` ```xml ``` If your workflows use the [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown) step, also add the location strings. Both are best-effort (they power the "Use my current location" shortcut and the attest fix; the pin always works by dragging alone), but iOS **crashes** on the permission request if the usage string is missing: ```xml NSLocationWhenInUseUsageDescription Your location helps place the map pin on your address. ``` ```xml ``` The SDK bundles `webview_flutter`, which renders the Google map and the Street View entrance framer on the address step inside your app; when that page cannot load, the built-in map and the entrance photo remain. Address fields the workflow marks required are enforced in the app (Continue opens the details sheet naming what is still needed), so a submission is never refused for a missing field at the end of the flow. ### Presence reporting (Address Intelligence) When a workflow enables [presence verification](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification), the SDK stores the confirmed pin on-device at capture. Call the reporter from your app on a natural moment (app open works well): ```dart final result = await MyazaAddressPresence.report( apiKey: 'pk_live_…', externalUserId: 'user_42', // the same userId the KYC flow ran with ); // result.reason: reported | noPin | servicesOff | noFix | outsideFence | noWatch | networkError ``` You can also call it from `onSubmit`, the moment the flow finishes: the watch is created a few seconds after a submission is accepted, so when the pin was captured within the last 15 minutes the reporter waits for it (up to 90 seconds) before posting. `noWatch` means nothing is monitoring this user right now, so there was nothing to report to. It never throws and never blocks startup. The geofence is evaluated **on-device**: only the derived day + night flag is transmitted, never a coordinate. A fix outside the fence sends nothing; a mock-location fix is reported flagged. `clearPresencePin(externalUserId)` drops the stored pin. Stored pins self-expire after 45 days for bounded checks; a pin captured under [always-on monitoring](https://trust.myaza.co/documentation/address-intelligence/markdown#always-on-monitoring) never expires until revoked. ### Background monitoring (native geofencing) The stronger tier: the OS wakes the plugin's native side on fence crossings, app closed or not — on Android the fence survives reboots, and iOS relaunches the app for crossings by itself. Entries stamp; exits fold the dwell into per-day aggregates natively and flush them. Declare the background-location entries in **your own** manifest and Info.plist first (the plugin never adds them for you, because the declaration changes your store review posture — the [Background Location Declarations](https://trust.myaza.co/documentation/background-location-declarations/markdown) page carries the ready-to-paste texts). Then: ```dart final result = await MyazaBackgroundPresence.enable( apiKey: 'pk_live_…', externalUserId: 'user_42', ); // result.reason: started | noPin | permissionDenied | backgroundDenied | unavailable ``` `enable()` walks the two-step permission escalation (while-in-use, then "allow all the time"); `MyazaBackgroundPresence.disable()` disarms and forgets. A refusal leaves the foreground tier working exactly as before. ### The Android foreground service On phones whose battery managers drop geofence transitions (Tecno, Infinix, Xiaomi and friends), a persistent notification is what keeps the check alive. Opt-in and Android only. The plugin ships the service class; you declare it, with its two permissions, in your own manifest (the block is on the [Background Location Declarations](https://trust.myaza.co/documentation/background-location-declarations/markdown#the-android-foreground-service) page), then: ```dart final result = await MyazaPresenceService.enable( apiKey: 'pk_live_…', externalUserId: 'user_42', notification: const PresenceNotification( title: 'Address verification in progress', body: 'Open the app to see your progress', ), ); // result.reason: started | unsupportedPlatform | noPin | permissionDenied | backgroundDenied | notDeclared | unavailable ``` `enable()` walks the same permission escalation and arms the fence too. A low-power fix every ten minutes becomes the same enter/exit spans the geofence folds, natively, on the same stored state; the queue flushes while the process is alive; a dropped fence is re-armed; a reboot restarts it. `MyazaPresenceService.disable()` stops it. See [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#the-android-foreground-service) for the reasoning. ### Which tier is running? ```dart final status = await presenceStatus('user_42'); // status.tier: PresenceTier.background | foreground | none, plus pinStored, alwaysOn, // locationServicesEnabled, both permission states, geofenceArmed, foregroundServiceRunning if (!status.locationServicesEnabled) { await openLocationSettings(target: PresenceSettingsTarget.services); // the toggle is off } else if (status.tier == PresenceTier.none && status.pinStored) { await openLocationSettings(); // the only road back after a denial } ``` To show the person where the check stands, poll `GET /api/kyc/address/presence/:externalUserId` with the publishable key; see [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#showing-the-person-where-the-check-stands). ## Usage `MyazaKYC.show()` opens the full flow as a modal bottom sheet (a full-screen page on Android). Note that `context` is a **named** parameter, and the callbacks are passed to `show()` alongside `config`, not inside it. ### Recommended: mount a workflow Build the flow once in the dashboard as a [workflow](https://trust.myaza.co/documentation/workflows/markdown), then mount it by id. The country, ID types, capture steps, branding and copy all come from the workflow, so changing the flow is a re-publish rather than an app release, which matters more on mobile, where a redeploy means an app-store round trip. ```dart import 'package:flutter/material.dart'; import 'package:myaza_kyc_sdk_flutter/myaza_kyc_sdk_flutter.dart'; void startKYC(BuildContext context) { MyazaKYC.show( context: context, config: const MyazaKYCConfig( apiKey: 'pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', workflowId: 'wf_AbC123dEf456', // Runtime data — a workflow is a shared template and cannot carry any of it. userId: 'user_42', userData: UserData(firstName: 'Jane', lastName: 'Doe'), metadata: {'requestId': 'order_1001'}, ), onSubmit: (submission) => debugPrint('Submitted: ${submission.verificationId}'), onError: (error) => debugPrint('Error: ${error.code} — ${error.message}'), onClose: () => debugPrint('KYC closed'), ); } ``` **`userData` is worth passing.** It is the name you believe the user has, and it is compared against the name read off their document, and that comparison is what produces `dataMatch` on the verification. Leave it out and the check simply never runs: there is nothing to compare the document against, and `dataMatch` comes back `null`. It cannot live on the workflow. `userId`, `userData` and `metadata` are per-user runtime values, and a workflow is a template shared by every visitor, so these stay in code even when everything else moves to the dashboard. > `country` is optional here: the resolved workflow carries it, exactly as in the React SDK. Pass one only when you are not using a workflow. ### Or configure everything in code Skip the workflow and pass the flow's shape in `MyazaKYCConfig`. Useful for a quick start or a single fixed flow; anything you'd change later means an app release. ```dart import 'package:flutter/material.dart'; import 'package:myaza_kyc_sdk_flutter/myaza_kyc_sdk_flutter.dart'; void startKYC(BuildContext context) { MyazaKYC.show( context: context, config: MyazaKYCConfig( apiKey: 'pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', country: 'NG', idTypes: const ['bvn', 'drivers-license', 'passport'], userData: const UserData(firstName: 'Jane', lastName: 'Doe'), enableSelfie: true, enableDocumentCapture: true, enableLiveness: true, appearance: const MyazaKYCAppearance( primaryColor: Color(0xFF5645F5), companyName: 'Myaza', logo: 'default', theme: MyazaThemeMode.light, ), consent: const KYCConsentContent( title: 'Welcome, {firstName}', description: "A quick check to confirm it's really you.", ), success: const KYCSuccessContent( title: "You're all set, {firstName}!", description: "We'll email you once your verification is reviewed.", ), metadata: const {'requestId': 'order_1001', 'userId': 'user_42'}, ), onSubmit: (submission) { // The verification was created; submission.status is always 'pending'. // The final result arrives via webhook to your backend (or fetch it with a // secret-key GET /verifications/:id call, never from the client). debugPrint('Submitted: ${submission.verificationId}'); }, onError: (error) { // Technical errors only (network / 401 / 402 / upload). debugPrint('Error: ${error.code} — ${error.message}'); }, onClose: () => debugPrint('KYC closed'), ); } ``` ## Config (`MyazaKYCConfig`) | Field | Type | Default | Description | |---|---|---|---| | `apiKey` | `String` | — | **Required.** Sent as `Authorization: Bearer`. The **environment is derived from the key prefix** (`pk_test_*` → sandbox, `pk_live_*` → production); an unrecognised prefix throws. | | `workflowId` | `String?` | — | Run a published [workflow](https://trust.myaza.co/documentation/workflows/markdown) (`wf_…`) built in the dashboard. The SDK resolves its configuration on launch and uses it as the source of truth: **workflow config wins over overlapping fields**. Makes `country` optional. Requires **≥ 2.2.0**. | | `country` | `String?` | — | **Required unless `workflowId` is set.** ISO-2 country whose ID types are offered (`'NG'`, `'GH'`, …). Any ISO country works: the org's grants are enforced server-side. | | `countries` | `List?` | — | **Multi-region.** More than one entry inserts a country-select step; the picked entry's `idTypes` win. Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). | | `idTypes` | `List?` | all for country | Subset of [ID type](https://trust.myaza.co/documentation/id-types/markdown) keys to offer (`['bvn', 'passport']`, the same kebab-case keys as the React SDKs); `null` shows everything enabled for the country. | | `userId` | `String?` | — | Your stable reference for the person being verified: repeat checks of the same `userId` collapse onto one entity, and it's how you correlate results back to your record. | | `userData` | `UserData?` | — | Pre-fills the user's details. | | `enableSelfie` | `bool` | `true` | Capture a selfie during liveness. | | `enableDocumentCapture` | `bool` | `true` | Enable the document-scan step for document IDs. | | `allowDocumentUpload` | `bool` | `true` | Allow picking a document photo from the device gallery instead of the camera. `false` hides the "upload instead" option, except on the camera-permission-denied screen, where it stays as an escape hatch. | | `allowDocumentScan` | `bool` | `true` | Allow scanning the document with the live camera. `false` never opens the camera for documents: the applicant picks a photo of each side from the gallery instead. Keep at least one of `allowDocumentScan` and `allowDocumentUpload` on; with both `false` the camera stays on. Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). | | `enableLiveness` | `bool` | `true` | Run the liveness challenge step. The server can still disable it per ID type. | | `livenessMode` | `String` | `'gestures'` | How liveness proves presence: `'gestures'`, `'flash'` (screen-reflection), or `'both'`. Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). | | `deviceIntelligence` | `bool` | `true` | Collect device + IP fraud signals ([Device Intelligence](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons)). | | `consentStep` | `bool` | `true` | Show the consent (welcome) screen as the first step. `false` when your app has already asked for consent: the flow opens on its first real step. Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown). | | `biometric` | `BiometricFlowConfig?` | see [Biometric re-authentication](#biometric-re-authentication) | The biometric scopes' flow options: `selfieReview`, `resultDelivery` (`'both'` \| `'app'` \| `'webhook'`), `doneButton` and `copy` (your own words on the face check screens, see below). Usually set by a [workflow](https://trust.myaza.co/documentation/workflows/markdown); ignored off the biometric scopes. | | `voiceGuidance` | `VoiceGuidanceConfig` | enabled (`en-US`) | Spoken liveness instructions (accessibility, TTS **output**, no microphone). `VoiceGuidanceConfig.off` mutes it; `VoiceGuidanceConfig(language: 'fr-FR')` sets the voice language. | | `showThemeToggle` | `bool` | `true` | Show a light/dark toggle in the header. Set `false` to hide it. The flow then stays on `appearance.theme` and the user can't switch it. | | `disableClose` | `bool` | `false` | Hide the close (X) button and block **all** user dismissal (swipe-down drag, Android back, barrier tap). The flow can then only be closed programmatically by popping the route `MyazaKYC.show()` returns (its `Future` completes on close). The terminal "Submitted" step is non-dismissible regardless. | | `appearance` | `MyazaKYCAppearance?` | brand defaults | Brand & theme the flow: colours, logo, light/dark. See [Branding & theming](https://trust.myaza.co/documentation/sdks/markdown#branding-theming). | | `consent` | `KYCConsentContent?` | built-in copy | Override the consent/welcome screen `title` and `description`. See [Consent screen copy](https://trust.myaza.co/documentation/sdks/markdown#consent-screen-copy). | | `success` | `KYCSuccessContent?` | built-in copy | Override the success/submitted screen `title` and `description`. See [Success screen copy](https://trust.myaza.co/documentation/sdks/markdown#success-screen-copy). | | `metadata` | `Map?` | — | Forwarded with the verify request (include your `requestId`). | | `livenessConfig` | `LivenessConfig?` | 2 challenges, 8s each | Tune the liveness challenge sequence (see below). | `UserData` accepts `firstName`, `lastName`, `dateOfBirth`, `gender`, `address`, `phoneNumber` and `email` (all optional). `email` is never asked for in the flow: it is the address your organisation can have the applicant [emailed at about a decision](https://trust.myaza.co/documentation/api-review-verification/markdown#emailing-the-applicant). ## Callbacks Passed to `MyazaKYC.show()` alongside `config`: | Callback | Type | Description | |---|---|---| | `onSubmit` | `void Function(KYCSubmission)` | Called when the server accepts the verification. `status` is always `'pending'`. | | `onResult` | `void Function(KYCResult)` | Called once with the verdict on a flow that **waits** for it in the app (a biometric re-authentication on the default delivery). Never on a timeout. See [Biometric re-authentication](#biometric-re-authentication). | | `onError` | `void Function(KYCError)` | Called for **technical** errors only: receives a typed `KYCError` (`code`, `message`, optional `details`). Verification outcomes don't come through here. See [Errors](https://trust.myaza.co/documentation/sdks/markdown#errors). | | `onClose` | `void Function()` | Called when the user closes the flow. | ## Biometric re-authentication A workflow on the **biometric-authentication** scope confirms it is really your user, matching a live selfie against the face you enrolled. Three options on the workflow, plus your own words for its screens, shape how the Flutter flow behaves, exactly as on the web and React Native SDKs. All three live on the workflow's **Presence Intelligence** panel in the builder, under **Flow**, so they need no code change; a config can pass the same `biometric` block. - **Where the verdict lands**: **In the app and on your webhook** (the default) keeps the person on one loading screen from the moment the selfie is taken until the check settles, polling the status endpoint for up to a minute, then shows whether it was them; your app hears the verdict in `onResult` and your webhook receives it as on every other flow. **In the app only** is the same wait, but the server sends no webhook for that check at all, so the verdict is your app's alone. **Webhook only** shows the ordinary submitted screen and leaves the verdict to your webhook. Enrolment workflows never wait. - **Selfie review**: whether the captured selfie is shown back with Retake and Continue before it is sent. **Off by default** on the biometric scopes. - **Done button**: whether the final screen carries a Done button. **On by default.** Turn it off when your app pops the sheet itself from `onResult`; the screen then stays until your app dismisses the SDK. - **Screen copy**: your own title and description for the screens the person sees, on the workflow's Presence Intelligence panel under **Face check screens**: the loading screen while the check runs ("Checking it's you") and, on a verdict delivered in the app, the verified screen ("You're verified") and the not-verified screen ("We couldn't confirm it's you"). Leave a field empty to keep the default; `{firstName}` and `{lastName}` fill from the `userData` you pass, as on the welcome and success screens. A not-verified description replaces the reason the server sends. Enrolment offers the loading screen only. In code the same block is `biometric.copy` (`waiting`, `verified`, `declined`, each with `title` and `description`). ```dart await MyazaKYC.show( context: context, config: const MyazaKYCConfig( apiKey: 'pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', workflowId: 'wf_AbC123dEf456', // a biometric-authentication workflow userId: 'user_42', // required: the enrolled user being re-authenticated ), onSubmit: (s) => debugPrint('submitted ${s.verificationId}'), onResult: (r) { // r.status is the same vocabulary GET /api/kyc/status/:id serves. if (r.status == 'approved') unlockSensitiveAction(); }, ); ``` `onSubmit` still fires the moment the check is submitted. `onResult` fires once when the wait settles, never when it times out, and carries the state and reason pair only (`verificationId`, `status`, `reason`, `reasonCode`); scores and other result data stay behind your secret key. The webhook (where the flow sends one) and `GET /api/kyc/verifications/:id` remain the record. ## Countries & ID types are plain strings `country` takes any ISO-2 code (`'NG'`, `'GH'`, `'FR'`, …) and `idTypes` takes the **same kebab-case keys as the React SDKs** (`'bvn'`, `'drivers-license'`, `'ghana-card'`, …). See the [ID types](https://trust.myaza.co/documentation/id-types/markdown) catalogue. There is no `Country`/`IdType` enum to import. The one enum you'll meet is `MyazaThemeMode` (`light` / `dark`) on `appearance.theme`. ## Liveness configuration `LivenessConfig` tunes the active-liveness step. Defaults match the web SDK. | Field | Type | Default | Description | |---|---|---|---| | `challengeCount` | `int` | `2` | Number of gesture challenges drawn from the pool. | | `challengePool` | `List?` | `kDefaultChallengePool` | The set of challenges to draw from. | | `timeoutPerChallenge` | `int` | `8` | Seconds allowed per challenge before it fails. | | `enableAvatar` | `bool` | `true` | Show the animated avatar that demonstrates each gesture. | The default pool covers four `LivenessChallenge` gestures: `nod`, `turn`, `blink`, and `smile`. --- # Risk Intelligence Risk Intelligence helps your backend answer one operational question: **can this customer action proceed, does it need review, or should it stop?** Myaza connects the decision to the customer, evidence, alerts and any resulting investigation. ## Choose the job you need | Job | Start here | Result | |---|---|---| | Decide on a payment, transfer or crypto movement | `POST /api/v1/transactions` | A synchronous `allow`, `review` or `block` summary. | | Decide on a login, sign-up or account change | `POST /api/v1/activities` | The same synchronous decision summary. | | Register a customer you already verified | `POST /api/v1/entities` | An Entity ID for risk assessment and monitoring. | | Screen an existing customer | `POST /api/v1/risk-assessments` | A risk snapshot using the checks you selected. | | Keep an existing customer under review | `POST /api/v1/monitoring/subscriptions` | Scheduled checks, material changes and alerts. | | Apply Travel Rule controls to crypto | Add `transaction.travelRule` | Applicability, missing information and transfer action. | | Investigate a material alert | Investigations | Owner, evidence, timeline, remediation and outcome. | ## Read the small result first Activity and transaction responses include `assessment.summary`. It contains the outcome, strongest plain-language reason, score, risk level and next action. Use that object for normal routing. Read the detailed rules, screening and billing evidence only when an operator needs to investigate. ```json { "outcome": "review", "title": "Review required", "reason": "Transaction value exceeded the active review threshold.", "score": 50, "riskLevel": "medium", "matchedRules": 1, "nextAction": { "type": "review_activity", "label": "Review transaction" } } ``` ## What is synchronous and what is not - Activity and transaction decisions return in the request. Their assessed webhooks are optional notifications carrying the same summary. - Monitoring is ongoing. Runs, changes and alerts arrive after enrolment. - Travel Rule is part of a crypto transaction. Missing information keeps the current validation generation pending until you submit the named fields. - Investigations are casework records. Assignment and status changes return immediately and remain in the timeline. All Risk Intelligence server calls use a secret key and the versioned base URL `https://sandbox.trust.myaza.app/api/v1` in Sandbox or `https://trust.myaza.app/api/v1` in Production. In the dashboard, manage one customer's monitoring state and frequency from the Entity. Use Risk Intelligence for portfolio changes, Review Queue for action, and Rules & Policies for monitoring defaults. ## Next steps - [Run your first decision](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown). - [Read customer risk and monitoring together](https://trust.myaza.co/documentation/customer-risk-reads/markdown). - [Send complete transactions and activities](https://trust.myaza.co/documentation/monitoring-events/markdown). - [Start continuous monitoring](https://trust.myaza.co/documentation/monitoring-policies/markdown). - [Integrate Travel Rule](https://trust.myaza.co/documentation/travel-rule/markdown). - [Configure webhooks](https://trust.myaza.co/documentation/webhooks/markdown). - [Work with alerts and investigations](https://trust.myaza.co/documentation/investigations/markdown). --- # Risk Intelligence quickstart This is the shortest complete path from a Sandbox key to a useful transaction decision. It runs on your backend with the official Node.js SDK. Prefer a copy-ready HTTP example? Open **Fraud Monitoring → Integration guide** for cURL, Node.js, Python, Go and Coding agent options, a live request receipt, and a signed webhook verifier. See [Crypto operations](/docs/crypto-operations) for deposit feeds, the Crypto Provider Directory and address-book integration. No SDK changes are required for HTTP. To act on screening or device/face findings, subscribe to `screening.match`, `risk.signal.created` and `alert.created`. Match the event's `subject.externalUserId` to your customer, verify the signature and deduplicate by event ID. Review findings in **Investigations → Review Queue** before deciding whether to restrict access. Myaza does not directly block accounts in your application. See [Risk Intelligence webhooks](/docs/webhook-risk-intelligence) for the contracts. ## 1. Create a secret Sandbox key Open **Developers → API Keys**, select **Sandbox**, then create a **Secret** key. Copy the full `sk_test_...` value when it appears. It is shown only once. Store it in your shell or secrets manager: ```bash export MYAZA_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` Risk Intelligence is server-side. Do not put this key in browser or mobile code, and do not use a publishable `pk_...` verification key. ## 2. Install the SDK ```bash npm install @myazahq/trust-sdk ``` The package is ESM-only and requires Node.js 20 or newer. ## 3. Submit one transaction Create `risk-demo.mjs`: ```js import { Myaza } from '@myazahq/trust-sdk'; const myaza = new Myaza({ apiKey: process.env.MYAZA_SECRET_KEY, environment: 'sandbox', }); const runId = crypto.randomUUID(); const assessment = await myaza.transactions.create({ externalActivityId: `demo-activity-${runId}`, subject: { type: 'individual', externalUserId: `demo-customer-${runId}` }, occurredAt: new Date().toISOString(), transaction: { externalTransactionId: `demo-transfer-${runId}`, assetClass: 'fiat', direction: 'outbound', amount: '12500.00', currency: 'NGN', transactionType: 'bank_transfer', }, }, { idempotencyKey: `demo-transaction-${runId}` }); console.log({ activityId: assessment.activityId, transactionId: assessment.transactionId, outcome: assessment.summary?.outcome, reason: assessment.summary?.reason, nextAction: assessment.summary?.nextAction, }); ``` Run it: ```bash node risk-demo.mjs ``` ## 4. Interpret the result Read `summary` first: | Outcome | What your backend should do | |---|---| | `allow` | Continue. No operator action is required. | | `review` | Hold the action and send it to your review workflow. | | `block` | Stop the action. Keep the decision and evidence for investigation. | The reason, score and next action explain the current decision. Detailed matched rules, screening evidence and billing remain available on the same response. ## 5. See it in the dashboard Open **Risk Intelligence → Fraud Monitoring → Transactions** and search for the `externalTransactionId` printed by the script. The dashboard and API show the same decision and evidence. ## Optional: receive the decision by webhook Create an endpoint under **Developers → Webhooks** and subscribe to `fraud.transaction.assessed`. Verify the exact raw request body with the SDK before parsing or applying side effects. See [Webhooks](https://trust.myaza.co/documentation/webhooks/markdown) for the receiver example, retry model and replay protection. ## Assess or monitor an existing customer Customer risk assessments and monitoring subscriptions use a Myaza Entity ID. Upsert the customer once using your stable external reference, then use the returned `entityId`. This is the supported bootstrap path for customers already verified by your own KYC system. ```js const customer = await myaza.entities.upsert({ externalUserId: 'customer-123', type: 'INDIVIDUAL', // Assert this only after your own KYC has verified the customer. kycProvenance: 'EXTERNAL_VERIFIED', kycSource: 'your_kyc_system', profile: { fullName: 'Amina Bello', dateOfBirth: '1992-04-18', nationality: 'NG', }, }, { idempotencyKey: 'entity:customer-123:v1' }); const risk = await myaza.riskAssessments.create({ subject: { type: 'individual', id: customer.entityId }, checks: ['sanctions', 'pep', 'adverse_media'], sandboxScenario: 'SAFE', }, { idempotencyKey: 'risk-assessment:customer-123:v1' }); ``` Use `myaza.entities.get('customer-123')` to look the Entity up later. If the customer is not verified yet, complete your normal verification flow before asserting verified provenance. Transactions remain the simplest first call because they accept `subject.externalUserId` directly. Next, [keep the customer under continuous review](https://trust.myaza.co/documentation/monitoring-policies/markdown). ## Before your first Travel Rule transfer Travel Rule evaluates crypto transfers against your organisation's active policy version. Create that policy once in **Risk Intelligence → Rules & Policies → Fraud rules → Travel Rule**: install a library rule or create one, save the draft, then select **Publish draft**. Any authorised Travel Rule manager can publish their own draft directly. Later edits stay in draft until published and never rewrite earlier transfer results. After the policy is active, [send a crypto transfer through Travel Rule](https://trust.myaza.co/documentation/travel-rule/markdown). For a deterministic first test, add the top-level field `sandboxScenario: 'TRAVEL_RULE_MISSING_BENEFICIARY'` to the transaction request. That explicit fixture is non-billable and Sandbox-only; remove it in Production. An ordinary Sandbox crypto request uses configured pricing and is billable. --- # Read customer risk Use these read-only endpoints to explain a customer's risk or find customers who need attention. They do not buy checks, enrol customers, publish rules or release transfers. Customer risk and the risk of one transaction are different contexts. ## Availability This is a **SHADOW preview**, disabled by default. Myaza must enable the read capability for the environment; current scores additionally need a reviewed customer-risk configuration and qualified evidence. An API key alone does not activate it. Existing verification, workflow and transaction decisions are unchanged. Use a secret key on your backend. The key supplies the organisation and environment; do not send either in query parameters. Dashboard readers also need risk-read and individual- or business-read permission for the selected customer type. ## Choose a read All paths below start at your environment's `/api/v1` base URL. | Task | GET path | |---|---| | Current customer risk and its supporting evidence | `/risk-intelligence/customers/{entityId}/risk` | | Immutable assessment history | `/risk-intelligence/customers/{entityId}/risk/history` | | One exact historical assessment | `/risk-intelligence/customers/{entityId}/risk/snapshots/{snapshotId}` | | Filter by current risk | `/risk-intelligence/customers/risk-cohort?basis=CURRENT&subjectType=INDIVIDUAL` | | Filter by the latest retained assessment | `/risk-intelligence/customers/risk-cohort?subjectType=INDIVIDUAL` | Use `subjectType=BUSINESS` for business customers. Omitting `basis` deliberately selects historical, as-assessed results, not a fallback when current risk fails. In Dashboard, open **Customer assessments** and choose **Current** or **As assessed**. ## Filter risk and monitoring together For example, find currently high-risk individuals with active daily monitoring: ```bash curl --get "$MYAZA_API_URL/risk-intelligence/customers/risk-cohort" \ --header "Authorization: Bearer $MYAZA_SECRET_KEY" \ --data-urlencode 'basis=CURRENT' \ --data-urlencode 'subjectType=INDIVIDUAL' \ --data-urlencode 'includeMonitoring=true' \ --data-urlencode 'monitoringState=ACTIVE' \ --data-urlencode 'monitoringFrequency=DAILY' \ --data-urlencode 'level=HIGH' \ --data-urlencode 'pageSize=25' ``` Set `MYAZA_API_URL` to your environment's base URL including `/api/v1`. Keep `MYAZA_SECRET_KEY` in your backend secret store; never paste it into a browser. Monitoring inclusion requires Continuous Monitoring access and, for Dashboard sessions, `monitoring_subscriptions:read`. It is optional and valid only with `basis=CURRENT`. The same filtered population supplies customer rows, totals and risk-band counts; do not combine separate risk and monitoring pages yourself. Supported monitoring states are `ACTIVE`, `PAUSED`, `ENDED` and `NOT_ENROLLED`. Cadence is `HOURLY`, `DAILY`, `WEEKLY` or `MONTHLY`, including a customer override. `monitoringPolicyId` narrows by the latest subscription's policy. A null row-level `monitoring` means confirmed non-enrolment; missing or unavailable proof returns 503 for the combined read, never `NOT_ENROLLED`. Other filters are `level`, `assessmentState`, `status`, `disposition` and `search`. Search covers names and customer references, not encrypted government ID numbers. Use the returned `nextCursor` fields together and retain the same filters. Each page is a new observation, not a frozen export. The maximum page size is 100. ## Display the result safely - A numeric current headline requires `assessment.state=CURRENT` (or `current.assessmentState=CURRENT` on the single-customer endpoint). Read its score and level together. A null score is not zero. - A valid zero is Low. The versioned bands are Low 0–24, Medium 25–49, High 50–79 and Critical 80–100. The index is not a probability of fraud. - `PENDING`, `STALE`, `INCOMPLETE`, `UNAVAILABLE` and other non-current states must not show a retained number as current. Historical evidence remains a separate, explicitly as-assessed view when available. - Reconcile the selected assessment using its snapshot reference, retained factors and coverage. Contextual connections are not automatically score contributors. Do not add their values to the score again. - Refresh rows and totals together at `nextTransitionAt`, on return to the view and at least every 30 seconds. Off-page evidence may expire sooner than the visible rows. - With monitoring included, `monitoringObservation` has an independent clock. Compute its `nextTransitionAt - observedAt` duration separately from Core's duration. Use the shorter window, subtract the entire request time and measure the remaining display time monotonically. Never compare the two wall clocks. - Once that window expires, hide the current rows and totals until a valid refresh succeeds. Do not retain an old Low result during an outage. Responses are `no-store`. Invalid filters return 400; denied access returns 403; disabled preview or unavailable evidence returns 503. Unknown or inaccessible single customers return 404. These states do not authorise a transaction. ## Trace an assessment's evidence In **Risk evidence**, use **Why this score**, **Connections** and **History** for the same selected assessment. Connections offers a source map and an accessible list, with bounded pages and filters for contributions or supporting evidence. Open a finding for its retained observation time and source assessment or signal reference. Historical views never fetch today's findings to fill missing evidence. Solid connections represent retained point allocations. Supporting context adds no points; a minimum-risk adjustment comes from the model, not another check. Filtering or paging changes only what is visible, never the recorded total. The map is assessment lineage, not a live network of wallet owners or customers. Excluded or erased observations do not create invented edges. If a response's score and retained contributions conflict, the view stays unavailable. ## Integration acceptance Test individual and business reads, a real zero, unassessed customers, combined filters, cursor totals, expired evidence, missing monitoring proof, denied roles, another organisation/environment and delayed responses after a workspace switch. Reads must not create checks, charges or notifications. Compare an exact snapshot between the customer endpoint, cohort and history; do not compare independently observed live results as though time had stopped. Continue with [continuous monitoring](https://trust.myaza.co/documentation/monitoring-policies/markdown), [transaction decisions](https://trust.myaza.co/documentation/monitoring-events/markdown) and [agent integration guidance](https://trust.myaza.co/documentation/ai-integration/markdown). --- # Compliance (FRAML) Compliance is Myaza Trust's **fraud and anti-money-laundering** layer (FRAML). On top of KYC verification, it lets you keep watching the people and businesses you've onboarded: screen them against sanctions / PEP / adverse-media watchlists, score their activity in real time, and run a full investigation workflow from alerts through regulatory filings. There are two sides to it: - **The Identity Hub API**: a server-to-server API you call to sync your users as monitored **entities** and to stream their **events** (transactions and actions) for scoring. See [Identity Hub API](https://trust.myaza.co/documentation/identity-hub/markdown) and [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown). - **The dashboard**: where your team manages monitoring on each Entity, understands changes in **Risk Intelligence**, resolves **Review Queue** items, and edits defaults in **Rules & Policies**. Everything is **environment-scoped** (sandbox vs. production) and **organisation-scoped**, exactly like the rest of the platform; the API key you use decides which. See [Environments](https://trust.myaza.co/documentation/environments/markdown). ## Building blocks | Concept | What it is | |---|---| | **Entity** | A person (`INDIVIDUAL`) or company (`BUSINESS`) you monitor, keyed by your own `externalUserId`. Carries a KYC disposition, a risk tier, and (once resolved) a link to a global identity. | | **Screening** | An ongoing check of an entity against a watchlist type (`SANCTIONS`, `PEP`, or `ADVERSE_MEDIA`) producing zero or more **matches**. | | **Event** | A transaction or action you send for scoring (`POST /events`). The engine returns a **decision** and a **risk score**. | | **Signal** | The stored, scored record of an event: the monitored activity stream you browse in the dashboard. (A separate device/session *signal* stream is store-only.) | | **Alert** | An investigation item opened automatically when an event scores at or above the review threshold. | | **Investigation** | An owned evidence and decision record that can group one entity's related alerts and end in a disposition. | | **SAR / STR** | A Suspicious Activity / Transaction Report drafted and filed from an investigation, exportable as goAML XML. | ## Risk & disposition vocabulary An entity carries two independent axes: - **Disposition** is its KYC standing: `PENDING`, `UNDER_REVIEW`, `APPROVED`, `REJECTED`. - **Risk tier** is its monitoring risk posture: `LOW`, `MEDIUM`, `HIGH`, or `UNSCORED`. Each event yields a **decision** (`ALLOW`, `REVIEW`, or `BLOCK`) and a **risk score** from 0 to 100. ## The real-time decision lifecycle An event flows through the system like this: ``` your backend │ POST /api/identity/events ▼ Event ───score──► Signal (every event; browsable stream) │ │ decision ≥ review threshold ▼ Alert (OPEN: needs triage) │ group an entity's alerts ▼ Investigation (OPEN → IN_REVIEW → CLOSED + disposition) │ if reportable ▼ SAR / STR (DRAFT → FILED, exported as goAML XML) ``` - **Event → Signal** happens on every ingested event; the signal is stored and scored. - **Signal → Alert** happens automatically when the composite score crosses your **review threshold**. - **Alert → Investigation → SAR** are **manual** compliance actions taken in the dashboard. ## Permissions Each Compliance area has its own permission key, so you can grant read access to one surface without the others. The server enforces these; the dashboard hides what you can't use. | Key | Grants | |---|---| | `identity:read` | View individual entities, the compliance overview, and entity detail. | | `business_identity:read` | View business entities. | | `screening:read` | View screening results. | | `signals:read` | View the monitored event/signal stream. | | `alerts:read` | View alerts. | | `alerts:manage` | Open an investigation from selected alerts. | | `cases:read` | View investigations (the permission key retains its compatibility name). | | `cases:manage` | Open, assign and decide investigations; draft, file or cancel SARs. | | `monitoring:manage` | Edit monitoring rules (reading them is always allowed). | All investigation and SAR writes are audited server-side and appear in your organisation's audit log (**Dashboard → Audit logs**). ## Next steps - [Identity Hub API](https://trust.myaza.co/documentation/identity-hub/markdown): sync your users as monitored entities. - [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown): stream events for scoring. - [Fraud rules](https://trust.myaza.co/documentation/monitoring-rules/markdown): configure how events are scored. - [Screening](https://trust.myaza.co/documentation/screening/markdown): sanctions, PEP, and adverse-media checks. - [Investigations and reporting](https://trust.myaza.co/documentation/investigations/markdown): the investigation workflow. --- # Identity Hub API The Identity Hub is the server-to-server API you use to register the people and businesses you want to monitor as **entities**, and to read back their risk state. Once an entity exists you can stream its [events](https://trust.myaza.co/documentation/monitoring-events/markdown) for scoring. ## Base URL & authentication All Identity Hub endpoints live under a single base URL, the same for sandbox and production: ``` https://trust.myaza.app/api/identity ``` Every endpoint requires a **secret (`sk_`) key** as a Bearer token: these are backend-only, server-to-server calls that read and write identity data, so a publishable (`pk_`) key is rejected with `403 secret_key_required`. ```bash curl "https://trust.myaza.app/api/identity/entities/user_42" \ -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` The key identifies your organisation and its environment; `sk_test_` keys operate on sandbox data, `sk_live_` on production. See [Authentication](https://trust.myaza.co/documentation/authentication/markdown). ## Register or update an entity `POST /entities`: create or update an entity, keyed by your own `externalUserId`. The call is **idempotent**: sending the same `externalUserId` again updates the existing entity rather than creating a duplicate. ### Request ```json { "externalUserId": "user_42", "metadata": { "loanId": "loan_20191", "accountId": "acc_7732" }, "type": "INDIVIDUAL", "kycProvenance": "MYAZA_VERIFIED", "kycSource": null, "profile": { "fullName": "John Doe", "dateOfBirth": "1990-01-01", "nationality": "NG", "idType": "bvn", "idNumber": "12345678901", "address": { "city": "Lagos", "country": "NG" }, "declaredMonthlyVolume": 500000, "declaredTxnTypes": ["transfer", "deposit"], "walletAddresses": [ { "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "chain": "bitcoin", "label": "withdrawal" } ] } } ``` | Field | Required | Notes | |---|---|---| | `externalUserId` | Yes | Your own stable reference for the user. The entity's primary key within your org + environment. | | `metadata` | No | Your own correlation fields for this entity (a loan id, an account id). Bounded (16 KiB, four levels, 100 keys), returned on every read and carried on every [webhook](https://trust.myaza.co/documentation/webhooks/markdown#your-reference-on-every-event) about the entity. Sending it again replaces the stored object. | | `type` | No | `INDIVIDUAL` (default) or `BUSINESS`. | | `kycProvenance` | No | How the entity was KYC'd: `MYAZA_VERIFIED`, `EXTERNAL_VERIFIED`, or `UNVERIFIED` (default). | | `kycSource` | No | Free-text label of the external verification source, when `kycProvenance` is `EXTERNAL_VERIFIED`. | | `profile` | No | Declared attributes, all optional. `nationality` and declared fields feed monitoring rules (e.g. cross-border). | | `profile.walletAddresses` | No | Up to 20 crypto wallet addresses (`{ address, chain?, label? }`, address 20–128 chars). Omit the key to leave the stored set unchanged; send an array (including `[]`) to replace it. Attaching a wallet enrols the entity in [`WALLET` screening](https://trust.myaza.co/documentation/screening/markdown#types); changing the set re-screens promptly. Wallets are risk attributes, never identity keys. | ### Response `201 Created` for a new entity, `200 OK` for an update: ```json { "entityId": "ent_01j9abc123", "identityId": "idn_01j9xyz456", "externalUserId": "user_42", "metadata": { "loanId": "loan_20191", "accountId": "acc_7732" }, "kycProvenance": "MYAZA_VERIFIED", "created": true, "screening": { "status": "QUEUED", "types": ["SANCTIONS", "PEP"] } } ``` - **`metadata`** echoes your customer metadata exactly as stored. - **`identityId`** is the entity's link to a **global identity**: a person or company shared across organisations and environments. It's `null` until resolution links one (see below). - **`screening`** reflects [screening](https://trust.myaza.co/documentation/screening/markdown) enrolment. It's the string `"INACTIVE"` when screening is not configured for your organisation, never a fake `QUEUED`. ### KYC provenance & identity resolution When you declare an entity **verified** (`MYAZA_VERIFIED` or `EXTERNAL_VERIFIED`) and supply an ID (`profile.idType` + `profile.idNumber`), the Hub runs **resolution**: it links the entity to a global identity, matching an existing one where the ID is already known or creating a new one. `UNVERIFIED` entities skip resolution and stay unlinked. A linked identity carries its own `trustState` (`UNVERIFIED`, `VERIFIED`, `FLAGGED`), risk tier, and the set of verified identifiers (e.g. BVN **and** NIN) the person has accumulated across your org. ## Look up an entity `GET /entities/:externalUserId`: fetch an entity by your own reference, scoped to the key's org and environment. ```json { "entity": { "entityId": "ent_01j9abc123", "externalUserId": "user_42", "metadata": { "loanId": "loan_20191", "accountId": "acc_7732" }, "identityId": "idn_01j9xyz456", "type": "INDIVIDUAL", "status": "ACTIVE", "disposition": "APPROVED", "kycProvenance": "MYAZA_VERIFIED", "kycSource": null, "kycVerifiedAt": "2026-04-27T12:00:00.000Z", "riskTier": "LOW", "riskScore": 12, "createdAt": "2026-04-27T12:00:00.000Z", "profile": { "fullName": "John Doe", "nationality": "NG" } }, "identity": { "id": "idn_01j9xyz456", "type": "INDIVIDUAL", "trustState": "VERIFIED", "riskTier": "LOW", "riskScore": 12 }, "identifiers": [ { "idType": "bvn", "idNumber": "12345678901" } ], "verifications": [] } ``` `identity` and `identifiers` are populated only when the entity is linked. `verifications` is the entity's full KYC history (newest first). A missing entity returns `404 { "error": "entity_not_found" }`. ## Update an entity `PATCH /entities/:externalUserId`: change some of what you hold about an entity without resending the rest. Only the keys you send are touched; an absent key leaves the stored value alone, and `null` clears a nullable field. (`POST /entities` remains the full upsert, which replaces the whole profile.) ```json { "metadata": { "loanId": "loan_20191", "tier": "gold" }, "profile": { "nationality": "GH", "declaredMonthlyVolume": 750000 } } ``` The body accepts the same `metadata`, `kycSource`, `kycProvenance` (`EXTERNAL_VERIFIED` or `UNVERIFIED` only) and `profile` fields as the create call. `externalUserId` and `type` cannot change: the reference is the entity's key, and a person does not become a business. **Identity facts on a Myaza-verified entity are locked.** When `kycProvenance` is `MYAZA_VERIFIED`, its `fullName`, `dateOfBirth`, `nationality`, `idType` and `idNumber` came from a verification we ran, and a patch touching them is refused with `400 field_not_editable` naming the fields. Run a new verification to change them ([verify again](https://trust.myaza.co/documentation/api-verify-again/markdown)). Everything you declare yourself, contacts, address, declared volume, wallets, your own metadata, stays editable. Changing `idType` or `idNumber` on an externally-verified entity re-runs identity resolution, exactly as the create call does. An `address` you send is filed as declared evidence in the entity's address book, as `email` and `phone` are in its contact book. It may be one `addressLine`, or structured parts (`line1`, `line2`, `city`, `state`, `country` as ISO-2, `postcode`), which are kept and composed into the line. It becomes the headline address only while nothing a verification found outranks it: an address read from a government record or a proof-of-address document keeps that place. `declaredMonthlyVolume` takes an optional `declaredVolumeCurrency` (an ISO-4217 code such as `NGN`). ### Response `200 OK` ```json { "entityId": "ent_01j9abc123", "externalUserId": "user_42", "identityId": "idn_01j9xyz456", "changedFields": ["metadata", "nationality", "declaredMonthlyVolume"], "screening": null } ``` `changedFields` names what actually moved (empty when nothing did). `screening` is set only when the wallet set changed and WALLET screening was re-queued. Your endpoints receive `entity.updated` carrying the same field names, never the values. ## Delete an entity `DELETE /entities/:externalUserId`: remove the entity from your organisation. The body may carry a `reason` (up to 500 characters) for your audit log. Deletion is **soft**. The entity leaves your dashboard and every Identity Hub read, its ongoing screening is paused (history, adjudications and cleared matches are kept), and its verifications stay where they are. The global identity it resolved to is untouched: that record is shared and belongs to the person. Myaza keeps the entity, marked as deleted, and can restore it on request; there is no restore endpoint. **Registering the same `externalUserId` again brings it back.** A new `POST /entities`, a new verification for that user, or activity for it through the [events API](https://trust.myaza.co/documentation/monitoring-events/markdown) revives the deleted entity in place rather than creating a second one, because the reference is the key. ```bash curl -X DELETE "https://trust.myaza.app/api/identity/entities/user_42" \ -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "reason": "Account closed at the customer\u2019s request" }' ``` ### Response `200 OK` ```json { "entityId": "ent_01j9abc123", "externalUserId": "user_42", "deleted": true, "deletedAt": "2026-09-05T10:12:00.000Z", "screeningPaused": true } ``` Your endpoints receive `entity.deleted`. A deleted entity answers `404 entity_not_found` on every other endpoint, the same as one that never existed. ## Bulk import For backfilling an existing user base, `POST /entities/import` accepts **1–1000** entities in one call. It's **durable and asynchronous**: the request is persisted and acknowledged with `202` immediately, then a worker processes each item in the background (nothing is lost on a restart). ### Request ```json { "entities": [ { "externalUserId": "user_1", "type": "INDIVIDUAL", "kycProvenance": "EXTERNAL_VERIFIED", "kycSource": "acme-kyc" }, { "externalUserId": "user_2", "type": "BUSINESS" } ] } ``` Each element uses the same shape as a single [entity registration](#register-or-update-an-entity). ### Response ```json { "jobId": "job_01j9def789", "status": "PROCESSING", "total": 2 } ``` ### Poll import progress `GET /entities/import/:jobId`: ```json { "jobId": "job_01j9def789", "status": "COMPLETED", "totalCount": 2, "succeededCount": 2, "failedCount": 0, "pendingCount": 0, "createdAt": "2026-04-27T12:00:00.000Z", "completedAt": "2026-04-27T12:00:05.000Z", "failures": [] } ``` `failures` lists up to 50 failed items with `externalUserId` and an `error` string so you can retry them. An unknown job returns `404 { "error": "import_job_not_found" }`. ## Address surface Entities whose flow captured an [address pin](https://trust.myaza.co/documentation/address-intelligence/markdown) carry an address surface: | Method | Endpoint | What it does | |---|---|---| | `POST` | `/entities/:externalUserId/address/verify` | Mint a [presence watch](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification) for the captured pin. One live watch per entity; a repeat call returns the existing one (`existing: true`). `422 no_address_pin` when no pin was ever captured. | | `POST` | `/entities/:externalUserId/address/revoke` | Revoke the live watch. | | `POST` | `/entities/:externalUserId/address/reattest` | A short-window watch answering "still at this address: yes / no / unknown". Billed on resolution like any watch; never live whereabouts. | | `GET` | `/entities/:externalUserId/address` | The stability attestation: capture tier, presence outcome, months of tenure, `strong`/`moderate`/`weak` grade, and moves in 24 months. | | `GET` | `/entities/:externalUserId/address/packet` | The navigation packet for delivery and recovery teams: the pin with its Plus Code, the entrance-photo URL and the directions your own flow collected. | ## Monitoring switches Two per-entity switches, the same ones the dashboard's entity page offers. Both are idempotent: switching on what is already on changes nothing and says so. | Method | Endpoint | What it does | |---|---|---| | `POST` | `/entities/:externalUserId/screening/ongoing` | Body `{ "ongoing": true }` enrols the entity in sanctions, PEP and adverse-media screening where it is not yet enrolled and resumes any paused rows, so it is re-screened on your risk-based cadence; `false` pauses them. A pause keeps every past result and adjudication. Returns `{ ongoing, active, changed }`: `changed` counts the screening rows that moved, and `active: false` means screening is not running for your organisation at all, so nothing was enrolled. | | `POST` | `/entities/:externalUserId/address/always-on` | Body `{ "enabled": true, "cadenceDays": 90 }` turns the entity's presence watch into an always-on chain that re-checks presence every `cadenceDays` (30, 60, 90, 180 or 365; default 90) and renews itself, minting a watch from the captured pin when none is live (`422 no_address_pin` when there is no pin). `{ "enabled": false }` stops the chain renewing; the check in flight still runs to its verdict. Returns `{ enabled, watchId, minted, cadenceDays }` or `{ enabled: false, stopped }`. Always-on monitoring is billed per entity per year at the cadence's rate. | ## Webhooks Registering entities emits [webhook events](https://trust.myaza.co/documentation/webhooks/markdown) you can subscribe to: | Event | When it fires | `data` | |---|---|---| | `entity.imported` | A new entity was created. | `entityId`, `identityId`, `externalUserId`, `environment` | | `identity.resolved` | An entity was linked to a global identity (new or existing). | `entityId`, `identityId`, `environment`, `matchedExisting` | | `entity.updated` | You edited an entity. | `entityId`, `externalUserId`, `identityId`, `type`, `changedFields` (names only, never values), `environment` | | `entity.deleted` | You deleted an entity. | `entityId`, `externalUserId`, `identityId`, `type`, `deletedAt`, `environment` | | `entity.restored` | Myaza restored an entity you had deleted, at your request. | `entityId`, `externalUserId`, `identityId`, `type`, `restoredAt`, `environment` | ## Errors | Status | Body | Cause | |---|---|---| | `400` | `{ "error": "invalid_request", "details": … }` | The body failed validation; `details` is a field-level breakdown. | | `401` | `{ "error": "Invalid API key" }` | Missing, unknown, or revoked key. | | `403` | `{ "error": "secret_key_required" }` | A publishable (`pk_`) key was used; Identity Hub is secret-key only. | | `400` | `{ "error": "field_not_editable", "fields": [...] }` | A patch tried to change identity facts on a Myaza-verified entity. | | `404` | `{ "error": "entity_not_found" }` | No entity with that `externalUserId` in this org + environment, or it has been deleted. | See [Errors](https://trust.myaza.co/documentation/errors/markdown) for the platform-wide list. ## Next steps - [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown): stream this entity's transactions for scoring. - [Screening](https://trust.myaza.co/documentation/screening/markdown): how watchlist enrolment and matches work. --- # Screening Screening checks your [entities](https://trust.myaza.co/documentation/identity-hub/markdown) against watchlists (sanctions lists, politically-exposed-person (PEP) registers, adverse media, and, when wallet addresses are attached, sanctioned crypto wallets) and records any matches. In the dashboard, open a customer and select **Risk** to see their compliance intelligence. Results requiring attention also appear in the **Review Queue**. Both require the `screening:read` permission. ## Enrolment An entity is enrolled in screening automatically when you register it via the [Identity Hub](https://trust.myaza.co/documentation/identity-hub/markdown#register-or-update-an-entity). The registration response echoes the screening state: ```json "screening": { "status": "QUEUED", "types": ["SANCTIONS", "PEP", "ADVERSE_MEDIA", "WALLET"] } ``` When screening is not active for your organisation, this is reported honestly as the string `"INACTIVE"` rather than a fake `QUEUED`. Nothing is screened until the service is active. A verification enrols the person (or business) when it passes, but only if the workflow it ran includes **AML Screening**. A workflow with AML Screening turned off enrols nobody, so nothing is screened or charged for screening. Its **Ongoing monitoring** setting decides whether the enrolment keeps re-screening or runs once. A verification submitted without a workflow enrols everyone who passes. ## Types | Type | Checks against | |---|---| | `SANCTIONS` | Government and international sanctions lists. | | `PEP` | Politically-exposed-person registers. | | `ADVERSE_MEDIA` | Negative news and adverse-media sources. | | `WALLET` | The entity's attached crypto wallet addresses, checked against sanctioned-wallet data (e.g. OFAC SDN listings). Enrolled only when the profile carries at least one [`walletAddresses`](https://trust.myaza.co/documentation/identity-hub/markdown#register-or-update-an-entity) entry; one screen covers all attached addresses, and a changed address set re-screens promptly instead of waiting for the cadence. | ## Statuses | Status | Meaning | |---|---| | `QUEUED` | Enrolled; the initial run hasn't completed. | | `CLEAR` | No matches found. | | `POTENTIAL_MATCH` | One or more possible matches need review. | | `CONFIRMED` | A match has been confirmed. | ## Matches A screening that isn't `CLEAR` carries one or more customer-safe **matches**: the matched name or wallet, a match score, match types, aliases and selected identity evidence. Internal source identity, raw watchlist records and internal provenance are not exposed. ## What you see in the dashboard Customer Risk shows the latest screening **type**, **status**, **match count**, when it was last checked, and the next monitoring check when one is scheduled. Open an evidence row to inspect the customer-safe match details and adjudication state. The detail view keeps a newest-first review history with the outcome, recorded time, review note and score at the time of the decision. It does not expose reviewer IDs, internal source names, raw datasets or source records. Screening evidence that changes a risk decision is also linked from the decision, transaction, or investigation where it mattered. When there is no recorded evidence: > Screening results appear here once entities are enrolled in ongoing sanctions / PEP / adverse-media screening. ## Related - [Identity Hub API](https://trust.myaza.co/documentation/identity-hub/markdown): where enrolment happens. - [Compliance overview](https://trust.myaza.co/documentation/compliance/markdown): how screening fits the wider FRAML picture. - `GET /api/v1/monitoring/screenings/{screeningId}`: retrieve the current customer-safe screening resource linked by a webhook. --- # Crypto operations Open **Risk Intelligence → Fraud Monitoring → Transactions**. Use **Crypto transfers** for assessed transactions, **Unmatched deposits** for observations that need matching, **Crypto Provider Directory** for sourced crypto provider records, and **Address book** for saved destinations. Existing transaction URLs and decisions are unchanged. ## Connect in minutes Select **Integration guide**, choose your use case and stack, then copy the request or the Coding agent prompt. You can use cURL, Node.js, Python or Go without installing an SDK. The guide checks that your Sandbox request arrived and links to its canonical record. It never sends a Live request or places secrets in a browser URL. The five-minute target starts with an available Sandbox secret key and ends with a first decision and a signature-verified sample in your own webhook receiver. Live access still requires business approval, permissions, configured controls and production testing. Machine-readable integration resources on the API origin: - `/openapi.json`: request and response contracts. - `/integration/examples?useCase=crypto`: generated requests and a Node.js webhook verifier. - `/llms.txt` and `/llms-full.txt`: coding-agent instructions. - `/.well-known/myaza-trust.json`: API and documentation discovery. Examples support `payments`, `crypto`, `account`, `custom` and `deposits`. Use the `apiBase` returned by the example. Custom events use `/api/identity`; the other examples use `/api/v1`. Existing SDK transaction methods still work. The new crypto record endpoints are HTTP APIs; do not assume your installed SDK already has matching convenience methods. ## What each record means | Record | Meaning | Does not mean | |---|---|---| | Crypto transfer | A canonical transaction assessed by your existing controls | Myaza moved or released funds | | Deposit observation | Your backend reported an inbound chain output | A customer is matched or a decision was made | | Crypto provider evidence | Platform-reviewed, sourced identity and registration information | A compliance guarantee or active Travel Rule connection | | Approved address | Your organisation approved the saved destination | Ownership was verified or standalone screening ran | The network catalogue validates supported address syntax. It does not enable an automatic chain feed, verify checksums or prove control of a wallet. Never silently round asset amounts beyond the supported eight decimal places. ## Check a transfer's clearance Open a crypto transaction and use **Check transfer** under **Transfer clearance**. The result keeps the risk decision, information review, secure exchange and funds movement separate. **On hold** means do not release funds. **Cancel clearance** stops that clearance record; it does not reverse a blockchain transaction. Past checks remain available in the history. Team members with read-only access can view and reload results. Country coverage and exchange connections are still being qualified. **No current policy is enabled for live clearance.** Passing information checks, a risk-only **Allow**, a saved crypto provider or a matched deposit is not permission to release funds. Myaza Trust does not move funds or automatically block actions on your platform. From your backend, use the additive endpoints: - `GET /api/v1/transactions/{id}/travel-rule/transfer` reads current clearance, or `{ "transfer": null }` when no check has been started. - `POST /api/v1/transactions/{id}/travel-rule/transfer` starts or refreshes a check. Send `Idempotency-Key` and `{ "action": "refresh" }`. For an existing record, include its `expectedRevision`. Send `action: "cancel"` with that revision to cancel it. Retry an uncertain request with the **same key and unchanged body**. A replay identifies the original command revision but returns the current safe transfer state. Reload after a revision conflict. Initial expiry defaults to one hour and cannot be extended by a refresh. Cancelled and expired records cannot be reopened. These commands do not charge credit, contact another institution or move funds. Your execution backend must retain its hold when clearance is missing, unavailable, expired or `canRelease` is false. This API is not yet a signed, single-use execution authorisation; keep real transfers disabled until the complete integration is qualified. ## Manage crypto networks Platform admins can select **Manage networks** in the Crypto Provider Directory, Address book or Unmatched deposits. Add a name, stable network code, address format and audit reason. New entries become available in crypto record forms and Travel Rule policy network pickers. The network code and address format cannot change after creation. Disable an entry to stop new records; existing records, exact retries and history remain available. Concurrent edits require reloading the latest catalogue before saving again. `GET /api/v1/crypto/catalogue` returns active `networks` plus `networkOptions`, including disabled entries for historical display. Only authorised platform admins can change the catalogue. This does not add a supported funding rail: credit top-ups still use the receiving wallets separately configured by platform finance. ## Report and match a deposit Send `POST /api/v1/crypto/deposits` from your backend with a secret key and an `Idempotency-Key` header. The minimal body is available in **Integration guide → Deposit feed**. Include `externalDepositId`, `network`, `transactionHash`, `outputIndex`, `destination`, decimal-string `amount`, `currency`, `confirmations` and ISO `observedAt`. Use a stable output index, such as the transaction's log index for a token transfer. The observation appears under **Unmatched deposits**. Your team can: - **Match customer**: select an existing customer and record a reason. Myaza runs the existing transaction assessment and pricing, then links the observation. - **Link existing transaction**: choose an already assessed inbound crypto transaction. Amount, currency, network, chain hash, output and destination must all match. No second assessment or charge is created. - **Add to address book**: save the retained destination without exposing its full value in the browser. It starts as a claimed address awaiting review. - **Needs review** or **Ignored**: record the reason. Observations remain in history. For API integration, use `POST /api/v1/crypto/deposits/{id}/assessment` with `customerId`, current `version` and `reason`, or `/matches` with `transactionId`, `version` and `reason`. Use the same idempotency key and unchanged body on retries. Once assessment starts, the selected customer is fixed. A timeout can be resumed without creating a second charge. Update confirmations or report a reorganisation with `PATCH /api/v1/crypto/deposits/{id}`. A reorganisation does not erase the historical assessment or silently unmatch the deposit. It emits an event for your backend to handle. Never treat a matched record as fund release. ## Crypto provider reviews and address book Platform reviewers add crypto provider evidence with a source URL, jurisdiction and a concise review summary. Every revision is retained. An empty directory means no records have been curated; the product does not invent crypto providers or licences. Your team's **Approved**, **Watchlist** and **Avoid** review is scoped to its organisation and environment. It does not edit global evidence or automatically change transaction rules. Use Rules & Policies for enforcement. Travel Rule is shown as **Not configured** until a real connection is supported and configured. Saved addresses are encrypted at rest and masked in responses. Approval, suspension and archiving require a reason and the current record version. Address approval is distinct from ownership verification and transaction screening. ## Address tools ### Find the wallet's crypto provider When your organisation has exchange-network discovery enabled, open a saved wallet and use **Crypto provider → Find crypto provider**. Enter the asset, such as USDT. Myaza asks the connected network which institution recognises that wallet. Results appear in the same address details, beside wallet screening and control checks. A **Network match** is exchange-reported evidence, not ownership verification or transfer approval. **No crypto provider confirmed** does not mean a wallet is self-hosted. Multiple matches need clarification. The network does not reach every exchange and does not provide blockchain risk scores, address categories or transaction history. Discovery targets participating institutions' customer deposit wallets, not their cold wallets. Myaza manages the network credentials; customers do not enter signing keys here. From a backend, use the existing secret-key crypto API: ```http POST /api/v1/crypto/addresses/{id}/discovery Content-Type: application/json { "requestId": "1d526c54-e8ed-4b2f-b034-3a5ea370f9c2", "version": 1, "currency": "USDT" } ``` Use a fresh UUID for a new lookup and the address's current version. Save the UUID before sending. Poll `GET /api/v1/crypto/addresses/{id}/discovery/{requestId}` at least 12 seconds apart, including after a timeout. An exact same-body retry never reposts the vendor lookup. Do not create another request ID to recover uncertainty. The dashboard's **Check again** polls the original request. Results are `pending`, `matched`, `unknown` or `ambiguous`. A matched result can include `institutionId`, `institutionName`, `legalName` and `checkedAt`. Requests are scoped to the original organisation/environment, address and account-binding version and expire after one hour. Configuration or address changes can invalidate the result. No private address or signing key is returned. `GET /api/v1/crypto/network/status` lists locally configured discovery capabilities and enabled network codes. `GET /api/v1/crypto/network/institutions` returns the network directory. These are distinct from Myaza's reviewed crypto provider records. In **Crypto providers**, select **Browse network** to search those institutions by company or country. Browsing does not add companies to the reviewed directory or activate connections. The CodeVASP connector currently supplies discovery; its `transferExchange` capability is false. Existing TRP connections and their activation/release checks are unchanged. ### Screen, edit or verify control Open a saved address to edit its label or linked customer or crypto provider, run wallet screening, or prove control. The wallet and network cannot be edited; save a new destination instead. Relinking a customer or crypto provider returns the address to review and clears its control proof. - **Screen address** uses the existing wallet-screening price and assessment engine. Sandbox results are labelled. Waiting for credit, running, failure and a completed decision are different states. Screening never approves the address for your team. - **Verify control** creates a message that expires in ten minutes. Ask the wallet holder to sign it and paste back the signature. Ethereum, Polygon, Arbitrum and Base personal wallets are supported; smart-contract wallets are not. No funds, keys or recovery phrases are requested. A valid signature proves key control at that time, not legal ownership. - **Reveal address** requires both management access and `crypto_addresses:reveal`. Record a reason. The address hides after 30 seconds or when the page loses visibility; the audit trail records access without retaining the full address. The matching API actions live at `/api/v1/crypto/addresses/{id}/metadata`, `/screening`, `/control-challenge`, `/control-proof` and `/reveal`. Read the exact request schema before writing an integration. Each write requires `Idempotency-Key`; retry unchanged input with the same key after a timeout. A control proof returns the updated record version. ## Directory and connections Search directory names, LEIs and registration references. Filter by jurisdiction, review status, registration evidence or network. Filters are applied before pagination. An organisation review does not change the platform's sourced record or endorse a crypto provider. Select **Set up Travel Rule** from the transaction workspace to choose your company, manage exchange connections, open transfer rules and configure result notifications. The same setup is available under **Manage controls → Travel Rule setup**. Use **Advanced** for deposit-feed diagnostics, delivery history and historical wallet reviews. Sandbox and Live profiles stay separate. A company selection does not establish a connection; a successful HTTP delivery does not prove signature verification. Live exchange requires an approved, configured institution connection. In **Crypto providers**, search by company name, LEI or registration reference. Use **Filters** to narrow the directory. Open a company to see its profile, expand **Company evidence** for source records, or record your team's assessment in the review section. Directory review, team review and connection status are separate. Unmatched deposits show current totals for deposits to match, matched deposits and addresses to review. These are current queues, not time-series or financial-volume analytics. ## Coding-agent documentation server For teams with the Core repository checked out, a local, read-only MCP server supplies OpenAPI contracts and the same copy-ready examples. It cannot call your API, read keys, access files, run commands or change customer data. Install the repository dependencies using its pinned Node version, then configure your MCP client to run: ```json { "mcpServers": { "myaza-trust-integration": { "command": "node", "args": ["--import", "/absolute/path/to/myaza-kyc-core/node_modules/tsx/dist/loader.mjs", "/absolute/path/to/myaza-kyc-core/src/integration-mcp.ts"] } } } ``` Replace the paths with your checkout. No environment secrets are required. Available tools are `integration_example` and `api_schema`; `myaza://integration/openapi` is the contract resource. Alternatively, run `pnpm mcp:integration` from Core. Teams without the repository can use the public OpenAPI and llms resources above; no hosted MCP service is advertised. ## Webhooks and safe retries Subscribe to `crypto.deposit.unmatched`, `crypto.deposit.matched`, `crypto.deposit.reorged`, `crypto.address.approved` and `crypto.address.suspended` in the existing webhook settings. Payloads contain masked projections, never full wallet addresses. Use the existing webhook simulator and the guide's **Copy Node.js verifier** action. Verify `X-Myaza-Signature-V2` against the exact raw request bytes. Check the timestamp, accept a valid signature from either secret during rotation, and persist a durable inbox keyed by event `id` before acknowledging delivery. A retry may have a different `deliveryId`. Reject tampered bodies, invalid signatures and expired timestamps. A `2xx` alone does not prove signature verification. All crypto writes require an idempotency key. Reusing a key with different input returns `409`; stale review versions also return `409`. Refresh the record, explain the new decision, and use a new key only for a genuinely new operation. An error is never approval. Lists use `page` and `pageSize` (maximum 100). Detail evidence uses `historyPage`, 25 records per page, with a total count. Use the page controls to read older evidence. Read permission is `fraud_prevention:read`; review actions require `fraud_prevention:manage`. Crypto provider curation is platform-owner only with `platform.providers:write`. Production API calls keep the existing secret-key, product access and business-approval gates. ## Enforcing decisions in your app Myaza assesses and notifies. Your backend decides whether to stop a payment, restrict an account, request another check or send a case to your team. A **Block** badge in Myaza does not freeze the customer's account in another application. | What you monitor | Results to subscribe to | Your application handles | |---|---|---| | Verification | Verification results and `risk.decision.changed` when risk controls apply | Finish onboarding, request review or refuse access using the final decision | | Screening | `screening.match`, `screening.completed`, `screening.adjudicated` and `risk.decision.changed` | Hold a potential match for review; use the resolved decision, not the match alone | | Transactions and account activity | `fraud.transaction.assessed`, `fraud.activity.assessed`, `fraud.decision.changed` | Allow, hold or block the particular action | | Custom events | `event.flagged`, applicable risk decision changes and alerts | Apply the action your published policy requires | | Device reuse or linked accounts | `risk.signal.created`, `risk.decision.changed`, `alert.created` | Review the evidence or request another check; a shared device alone is not proof of fraud | Use `externalUserId` where provided to map the entity to your customer, and the transaction or activity reference for an individual action. Some events have different subject shapes; follow their documented payloads instead of assuming one shared field location. Before releasing a payment, evaluate the latest decision in your backend. Keep pending, failed or unavailable checks on hold according to your approved operating policy. For crypto, completed information validation is **not** confirmation that a counterparty received the Travel Rule information. Do not release funds based on that state alone. Verify signatures, persist each event ID once and acknowledge only after durable receipt. Webhooks can be retried or arrive out of order: fetch the latest record before applying a delayed update. Test allowed, review, blocked, pending and failed outcomes, replayed events, timeouts and manual decision changes before Live use. --- # Transaction, behaviour and event monitoring Send each transaction or customer activity when it happens. Myaza connects it to the customer, evaluates the relevant controls and returns an explainable `allow`, `review` or `block` decision. ## Choose the correct endpoint | What you are sending | Endpoint | Why | |---|---|---| | A payment, transfer, deposit or withdrawal with sender and recipient details | `POST /api/v1/transactions` | Builds the transaction relationship, records accounts or wallets, screens the correct directional party and runs fraud assessment. | | A login, sign-up, password reset, beneficiary change or other supported customer activity | `POST /api/v1/activities` | Builds Behaviour intelligence from device, IP, location and event attributes, then runs fraud assessment. | | An existing integration using the older event shape | `POST /api/identity/events` | Compatibility only. It accepts a reduced free-form payload but cannot model complete parties and instruments. | Use a secret `sk_` key with the same base URL and authentication as the [Identity Hub](https://trust.myaza.co/documentation/identity-hub/markdown#base-url-authentication). Use the v1 endpoints for new integrations. ## Complete value reference The tables below are the complete closed-value fields accepted by the v1 transaction, activity and outcome contracts. Values are lowercase and case-sensitive in requests. If a value is not listed here, the API rejects it rather than guessing. ### Request enum values | Field | All accepted values | |---|---| | `subject.type` | `individual`, `business`, `wallet`, `bank_account`, `device`, `email`, `phone`, `ip` | | Activity `type` | `login`, `signup`, `password_reset`, `beneficiary_added`, `profile_change`, `device_change`, `payout`, `withdrawal` | | `transaction.assetClass` | `fiat`, `crypto` | | `transaction.direction` | `inbound`, `outbound` | | `transaction.parties[].role` | `customer`, `sender`, `recipient`, `counterparty` | | `transaction.parties[].type` | `individual`, `business`, `unknown` | | `transaction.parties[].instruments[].type` | `bank_account`, `crypto_wallet`, `card`, `other` | | Outcome `classification` | `fraud`, `legitimate`, `inconclusive` | | Outcome `source` | `customer`, `analyst`, `chargeback`, `case`, `sar`, `automation` | The response decision values are `allow`, `review` and `block`. Use them as machine values and show your own customer-facing copy separately. ### Open string fields These fields are intentionally not enums: `channel`, `transaction.transactionType`, `transaction.status`, `transaction.paymentMethod`, `transaction.blockchain`, `currency`, `metadata` keys and `customAttributes` keys. Send stable, documented values from your own system. Myaza stores them as evidence and may use them in authorised custom controls, but does not silently translate them into another value. `POST /api/identity/events` is a compatibility endpoint, so its `type` is also an open string. Recognised names containing transaction, payment, transfer, login, sign-in, sign-up, register, password reset, beneficiary added, profile change, device change, payout or withdrawal are routed into the canonical assessment pipeline. New integrations should use the closed v1 activity types above. ## Send a complete transaction `POST /api/v1/transactions` The `subject` is your customer. The `parties` array describes who sent and received the funds. Add each bank account, wallet, card or other payment method to the party that used it. ### Outbound bank transfer ```json { "externalActivityId": "activity_txn_10001", "subject": { "type": "individual", "externalUserId": "user_42" }, "occurredAt": "2026-08-16T09:30:00.000Z", "channel": "bank_transfer", "deviceRef": "device_7f53", "ipAddress": "102.89.12.34", "country": "NG", "location": { "city": "Lagos", "region": "Lagos", "latitude": 6.5244, "longitude": 3.3792, "accuracyRadiusKm": 10 }, "transaction": { "externalTransactionId": "txn_10001", "assetClass": "fiat", "direction": "outbound", "amount": "48000.00", "currency": "NGN", "transactionType": "bank_transfer", "status": "pending", "channel": "bank_transfer", "paymentMethod": "bank_account", "parties": [ { "role": "sender", "externalUserId": "user_42", "type": "individual", "displayName": "Sample customer", "country": "NG", "deviceRef": "device_7f53", "instruments": [ { "type": "bank_account", "externalInstrumentId": "customer_account_01", "identifier": "0123456789", "institutionName": "Sample Bank", "country": "NG", "currency": "NGN", "verified": true } ] }, { "role": "recipient", "externalPartyId": "beneficiary_998877", "type": "individual", "displayName": "Amina Bello", "country": "NG", "instruments": [ { "type": "bank_account", "externalInstrumentId": "beneficiary_account_998877", "identifier": "9988776655", "institutionName": "Recipient Bank", "country": "NG", "currency": "NGN" } ] } ], "metadata": { "paymentPurpose": "supplier_payment", "customerReference": "invoice_2026_104" } } } ``` This appears as **Sample customer → Amina Bello** throughout the dashboard. For an inbound transaction, the customer is the recipient and the title appears as **Sample customer ← sender name**. ### Inbound crypto transfer ```json { "externalActivityId": "activity_crypto_10002", "subject": { "type": "individual", "externalUserId": "user_42" }, "occurredAt": "2026-08-16T10:05:00.000Z", "channel": "blockchain", "country": "NG", "transaction": { "externalTransactionId": "crypto_10002", "assetClass": "crypto", "direction": "inbound", "amount": "0.25", "currency": "USDT", "transactionType": "transfer", "blockchain": "ethereum", "parties": [ { "role": "sender", "externalPartyId": "crypto_sender_01", "type": "individual", "displayName": "Charles Okafor", "country": "NG", "instruments": [ { "type": "crypto_wallet", "externalInstrumentId": "sender_wallet_01", "identifier": "0x8ba1f109551bd432803012645ac136ddd64dba72", "network": "ethereum", "currency": "USDT" } ] }, { "role": "recipient", "externalUserId": "user_42", "type": "individual", "displayName": "Sample customer", "country": "NG", "instruments": [ { "type": "crypto_wallet", "externalInstrumentId": "customer_wallet_01", "identifier": "0x742d35cc6634c0532925a3b844bc454e4438f44e", "network": "ethereum", "currency": "USDT", "verified": true } ] } ] } } ``` This appears as **Sample customer ← Charles Okafor**. Myaza screens the sender's crypto wallet before producing the transaction decision. ## How directional screening works The transaction direction is always from the perspective of your customer. | Direction and asset | Party screened before the decision | What is sent for screening | |---|---|---| | Outbound fiat | Recipient | The recipient's name and available identity evidence. Bank account numbers remain relationship evidence and are not used for screening. | | Inbound fiat | Sender | The sender's name and available identity evidence. Bank account numbers remain relationship evidence and are not used for screening. | | Outbound crypto | Recipient | The recipient wallet address and network. | | Inbound crypto | Sender | The sender wallet address and network. | Individuals are checked for sanctions, PEP and adverse media. Businesses are checked for sanctions and adverse media. If the required target name or wallet address is missing, Myaza returns a review decision with an insufficient-data reason rather than treating the transaction as clear. ## Transaction fields ### Request envelope | Field | Required | Meaning | |---|---:|---| | `externalActivityId` | Yes | Stable idempotency key for this assessment. Reuse it only when retrying the same logical request. | | `subject` | Yes | Your customer. Supply exactly one of `subject.id` or `subject.externalUserId`. | | `occurredAt` | Yes | ISO 8601 time when the activity happened. It cannot be more than five minutes in the future. | | `channel` | No | Origin such as `bank_transfer`, `mobile_app`, `card` or `blockchain`. | | `deviceRef` | No | Your stable device reference. Reusing it builds relationship and new-device evidence. | | `ipAddress` | No | Valid end-user IPv4 or IPv6 address. Myaza does not substitute the calling server's IP address. | | `country` | No | Two-letter country code associated with the event. | | `location` | No | `city`, `region`, coordinates and optional accuracy radius. Latitude and longitude must be supplied together. | | `metadata` | No | Up to 50 flat fields. Values may be strings, numbers, booleans, nulls or short primitive arrays. | | `customAttributes` | No | Up to 50 flat custom values available to authorised custom controls. | ### `transaction` | Field | Required | Meaning | |---|---:|---| | `externalTransactionId` | Yes | Your stable transaction reference. It appears in APIs, evidence and webhooks. | | `assetClass` | Yes | `fiat` or `crypto`. This selects the directional screening type. | | `direction` | Yes | `inbound` or `outbound`, from your customer's perspective. | | `amount` | Yes | Positive decimal string with up to eight decimal places. Send a string to preserve exact money. | | `currency` | Yes | Fiat currency or asset code, such as `NGN`, `USD`, `USDT` or `BTC`. | | `transactionType` | Yes | Your transaction category, such as `bank_transfer`, `card_payment`, `deposit` or `withdrawal`. | | `status`, `channel`, `paymentMethod` | No | Operational context used in evidence and custom controls. | | `blockchain` | Crypto only | Network for a crypto transaction, such as `ethereum` or `bitcoin`. | | `parties` | Recommended | Structured sender and recipient records. Supply these to unlock transaction titles, relationship intelligence and directional screening. | | `metadata` | No | Flat transaction-specific values such as purpose or your own customer reference. | ### Party fields | Field | Required | Meaning | |---|---:|---| | `role` | Yes | `sender`, `recipient`, `customer` or `counterparty`. Use sender and recipient for a complete money movement. | | `externalUserId` | One stable reference | Use when this party is already an entity in your Myaza environment. | | `entityId` | One stable reference | Myaza entity ID. Prefer `externalUserId` when integrating from your own system. | | `externalPartyId` | One stable reference | Your durable reference for an external counterparty that is not yet an entity. | | `displayName` | Required for external fiat screening | Human-readable person or business name. It also becomes the dashboard transaction title. | | `type` | No | `individual`, `business` or `unknown`. It selects the relevant name-screening checks. | | `country`, `industry` | No | Counterparty context for geography, screening and relationship evidence. | | `deviceRef` | No | Device used by this party, when your system observes it. | | `instruments` | No | Bank accounts, crypto wallets, cards or other payment methods used by this party. | Every party needs at least one stable reference: `externalUserId`, `entityId` or `externalPartyId`. ### Instrument fields | Field | Required | Meaning | |---|---:|---| | `type` | Yes | `bank_account`, `crypto_wallet`, `card` or `other`. | | `externalInstrumentId` | One stable value | Your durable account, wallet or payment-method reference. | | `identifier` | One stable value | Raw account number or wallet address observed for this transaction. It is fingerprinted for relationship matching and is not stored in raw form. | | `fingerprint` | One stable value | An existing service or device token when raw identifiers should not be submitted. | | `maskedIdentifier` | No | Safe display value. It cannot identify an instrument on its own. | | `institutionName`, `country`, `currency` | No | Bank or payment-method context. | | `network` | Crypto only | Blockchain network used by the wallet. | | `verified` | No | Whether your authorised process verified that the party controls this instrument. | Each instrument needs `externalInstrumentId`, `identifier` or `fingerprint`. Myaza derives a tenant-scoped fingerprint and keeps only safe display evidence. ## Transaction assessment response The response wraps the persisted decision in `assessment`. Read `summary` first: ```json { "assessment": { "activityId": "act_01j9ghi000", "transactionId": "atx_01j9ghi001", "decisionId": "dec_01j9ghi002", "decision": "review", "summary": { "outcome": "review", "title": "Review required", "reason": "New beneficiary with value matched.", "score": 72, "riskLevel": "high", "matchedRules": 1, "nextAction": { "type": "review_activity", "label": "Review transaction" } }, "alertId": "alt_01j9ghi003", "assessedAt": "2026-08-16T09:30:00.284Z" } } ``` Use `summary.outcome` to gate your flow and `summary.nextAction` to continue it. The complete response also retains matched rules, screening, billing, signals and processing evidence when you need to audit or investigate the result. Production billing uses the component prices configured for your organisation. A transaction can contain a fraud-assessment line and one or more screening lines. Sandbox requests are non-billable. Invalid input is rejected before usage is reserved, and service failures are not treated as successful billable assessments. ## Send behaviour activity `POST /api/v1/activities` Use this endpoint for non-transaction customer behaviour. The example below connects a login to the customer, device, IP and location used at the time. ```json { "externalActivityId": "login_10001", "subject": { "type": "individual", "externalUserId": "user_42" }, "type": "login", "occurredAt": "2026-08-16T09:30:00.000Z", "channel": "mobile_app", "deviceRef": "device_7f53", "ipAddress": "102.89.12.34", "country": "NG", "location": { "city": "Lagos", "region": "Lagos", "latitude": 6.5244, "longitude": 3.3792, "accuracyRadiusKm": 10 }, "metadata": { "authenticationMethod": "password", "sessionReference": "session_20001" }, "customAttributes": { "trustedDevice": false, "failedAttemptsBeforeSuccess": 2 } } ``` ### Supported behaviour types | Type | Use it for | |---|---| | `login` | Successful or attempted customer login. | | `signup` | Account registration activity. | | `password_reset` | Password or credential recovery. | | `beneficiary_added` | A new payee or beneficiary. | | `profile_change` | Material customer-profile changes. | | `device_change` | A device replacement or trust-state change. | | `payout` | Non-transaction payout activity that does not carry full sender and recipient details. | | `withdrawal` | Non-transaction withdrawal activity that does not carry full transaction details. | Use `POST /api/v1/transactions` when money movement has sender, recipient, account or wallet evidence. Do not send a transaction through the generic activity endpoint. Each accepted activity updates the customer's Behaviour intelligence with the evidence supplied, evaluates active risk-scoring rules, and records the resulting decision. Location observations appear under **Connected records → Locations & IPs**. Device references contribute to device history and relationship evidence. ## Location and IP evidence Send only evidence you actually collected. `ipAddress` must be a valid IPv4 or IPv6 address, `country` uses a two-letter code, and latitude and longitude must be supplied together. `accuracyRadiusKm` is optional and is accepted only with coordinates. If you send both location and IP evidence, your supplied location remains primary. IP enrichment fills only missing fields. Replaying the same logical activity does not create a duplicate location observation. Location capture happens before billable fraud assessment. Invalid activity input does not create a location record or an assessment charge. A temporary location-enrichment failure does not turn otherwise valid activity into a chargeable failure. ## Idempotency and freshness - Use one `externalActivityId` per logical assessment and keep it stable across retries. - Use one `externalTransactionId` per customer transaction. - Replaying the same request returns the existing assessment with `replayed: true` and does not score or bill it again. - Reusing either identifier with different immutable transaction data returns a conflict. - Send the original `occurredAt`. Myaza retains both the occurrence time and receipt time so late events remain visible and do not distort the audit trail. ## Webhooks Subscribe to `fraud.transaction.assessed` for every transaction decision and `fraud.activity.assessed` for every behaviour decision. Transaction webhooks include the subject, external activity and transaction references, structured parties, masked instruments, screening evidence and billing breakdown. Use `event.flagged` only as a compatibility alias for non-allow decisions. See [Transaction Monitoring webhooks](https://trust.myaza.co/documentation/webhook-transaction-monitoring/markdown) and [Event Monitoring webhooks](https://trust.myaza.co/documentation/webhook-event-monitoring/markdown) for delivery contracts and retry guidance. ## Compatibility event endpoint `POST /api/identity/events` remains available for existing integrations: ```json { "externalUserId": "user_42", "externalEventId": "txn_10001", "type": "transaction.transfer", "data": { "amount": 48000, "currency": "NGN", "country": "NG", "counterparty": "beneficiary_998877", "direction": "outbound", "channel": "bank_transfer", "ipAddress": "102.89.12.34", "location": { "city": "Lagos", "region": "Lagos", "latitude": 6.5244, "longitude": 3.3792, "accuracyRadiusKm": 10 } } } ``` This reduced shape can store and score a simple event, but it cannot submit structured sender and recipient profiles or attach instruments to each party. Migrate transaction integrations to `POST /api/v1/transactions` to receive complete relationship intelligence, directional screening, billing evidence and human-readable transaction titles. ## Errors | Status | Error | Meaning | |---|---|---| | `400` | `invalid_request` | Required fields are missing, money is not an exact decimal string, location is incomplete or the payload contains unsupported nested values. | | `402` | `insufficient_wallet` | The production credit balance cannot reserve the configured assessment components. | | `404` | `subject_not_found` or `party_not_found` | The supplied customer or linked entity does not exist in this organisation and environment. | | `409` | `idempotency_conflict` or `transaction_id_conflict` | A stable identifier was reused for different immutable data. | | `422` | `subject_type_not_supported` | The subject cannot be assessed by this activity contract. | | `503` | Service failure | Assessment did not complete. Follow normal retry guidance using the same identifiers. | ## Next steps - [Fraud rules](https://trust.myaza.co/documentation/transaction-rules/markdown): install or build controls for transactions, logins and other activity. - [Ongoing monitoring](https://trust.myaza.co/documentation/monitoring-policies/markdown): configure recurring customer review. - [Investigations and reporting](https://trust.myaza.co/documentation/investigations/markdown): investigate review and block decisions. - [Webhooks](https://trust.myaza.co/documentation/webhooks/markdown): receive lifecycle events without polling. --- # Fraud rule configuration Fraud rules decide how each [event](https://trust.myaza.co/documentation/monitoring-events/markdown) is scored and which decision it earns. Configure them under **Rules & Policies → Fraud rules**. Changes apply to events scored afterwards in that environment. Start in **Rule library** to install a reviewed starter control in one click. Open **Current rules** to tune decision thresholds, currency limits and built-in signals, or choose **Build custom rule** when your integration sends organisation-specific evidence. Library rules use the same versioned, testable rule engine as custom rules, so installation does not introduce a second decision path. Viewing the configuration is open to anyone with Risk Intelligence access; **editing requires the `monitoring:manage` permission**. Until you save once, an environment runs on the built-in defaults; saving creates an explicit configuration for that environment. **Reset to defaults** reverts the form to the platform defaults. ## How scoring works 1. Every rule inspects the event and produces a **signal** from 0 (no risk) to 1 (maximum). 2. Signals are combined by **weight** using a soft-OR: any one strong signal can push the score high, and additional signals raise it further with diminishing returns. 3. The result is a **composite score, 0–1** (surfaced as 0–100 on events). 4. The score is compared to two cutoffs to produce the **decision**. ``` ALLOW REVIEW BLOCK ├──────────────┼──────────────────┼──────────────────┤ 0 review threshold block threshold 1 ``` ## Decisions & limits The first tab, **Decisions & limits**, sets the cutoffs and money ceilings. ### Decision thresholds | Setting | Meaning | |---|---| | **Review threshold** | At or above this score, the event is marked `REVIEW` and an [alert](https://trust.myaza.co/documentation/investigations/markdown) is opened. | | **Block threshold** | At or above this score, the event is marked `BLOCK`. | The review threshold must be **less than or equal to** the block threshold. **Metered billing** (toggle) charges a small per-event fee for scoring in **production** only; sandbox and development are always free. ### Money limits Amount ceilings are held in a **base currency** (e.g. `USD`) with optional **per-currency overrides**. For each event, the engine picks the ceiling for that event's currency, falling back to the base currency when there's no override. | Limit | Feeds | |---|---| | **Single transaction** | The **Threshold** rule: the most one transaction can be before it flags. | | **Rolling-window total** | The **Velocity** rule: the most an entity can move in a currency within the velocity window. | Add a currency to give it its own limits; remove it to fall back to base. Some behavioral rules also take per-currency **minimum amounts** (New-beneficiary, Round-amount). ## Built-in rules Every rule carries a **weight** (0–1): how strongly it influences the composite score. `0` turns the rule off; `1` gives it full influence. The rules are grouped into three tabs. ### Core rules Threshold-based checks that work from `amount` / `currency` / `country` / `counterparty` alone. | Rule | Flags when… | Key config | |---|---|---| | **Threshold** | A single event's amount exceeds its currency's single-transaction limit. | weight | | **Velocity** | Too many events, or too much total value in one currency, across a rolling window. | window (hours), max count, weight | | **Pattern (structuring)** | A burst of same-currency events sit just under the single-transaction limit (3+ at 70–100% of the limit). | window (hours), weight | | **Geography** | The event's `country` is on your high-risk or blocked list. | high-risk[], blocked[], weight | | **Counterparty** | The `counterparty` is on your high-risk or blocked list (one entry per line, case-insensitive). | high-risk[], blocked[], weight | | **Baseline anomaly** | The amount deviates sharply from what's normal *for this entity* (EWMA per entity, cohort fallback during warm-up). | z-score threshold, weight | ### Flow & velocity Behavioral patterns across a sequence of events. These need specific `data` fields and stay dormant until you send them. | Rule | Flags when… | Needs | Key config | |---|---|---|---| | **Dormant-then-active** | An account silent for N days suddenly transacts. | — | dormant days, weight | | **Pass-through** | Money flows in then straight back out in matched amounts (layering). | `direction` | window (hours), match ratio, weight | | **Rapid movement** | Funds leave almost as fast as they arrive (opposite-direction flow within minutes). | `direction` | window (minutes), weight | | **Smurfing** | Many distinct senders funnel money into one account. | `direction`, `counterparty` | window (hours), min sources, weight | ### More signals | Rule | Flags when… | Needs | Key config | |---|---|---|---| | **New beneficiary** | A first, sizeable payment to a counterparty never paid before. | `counterparty` | window (hours), min amount, weight | | **Round amount** | Suspiciously round amounts, exact multiples of a unit at or above a minimum. | `amount` | unit, min amount, weight | | **Cross-border** | A transaction in a country different from the entity's nationality (elevated if that country is on your geo lists). | entity `nationality`, event `country` | weight | | **Cash-intensive** | The event moved cash (`channel` = `cash`). | `channel` | weight | | **Profile change** | The entity performs a transaction `type` it hasn't done before, once it has enough history. | `type` | min history, weight | > **Match your payload to your rules.** A behavioral rule with a weight but without the fields it needs never fires. The rule-coverage panel flags rules that are weighted but unconfigured. See the [event `data` payload](https://trust.myaza.co/documentation/monitoring-events/markdown#the-data-payload). ## Custom rules When the built-ins don't cover a scenario, author your own declaratively, with no code. Each custom rule has: | Field | Purpose | |---|---| | **Name** | Machine-readable id, e.g. `high_value_crypto`. Fires as `custom:` on alerts and SAR reports. | | **Description** | Human label shown on alerts, e.g. "Large crypto transfer". | | **Enabled** | On/off toggle. | | **Condition** | The match expression (below). | | **Score** | Risk contribution (0–1) when the condition matches. | | **Weight** | How strongly that contribution affects the composite score (0–1). | You can define up to **50** custom rules per environment. Before saving a change, choose **Test changes**. Myaza replays recent scored events as a read-only historical test and shows how many decisions would change. The test creates no alerts, investigations, customer updates or charges. ### Conditions A condition is a tree combining field predicates with `all` (AND), `any` (OR), and `not`. The dashboard offers a **Builder** for flat all/any predicates and an **Advanced (JSON)** mode for arbitrary nesting. **Fields** you can test: `amount`, `type`, `currency`, `country`, `counterparty`, `direction`, `channel`, `ip`, `entity.riskTier`, `entity.nationality`, and any custom `meta_data.*` path. **Operators:** `gt` (>), `gte` (≥), `lt` (<), `lte` (≤), `eq` (=), `neq` (≠), `in` (in list), `nin` (not in list), `contains`, `exists`. ```json { "all": [ { "field": "amount", "op": "gt", "value": 50000 }, { "field": "currency", "op": "in", "value": ["BTC", "ETH"] } ] } ``` The rule above (named `high_value_crypto`, score `0.8`, weight `0.6`) flags large crypto transfers and contributes to the same composite score as the built-ins. An empty condition never fires, so the editor warns you to add at least one predicate. ## Next steps - [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown): send events these rules score. - [Investigations and reporting](https://trust.myaza.co/documentation/investigations/markdown): investigate what the rules flag. --- # Continuous monitoring Monitoring re-checks selected evidence on a schedule and records material risk changes. The capability appears where the work happens: | Task | Dashboard location | | --- | --- | | Enable, pause, resume, turn off, or change frequency for one customer | **Entities → Customer → Risk** | | See what changed across customers | **Risk Intelligence → Overview** | | Work an actionable change | **Review Queue** | | Install or edit policy defaults | **Rules & Policies → Ongoing monitoring** | The public API remains under `/api/v1/monitoring`. ## Policy defaults and entity overrides A policy defines the checks, default frequency, material-change threshold and escalation behaviour. An Entity can follow that default or use an explicit frequency override. Supported frequencies are `hourly`, `daily`, `weekly` and `monthly`. Every subscription response identifies: - `frequency.effective`, the schedule currently used; - `frequency.policyDefault`, the active policy default; - `frequency.override`, the Entity override or `null`; - `frequency.source`, either `policy_default` or `entity_override`. Changing a policy default updates Entities that follow the default. It does not silently replace an explicit Entity override. ## Entity scope and high-risk handling `config.scope.entityTypes` defines which Entity types may use a policy version. Values are lower case: `individual`, `business`, `wallet`, `bank_account`, `device`, `email`, `phone` and `ip`. Omit `scope` to include every supported type. Scope is stored with the immutable policy version. Start, bulk enrolment, policy change and re-enable requests reject an Entity outside that version's scope. Publishing also validates the scope against affected active subscriptions. Scope controls eligibility, not risk severity. Use `alertLevels`, normally `high` and `critical`, to select the risk levels that can open an alert. Use `escalation.investigation` to choose `none`, `manual` or `automatic` investigation handling. ## Enable monitoring `POST /api/v1/monitoring/subscriptions` ```json { "subject": { "type": "individual", "id": "ent_01" }, "policyId": "pol_01", "frequency": "daily" } ``` Omit `frequency` or send `null` to use the policy default. Send an `Idempotency-Key` header for every mutation. ## Change policy or frequency `PATCH /api/v1/monitoring/subscriptions/{subscriptionId}` ```json { "frequency": "hourly" } ``` Restore the policy default: ```json { "frequency": null } ``` The same endpoint accepts `policyId` when an Entity should move to another published policy. ## Pause, resume and Off Pause and Off are different states. | Action | Endpoint | Effect | | --- | --- | --- | | Pause | `POST /api/v1/monitoring/subscriptions/{id}/pause` | Keeps configuration and history. No future scheduled monitoring checks or charges run until resumed. | | Resume | `POST /api/v1/monitoring/subscriptions/{id}/resume` | Restarts a paused subscription using its current policy and frequency. | | Off | `POST /api/v1/monitoring/subscriptions/{id}/stop` | Permanently ends that subscription. History remains and no future checks or charges run. | An ended subscription is immutable. Re-enable monitoring with: `POST /api/v1/monitoring/subscriptions/{endedSubscriptionId}/enable` ```json { "policyId": "pol_01", "frequency": null } ``` The response contains the linked new active subscription. The ended record remains available for audit history. ## Retrieve state and history `GET /api/v1/monitoring/subscriptions/{subscriptionId}` returns the current state, effective frequency, recent runs, material deltas, alerts and billing evidence. Use the Entity view for day-to-day customer management. Use Risk Intelligence for material changes across the portfolio, and Review Queue when a change requires action. ## Billing behaviour Only completed billable checks consume usage. Paused and ended subscriptions create no future scheduled monitoring checks or charges. Historical usage remains unchanged. ## Webhooks Start with `monitoring.delta.detected` for material customer-risk changes. Add `monitoring.run.failed` when failed scheduled checks need an operational response. See [Continuous Monitoring webhooks](https://trust.myaza.co/documentation/webhook-continuous-monitoring/markdown) for the exact V2 payload. --- # Fraud rules Fraud rules evaluate one activity and return **Allow**, **Review** or **Block**. They can apply to transactions, logins, sign-ups, password resets, beneficiary changes, payouts and withdrawals. [Ongoing monitoring](/developers/documentation/monitoring-policies) is separate. It decides who is checked again over time and when a material change should reach your team. ## Start with the rule library Open **Rules & Policies → Fraud rules → Rule library**. Search or filter, then select **Install**. Installation publishes version 1 for the current organisation and environment. Repeating the same installation does not create a duplicate. Disabling a rule keeps its decision history. ## Test before you publish Use **Test against last 30 days** before publishing a custom rule. The test does not change activities, create alerts or create charges. ## Publish with control Publishing creates an immutable version. Later edits create another version so an earlier decision always points to the exact rule that produced it. ## Decision settings Open **Settings** to configure review and block thresholds, money limits and advanced scoring. These settings combine matched rules into the final decision. They are not a second rule library. ## API response Read `assessment.summary` first: ```json { "assessment": { "summary": { "outcome": "review", "title": "Review required", "reason": "A high-risk transaction rule matched.", "score": 82, "riskLevel": "high", "matchedRules": 1, "nextAction": { "type": "review_activity", "label": "Review transaction" } } } } ``` Use `summary.outcome` to branch and `summary.nextAction` for the next workflow step. Detailed rules, screening, billing and Travel Rule evidence remain available when you need them. ## Webhook Subscribe to `fraud.transaction.assessed` or `fraud.activity.assessed`. Both return the same `data.summary` shape. Deduplicate by the webhook envelope `id`. Use your `externalActivityId` and `externalTransactionId` to correlate the result. Do not recalculate the decision from score or detailed evidence. A later `fraud.decision.changed` event carries a new summary when the effective decision changes. --- # Investigations and reporting This is where flagged activity becomes an owned, evidence-backed decision. [Events](https://trust.myaza.co/documentation/monitoring-events/markdown) that score high raise **alerts**; your team opens an **investigation**, assigns an owner, reviews the evidence and records an outcome. Where activity is reportable, the investigation can produce a **SAR** or **STR**. Every action is audited server-side. ## Alert or investigation? An **alert** is a signal that needs triage. It shows what happened, why Myaza raised it and the rules or screening evidence involved. An **investigation** is the casework record. Use it when the alert needs an accountable owner, more evidence, remediation, escalation or a formal outcome. This separation keeps the alert queue fast without losing the deeper audit trail required for material decisions. ## Alerts An **alert** is opened automatically when an event scores at or above your [review threshold](https://trust.myaza.co/documentation/monitoring-rules/markdown#decision-thresholds). Review alerts under **Compliance → Alerts** (`alerts:read`). | Field | Notes | |---|---| | **Decision** | The event's decision: typically `REVIEW` or `BLOCK`. | | **Risk score** | The score that triggered it (0–100). | | **Status** | `OPEN`, `RESOLVED`, or `DISMISSED`. | | **Reasons** | The rules that contributed, each with a score and a detail string (e.g. `amount 48000 > 10000`). | The list shows each alert's entity, decision, risk score, status, and how many investigations it belongs to. > Alerts appear here when monitoring flags an event for review or block. ## Opening and assigning an investigation Open an alert to review its decision evidence. If more work is needed, choose an investigator from your organisation and select **Open investigation**. You can also select multiple alerts from one entity and open a grouped investigation. All linked alerts must belong to the same entity so the investigation remains scoped to one customer. ## Investigation workspace The investigation workspace puts the decision controls first: 1. **State** shows where the investigation is in its lifecycle. 2. **Why** summarises the strongest reasons that raised the linked alerts. 3. **Evidence** keeps rule explanations, linked records and uploaded attachments together. 4. **Owner** assigns accountability to a member of your organisation. 5. **Next action** starts review, escalates, records an outcome, closes or reopens the investigation. | Attribute | Values | |---|---| | **Status** | `OPEN` → `IN_REVIEW` → `ESCALATED` or `RESOLVED` → `CLOSED`, with reopening when new evidence appears. | | **Outcome** | `CLEARED`, `CONFIRMED`, `ESCALATED` or `INCONCLUSIVE`, supported by an analyst rationale. | | **Assignee** | A team member, or unassigned. | | **Alerts** | The grouped alerts; add more of the entity's alerts at any time. | Resolving and closing are separate. **Resolve** records the evidence-backed outcome; **close** completes the operational work. The rationale, actor, timestamps and later reopening remain in the immutable timeline. > Open an investigation from the Alerts view when the evidence needs an owner or outcome. ## Server-side SDK The public SDK calls this resource `investigations`: ```js const investigation = await myaza.investigations.retrieve(investigationId); const evidence = await myaza.investigations.evidence.list(investigationId); const timeline = await myaza.investigations.timeline(investigationId); ``` The canonical HTTP paths are `/api/v1/investigations`. The deprecated `/api/v1/cases` paths and `myaza.cases` SDK resource remain available for existing integrations; new integrations should use `myaza.investigations` and the primary `Investigation` SDK types. ## SARs & STRs When an investigation is reportable, draft a regulatory filing from it. The existing permission key for these actions is `cases:manage`. | Attribute | Values | |---|---| | **Report type** | `SAR` (Suspicious Activity Report) or `STR` (Suspicious Transaction Report). | | **Status** | `DRAFT` → `FILED`, or `CANCELLED`. | | **Reference** | Your external filing reference (e.g. the regulator's number), set when filing. | The lifecycle: 1. **Draft**: choose `SAR` or `STR` and a reason. The report is generated server-side in **goAML** format. 2. **Review & export**: inspect the goAML payload on the SAR detail view and **export it as XML** for submission to your FIU/regulator. 3. **File**: record the external reference and file path; the SAR moves to `FILED`. 4. **Cancel**: a `DRAFT` can be cancelled if it's no longer needed. ## Audit Every investigation and SAR write (open, assign, add evidence, transition, resolve, close, reopen, draft, file, cancel) is recorded in your organisation's audit log (**Dashboard → Audit logs**), with the actor and timestamp. ## Related - [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown): how alerts get raised. - [Monitoring rules](https://trust.myaza.co/documentation/monitoring-rules/markdown): the thresholds that decide `REVIEW` vs `BLOCK`. - [Compliance overview](https://trust.myaza.co/documentation/compliance/markdown): the end-to-end lifecycle. --- # Decision effectiveness Risk scores are useful only when you can prove what happened afterwards. Myaza connects each fraud or transaction decision to a downstream outcome so your team can measure captured fraud, false positives, check quality and financial impact. This gives your organisation three concrete benefits: - **Protect more revenue:** identify the controls and check types associated with confirmed fraud, prevented loss and recovery. - **Reduce customer friction:** measure legitimate customers interrupted by review or block decisions. - **Improve safely:** review drift, thresholds and check reliability without silently changing a live policy. Open **Risk Intelligence → Effectiveness** to see the current state, why Myaza reached it and the next safe action. Metrics remain marked as **Not enough evidence** until enough confirmed outcomes exist. Myaza does not turn missing labels into a reassuring zero. ### Understand the four headline measures - **Fraud captured** is the share of confirmed fraud linked to an interrupted transaction. - **Precision** is the share of interrupted, labelled transactions that were confirmed as fraud. - **False positives** are legitimate customers interrupted by a review or block decision. - **Outcome coverage** is the share of decisions with a confirmed downstream result. Low coverage means the other measures are not yet trustworthy. ## Record an outcome Send the final known result for a transaction. Use the same idempotency key when retrying the same outcome. The path reference may be a Myaza activity ID, external activity ID, Myaza transaction ID or your external transaction ID. Include `externalUserId` as an ownership guard when you have it. Myaza rejects the request with `409 external_user_mismatch` if that customer does not own the resolved activity. ```bash curl --request POST \ --url https://trust.myaza.app/api/v1/transactions/{transactionId}/outcomes \ --header 'Authorization: Bearer sk_live_…' \ --header 'Idempotency-Key: outcome-2026-08-16-001' \ --header 'Content-Type: application/json' \ --data '{ "externalUserId": "customer-8421", "classification": "fraud", "source": "chargeback", "occurredAt": "2026-08-16T10:30:00.000Z", "reasonCode": "chargeback.confirmed", "currency": "USD", "lossAmount": "125.50000000", "recoveredAmount": "25.00000000" }' ``` Outcome records are append-only. Replaying an identical request is safe. Reusing the idempotency key with different data is rejected. Resolved investigations are recorded automatically as fraud, legitimate or inconclusive outcomes for every linked transaction decision. Filed SARs add inconclusive escalation evidence and never assert guilt. For chargebacks, call this endpoint from the final dispute webhook with the event ID as the idempotency key. ## Read effectiveness evidence ```bash curl --request GET \ --url 'https://trust.myaza.app/api/v1/risk-intelligence/effectiveness?from=2026-08-01T00:00:00.000Z&to=2026-09-01T00:00:00.000Z' \ --header 'Authorization: Bearer sk_live_…' ``` The response includes decision distribution, outcome coverage, fraud-capture rate, false-positive rate, precision, exact decimal financial impact, segment comparisons and per-rule effectiveness. Check `completeness.complete` before using a report for policy decisions. The dashboard lets authorised teams filter the evidence window, review recommendations, manage immutable system versions and run shadow experiments. ## Drift, check reliability and relationships Use the authorised Risk Intelligence endpoints to retrieve: - `GET /api/v1/risk-intelligence/drift` - `GET /api/v1/risk-intelligence/providers` - `GET /api/v1/risk-intelligence/threshold-recommendations` - `GET /api/v1/risk-intelligence/system-versions` - `GET /api/v1/risk-intelligence/experiments` - `GET /api/v1/risk-intelligence/network/{subjectKind}/{subjectId}` Network exploration is bounded by depth and node limits. Reliability comparisons show availability, latency, disagreement and adjudicated false positives without ranking internal sources from incomplete evidence. ## Safe optimisation Recommendations never change a live customer decision automatically. They require labelled evidence, review, backtesting and a separately published system or rule version. Shadow experiments use the same canonical decision context but cannot change live decisions, alerts or billing. Myaza refreshes fixed daily evidence windows on a schedule. Re-running the same window updates its canonical snapshot rather than creating duplicate drift evidence. The full improvement loop is **rule → safe historical test → governed publish → decision evidence → confirmed outcome → effectiveness review**. Read [Protect transactions with rules](/dashboard/developers/documentation/transaction-rules) to install controls and test changes before they affect customers. ## Webhooks Subscribe to the lifecycle events below to keep your project in sync: - `fraud.outcome.recorded` - `risk.drift.detected` - `risk.threshold.recommended` - `risk.system_version.changed` Each customer-facing event includes `externalUserId` when the activity is linked to one, together with the internal activity or transaction reference used by Myaza. --- # Platform simulator The interactive platform simulator is part of the authenticated Myaza Trust documentation experience. It uses your current sandbox environment and the canonical Risk Intelligence, Monitoring, Transaction Intelligence, and Investigations workflows. [Open the platform simulator](/developers/simulator) Production execution is disabled. Scenarios that require controlled service fixtures remain clearly locked until those fixtures are configured. ## Test monitoring in context Create a Sandbox customer, then filter the catalogue to **Monitoring**. The lifecycle journeys cover: - immutable policy scope across all supported lower-case Entity types; - pause and resume without a scheduled check or charge while paused; - permanent Off with retained history; - an hourly Entity override against a daily policy default; - removing the override and returning to the policy default; - re-enabling an ended period as a distinct active subscription. Monitoring scenarios reuse stable Simulator policies by check set. Running the same journey again does not create another policy version or a new timestamped policy. Results link to the Entity for state and history, Risk Intelligence for material changes, Review Queue for action, and Rules & Policies for defaults. The public integration remains under `/api/v1/monitoring`. ## Set up Sandbox Travel Rule Travel Rule scenarios need a compatible published policy. If the Simulator shows **Setup required** or **Policy incompatible**, select **Set up Travel Rule**. The action opens [**Risk Intelligence → Rules & Policies → Fraud rules → Travel Rule**](/risk-intelligence/rules-policies/decisions?view=travel-rule&preset=sandbox-travel-rule-simulator) with the server-owned `sandbox-travel-rule-simulator` preset selected. 1. Select **Create Sandbox draft** or **Replace with Sandbox draft**. This creates a new draft, or supersedes the current unpublished draft; it does not change the active policy. 2. Review the draft, then select **Publish draft** separately. 3. Return to the Simulator and refresh. After the Simulator refetches the published policy, the setup shows **Ready for Simulator** and the scenario shows **Runnable**. The explicit Simulator fixture is deterministic and has no charge in local development and Sandbox. Ordinary Sandbox requests without a scenario still use configured pricing, and Production behaviour is unchanged. Remove `sandboxScenario` from Production requests. See [Travel Rule for crypto transfers](https://trust.myaza.co/documentation/travel-rule/markdown) for policy design, API requests, remediation and webhook events. --- # Travel Rule for crypto transfers Myaza Travel Rule helps your organisation decide when a crypto transfer requires originator, beneficiary and counterparty information. It assesses that information with identity, sanctions, PEP, adverse-media and wallet evidence, then recommends whether the transfer can proceed, must be held for review or must be stopped. The operational result always answers three questions: 1. **What happened?** The assessment is not required, needs information, needs review, is blocked, can proceed or failed validation. 2. **Why?** Myaza records the matched policy rule, missing fields, identity evidence, VASP evidence and screening results. 3. **What next?** Supply missing information, open the exact investigation, retry a failed validation or take no action. ## Choose your next step | Task | Guide | |---|---| | Your company, institutions and application. | [Set up Travel Rule](https://trust.myaza.co/documentation/travel-rule-setup/markdown) | | Choose rules, information packages and publication. | [Transfer requirements](https://trust.myaza.co/documentation/travel-rule-policies/markdown) | | Send the information you already hold in one request. | [Create a transfer](https://trust.myaza.co/documentation/travel-rule-transactions/markdown) | | Recover incomplete transfers without creating duplicates. | [Supply missing information](https://trust.myaza.co/documentation/travel-rule-information/markdown) | | Secure messaging and the customer wallet's execution gate. | [Exchange and authorise](https://trust.myaza.co/documentation/travel-rule-execution/markdown) | | Review incoming information and admit a verified deposit. | [Receive a transfer](https://trust.myaza.co/documentation/travel-rule-incoming/markdown) | | Report wallet confirmation and track message delivery. | [Confirm an outgoing transfer](https://trust.myaza.co/documentation/travel-rule-confirmation/markdown) | | Events, current status and safe notification handling. | [Receive Travel Rule updates](https://trust.myaza.co/documentation/travel-rule-webhooks/markdown) | | Signed control proof and separate address review. | [Self-hosted wallets](https://trust.myaza.co/documentation/travel-rule-self-hosted/markdown) | | Exercise information gaps, review and stopped transfers. | [Test and troubleshoot](https://trust.myaza.co/documentation/travel-rule-testing/markdown) | ## What this feature includes When a policy permits identity alternatives, the missing-information form lets you choose one accepted option. For a government ID, enter its number, choose the actual ID type and provide its issuing country. Business-only rules can accept an LEI. Do not guess an identifier's type. Existing untyped records are not converted, and completing these fields does not verify the person's or company's identity. - A governed rule library and guided rule builder. - Organisation and environment-scoped policy versions. - Impact testing against recent crypto transactions before publication. - Draft-first publication with an immutable publisher record for every active version. - Immutable assessment generations and policy snapshots. - Plain-language API and webhook summaries with the complete evidence retained. - Idempotent information submission when a transfer is missing required data. - Deterministic, non-billable Sandbox scenarios through the API. Myaza combines information checks with a Crypto Provider Directory, address book and governed institutional connections. A directory entry does not prove wallet ownership or establish a messaging connection. Live exchange requires a reviewed connection with the receiving institution. Your organisation remains responsible for the jurisdictions, thresholds and information requirements applicable to its regulated activity. ## Integrate with an AI agent Use the [AI integration guide](https://trust.myaza.co/documentation/ai-integration/markdown) for the product index, downloadable skills and machine-readable API contracts. Start with the Travel Rule skill and fetch only the guides needed for your workflow. --- # Set up Travel Rule ## Get started in the dashboard 1. Open **Fraud Monitoring → Transactions → Travel Rule** and select **Set up Travel Rule**. 2. **Your company:** confirm the company you represent. Myaza reuses your company verification and reviews the directory match. 3. **Institutions:** choose the exchanges you send to or receive from. Review each company before approving information exchange. Save your selection. 4. **Your application:** follow the integration guide and configure your result webhook. You can do this while Myaza prepares the connections. 5. **Test and activate:** Myaza checks the institution connection automatically; you can also rerun its test. Send a test transaction and enter its external activity reference. Complete the displayed checks and request activation. Setup is saved for your organisation and environment. Close it and return later to continue. Myaza manages certificate issuance and renewal in the background; customers do not upload certificates, edit server configuration or restart anything. Direct TRP connections use managed certificates; CodeVASP connections use the approved institution registration. CodeVASP-only setup does not require a TRP certificate. An independent Myaza reviewer confirms company representation and activation evidence. Activation checks institution communication, configured notifications, the received test and country policy reviews. The reviewer also checks evidence of real result delivery/recovery and the organisation's wallet enforcement. A configured webhook is not proof that your application verified its signature or acted on a decision. **Active** means this organisation's setup is activated in the selected environment. Every transfer still needs its own current clearance. **Pause Travel Rule** stops new exchange and execution; it does not reverse a transfer that already completed. ### Connect a crypto company If your company is missing from the directory, a Myaza platform administrator can add it through **Crypto providers → Add crypto company**, with its registration evidence. Myaza establishes the partner arrangement once, then provisions customer connections covered by that arrangement automatically. **Awaiting institution** means Myaza is still arranging that channel. Selecting a name cannot make another company trust it. Once the connection appears in **Set up Travel Rule**, use **Test connection** to check it or **Pause** to stop new exchanges. **Messaging setup needed** means identity testing passed but the exchange configuration is incomplete. Open the adjacent information button for the next action. A verified connection is not an approval for any transfer. Technical diagnostics and previous configuration remain under **Existing connections and diagnostics**. Keep Sandbox and Live separate. ### Add another destination Open **Institutions**, select the company and save. Existing active connections keep working. Myaza reuses the platform's approved partner route and provisions it for your organisation without a restart or another company activation review. **Connection requested** means the destination is still being arranged; **Connected** means its current connection checks passed. Each transfer still has its own clearance checks. Your team's institution approval and the destination's secure communication and country reviews remain required. An unavailable destination does not disable other destinations. Removing an institution stops new exchanges with that institution only. Company identity changes, expired company approval or pausing the whole service still require setup attention. ## A practical example You operate **Exchange A**. Alice is your customer and wants to send USDT to Bob at **Exchange B**. 1. Your backend sends Myaza one transaction containing Alice's and Bob's available information, the amount and the destination institution. 2. Myaza uses the approved connection to Exchange B to exchange the required information. You do not build another integration for every transfer or handle certificates. 3. Myaza reports what is missing, needs review or is ready for the next step. Your backend retrieves the current clearance, consumes its short-lived authorisation once, and instructs **your own wallet** to send. For a held transfer it does not send. 4. If Bob's required details were missing, your backend submits only the additional information against the same transaction. If the first request was complete, that follow-up request is unnecessary. Adding Exchange B to a directory is like adding a business contact. Establishing its connection is like verifying that you have the right secure delivery address. A contact record alone cannot receive a message. The connection is reused, not rebuilt per user. ## Your company and your counterparties **Your company** identifies the organisation you represent. **Add crypto company** adds a directory record when an institution is missing; it does not connect an application or enable secure messaging. **Connections** are authenticated channels between institutions. Before exchanging regulated information, Myaza needs a reviewed channel to the institution involved. A saved directory entry is not enough. You do not need to configure a connection for each transfer or each individual customer. Reuse the institution connection within the correct organisation and environment. Your application connects to **Myaza's API and webhooks**. The institution connection is a different path: Myaza exchanges the required information with the other institution. Your wallet still enforces the current decision. ### What if you only have a wallet address? A blockchain wallet address does not tell Myaza the owner's name, their exchange or how to contact them. Myaza does not guess ownership or send personal information to an endpoint discovered from an unverified address. Ask your sender for the recipient's exchange and available recipient details. The recipient can obtain a **Travel Address** from a supporting exchange and share it with the sender. This is a secure information-exchange address, not a crypto wallet address. With a Travel Address, Myaza can select an unambiguous existing approved route automatically. The receiving exchange privately resolves its own customer. If information is missing, Myaza returns the required fields to **your application**. Your application asks your sender to supply or obtain them. Myaza cannot independently contact an unknown wallet owner. Details must still be checked; supplying a name is not proof that the person controls the wallet. | Recipient destination | What happens | | --- | --- | | Connected institution | Exchange through its authenticated route, whether or not it is a Myaza customer. | | Institution without a working route | Request the destination in setup. Keep the transfer held while Myaza arranges a compatible, approved channel. | | Confirmed self-hosted wallet | Follow the separate self-hosted wallet flow and required control checks. Do not call an unknown wallet self-hosted just to continue. | | Unknown owner or institution | Obtain more information; do not guess a route or release a held transfer. | Myaza is not a closed customer-only network. External institutions can communicate through an approved CodeVASP route or compatible TRP connection without purchasing Myaza. A supported protocol does not mean every exchange already has a working connection. For a new transfer, choose the receiving institution. Myaza offers one ready route per institution; where both are available, the managed CodeVASP route is preferred. A prepared or uncertain message keeps its original route. Do not create another message or switch protocols to work around a lost response. --- # Transfer requirements ## Create a Travel Rule rule Open **Risk Intelligence → Rules & Policies → Fraud rules → Travel Rule**. 1. Select **Rule library** and compare the reviewed starting points in the table. Select **Install** beside one to open a prefilled draft, or select **Create rule** to start from a blank scope. 2. Name the rule and choose its transfer direction, counterparty type, jurisdictions, assets and networks. 3. Choose the standard, enhanced or **Supporting detail with alternatives** package. 4. Add an optional amount threshold only when your governing policy requires one. 5. Select **Test impact**. This previews the complete draft against recent crypto transfers without changing transactions, creating alerts or charging customers. 6. Select **Save draft**. 7. Select **Publish draft** when you are ready to make the saved policy version active. The active policy can contain several ordered rules. The first rule matching a transfer determines the information requirements. A transfer outside every explicit scope remains unknown and is held rather than treated as exempt. ### Let customers provide a permitted alternative **Supporting detail with alternatives** collects the core party information plus one supporting sender detail: a customer reference or a structured address. Choose **Individuals** for **Sender type** under **Advanced transfer filters** to also allow date and place of birth together. Business rules do not offer birth details. Review the chosen package against your country requirements before publication; these are configurable building blocks, not automatic country approval. On a transaction with missing information, select **Add missing information**. When the policy permits alternatives, choose the detail you can provide and fill the displayed fields. Myaza reassesses the transaction and retains the previous attempt for audit. A partial address correction preserves the address fields already supplied. Hidden alternatives are not submitted by the form. For API clients, `fields.baseRequired` lists always-required paths, while `fields.alternatives` lists groups of fully qualified paths. Supply **every** field in **one** group for each requirement. Send nested address fields as an object: ```json { "originator": { "residentialAddress": { "city": "Lagos", "country": "NG" } } } ``` The example completes a previously supplied street address; it is not a complete address by itself. Post corrections to `/api/v1/transactions/{id}/travel-rule/information` using the same idempotency key and body on retries. Missing/unknown evidence still holds the transfer, and the organisation's wallet must use the separate execution authorisation flow. ## Publish a draft Saving a rule never activates it. Myaza stores the complete configuration as a new draft version so the active policy and historical assessments remain unchanged. Any authorised Travel Rule manager can publish the draft, including the person who created it. Publication records the publisher and activation time, then freezes that policy version so historical decisions remain reproducible. ## Information packages **Standard party information** collects legal names and account identifiers for the originator and beneficiary. VASP transfers also collect the counterparty VASP legal name and jurisdiction. **Enhanced party and VASP information** additionally collects party residential addresses and countries of residence, plus VASP registration and licence identifiers. These packages are product starting points, not a legal determination. Review and adjust the saved draft for the laws, corridors and services applicable to your organisation. --- # Create a transfer ## Submit a crypto transaction Use a secret API key and a unique idempotency key. Set `MYAZA_API_BASE_URL` to the versioned API base, such as `http://localhost:32101/api/v1` locally: ```bash curl -X POST "$MYAZA_API_BASE_URL/transactions" \ -H "Authorization: Bearer $MYAZA_SECRET_KEY" \ -H "Idempotency-Key: crypto-transfer-1042-v1" \ -H "Content-Type: application/json" \ -d '{ "externalActivityId": "activity-1042", "subject": { "type": "individual", "externalUserId": "customer-42" }, "occurredAt": "2026-08-25T20:00:00.000Z", "transaction": { "externalTransactionId": "transfer-1042", "assetClass": "crypto", "direction": "outbound", "amount": "2500", "currency": "USDT", "transactionType": "crypto_transfer", "blockchain": "TRON", "travelRule": { "sourceJurisdiction": "NG", "destinationJurisdiction": "GB", "counterpartyType": "vasp", "walletType": "hosted", "originator": { "partyType": "individual", "data": { "legalName": "Originator name", "accountIdentifier": "customer-account-reference" } }, "beneficiary": { "partyType": "individual", "data": { "legalName": "Beneficiary name", "accountIdentifier": "beneficiary-account-reference" } }, "counterpartyVasp": { "partyType": "business", "data": { "legalName": "Counterparty VASP", "jurisdiction": "GB" } } } } }' ``` Do not send regulated party information in generic metadata. Use the dedicated `travelRule` party fields so Myaza can minimise, encrypt, retain and purge the evidence under the Travel Rule privacy controls. ### Node.js SDK The same request is available from the server-side Trust SDK: ```js const transaction = await myaza.transactions.create({ externalActivityId: 'activity-1042', subject: { type: 'individual', externalUserId: 'customer-42' }, occurredAt: '2026-08-25T20:00:00.000Z', transaction: { externalTransactionId: 'transfer-1042', assetClass: 'crypto', direction: 'outbound', amount: '2500', currency: 'USDT', transactionType: 'crypto_transfer', blockchain: 'TRON', travelRule: { sourceJurisdiction: 'NG', destinationJurisdiction: 'GB', counterpartyType: 'vasp', walletType: 'hosted', originator: { partyType: 'individual', data: { legalName: 'Originator name', accountIdentifier: 'customer-account-reference' }, }, beneficiary: { partyType: 'individual', data: { legalName: 'Beneficiary name', accountIdentifier: 'beneficiary-account-reference' } }, counterpartyVasp: { partyType: 'business', data: { legalName: 'Counterparty VASP', jurisdiction: 'GB' }, }, }, }, }, { idempotencyKey: 'crypto-transfer-1042-v1' }); if (transaction.travelRule) { console.log(transaction.travelRule.summary.title, transaction.travelRule.summary.nextAction); } ``` ## What happens next? This example includes both parties for the standard information package. Your published policy can require additional fields or evidence. Read the returned summary, then follow its next action. You do **not** need to submit the same information again. Use the returned `activityId` for later requests, not a guessed identifier. A read retrieves the current assessment; it does not create another transaction. If information is missing, use [Supply missing information](https://trust.myaza.co/documentation/travel-rule-information/markdown). For institutional messaging and the wallet gate, continue to [Exchange and authorise](https://trust.myaza.co/documentation/travel-rule-execution/markdown). These are ordinary API requests and use your configured pricing. For labelled, non-billable scenario traffic, use [Test in Sandbox](https://trust.myaza.co/documentation/travel-rule-testing/markdown). --- # Supply missing information The first transaction request can contain all known party information. A second information submission is needed **only** when fields are missing or need correction. In the missing-beneficiary Sandbox scenario, the sender is present and the recipient is deliberately empty. ## Read the result ```bash curl "$MYAZA_API_BASE_URL/transactions/activity-1042/travel-rule" \ -H "Authorization: Bearer $MYAZA_SECRET_KEY" ``` Read `assessment.summary` first: ```json { "outcome": "information_required", "title": "Beneficiary information needed", "description": "Add the beneficiary legal name and beneficiary account identifier before this transfer can be reviewed again.", "transferStatus": "hold", "missingInformation": [ { "key": "beneficiary.legalName", "party": "beneficiary", "field": "legalName", "label": "Beneficiary legal name" } ], "checks": { "passed": 4, "needsReview": 0, "failed": 0, "pending": 0 }, "nextAction": { "type": "submit_information", "label": "Add missing information" } } ``` The rest of the response contains the exact policy version, required and received fields, issues, party references, VASP verification provenance and screening attempt references. Keep this evidence available for investigation, but use the summary for normal operational routing. ## Supply missing information ```bash curl -X POST "$MYAZA_API_BASE_URL/transactions/activity-1042/travel-rule/information" \ -H "Authorization: Bearer $MYAZA_SECRET_KEY" \ -H "Idempotency-Key: activity-1042-beneficiary-v1" \ -H "Content-Type: application/json" \ -d '{ "beneficiary": { "legalName": "Beneficiary name", "accountIdentifier": "beneficiary-account-reference" } }' ``` The response returns the updated assessment. An exact retry with the same idempotency key returns the same generation and does not create a second charge. Different information requires a new idempotency key and creates a new immutable validation generation. Completing the named fields resolves data completeness only. Always route the updated `assessment.summary`: identity, VASP or wallet evidence can still require review or block the transfer even when `fields.missing` is empty. ## Node.js correction flow After a webhook or an interrupted workflow, read the current result. Only submit the missing beneficiary when requested; do not create another transaction: ```js const current = await myaza.transactions.travelRule.retrieve(transaction.activityId); if (current.summary.nextAction.type === 'submit_information') { const result = await myaza.transactions.travelRule.submitInformation( transaction.activityId, { beneficiary: { legalName: 'Beneficiary name', accountIdentifier: 'beneficiary-account-reference' } }, { idempotencyKey: 'activity-1042-beneficiary-v1' }, ); console.log(result.assessment.summary); } ``` --- # Exchange and authorise ## Exchange information and authorise execution An assessment result of **Allow** is not permission to move funds. Keep your payment job on hold until its current requirements and the separate execution check pass. The execution path covers outgoing and incoming institutional transfers. [Self-hosted wallets](https://trust.myaza.co/documentation/travel-rule-self-hosted/markdown) use a separate signed-wallet path. Below-threshold authorisation requires an explicit qualified policy, not just a low amount. 1. Complete the information review and the independent country reviews for the exact active policy version. Review all source, destination and organisation countries. 2. Create or refresh clearance with `POST /transactions/{id}/travel-rule/transfer`. 3. Prepare the message with `POST /transactions/{id}/travel-rule/exchanges`, using the current `expectedRevision` and the receiving institution's `travelAddress`. Myaza selects the matching existing approved route automatically. `connectionId` remains supported and is required if multiple scoped routes match that origin. An ordinary wallet address cannot select a route. Use a UUID v4 `Idempotency-Key`. 4. Send once with `POST /transactions/{id}/travel-rule/exchanges/{messageId}/send`. Wait for an authenticated acceptance. An uncertain or conflicting reply stays held. 5. From your backend, request and consume an execution authorisation as shown below. Your original transaction must identify exactly one `recipient` crypto-wallet instrument, with its real `identifier` and `network`. An opaque wallet reference, masked address or customer-supplied verified flag is not sufficient. The assessed address and institution-approved address must both match `recipientAddress` exactly. The amount, asset and network come from the immutable assessed transaction. ### Request a short-lived authorisation ```http POST /api/v1/transactions/{id}/travel-rule/execution/authorisations Authorization: Bearer Idempotency-Key: Content-Type: application/json { "expectedRevision": 2, "recipientAddress": "" } ``` The response contains `authorisation.id`, `authorisation.token` and `authorisation.expiresAt`. The signed token expires within 60 seconds. Keep it on your backend and out of logs, analytics, URLs and browser storage. Do not treat a valid signature or successful issuance as permission to send funds. ### Consume once, immediately before execution Your organisation's wallet executes the transfer; Myaza supplies the compliance authorisation. Integrate this gate into your backend before the wallet sends funds. You do not need to give Myaza custody of your funds. Your system owns submission, duplicate prevention, transaction confirmation and reconciliation. A webhook or an assessment's Allow is not a payment instruction: check current authorisation. Persist a unique UUID `externalExecutionId` in your own durable payment job before this request. Use the same secret API key that requested the authorisation. ```http POST /api/v1/transactions/{id}/travel-rule/execution/consume Authorization: Bearer Content-Type: application/json { "expectedRevision": 2, "recipientAddress": "", "token": "", "externalExecutionId": "" } ``` Myaza checks the current policy, country reviews, fraud decision, recipient, exchange evidence, connection, network catalogue and retention approval again. A changed or expired prerequisite holds the transfer even when the token has not expired. - `action: "execute_once"`: execute that exact durable job once. Your payment or custody integration must enforce the same job identifier against duplicate sends. - `action: "reconcile_execution"`: this is a replay, not another permission. Look up the existing job and reconcile it; never create a new payment. - Any error, missing response or ambiguous result: keep the job held. Read `GET /transactions/{id}/travel-rule/execution` to reconcile the recorded receipt. This read remains available after token expiry; it does not grant new permission. A transfer can have only one consumption receipt, across every token and revision. The receipt's `settlementStatus: "UNKNOWN"` is intentional: authorisation is not evidence of a broadcast or settlement. Myaza does not move funds. If the execution result is lost or uncertain, resolve it with your wallet system before taking any further action. Do not automatically retry a funds-moving operation with a new ID. --- # Receive a transfer ## Receive information for an incoming transfer First record the incoming transfer through the transaction API with the exact customer, recipient wallet, network, asset, amount and available party details. Keep its current transfer evidence unexpired. Myaza matches one recorded intent; a saved external address or shared wallet alone cannot identify the receiving customer. Your organisation's deposit ledger remains authoritative. ### CodeVASP For an approved CodeVASP destination, Myaza manages the receiving endpoint. Do not generate a TRP receiving address. The gateway compares the sender's selected claims with the recorded incoming transfer and checks the current policy and reviews. It saves the encrypted response before replying; exact retries reuse that response. Rejected requests do not occupy the transfer's incoming-message slot. The transaction page shows received information and the recorded response separately. **Response recorded** does not prove that the sender received it. Use **Compare information** for an audited view; do not send a TRP approval for a CodeVASP inquiry. Wait for the authenticated sender confirmation and your own wallet's chain checks before the incoming authorisation step below. Unknown or ambiguous recipients remain held. ### Direct TRP Open the incoming crypto transaction and find **Receive required information**. Choose **Get receiving address**, select a verified messaging connection and give the address to the sending institution. It is an information address, not a wallet address. It is limited to that institution and the current transfer revision. When information arrives, the page shows **Information received** and your webhook receives `travel_rule.exchange.updated` with `change: inquiry_received`. The transfer stays on hold. Receiving a message does not confirm the information is complete, authorise a deposit, or prove that funds arrived. Choose **Compare information** to see the sender's supplied values alongside your current record. Only fields required by the policy are shown, and access is recorded in the audit trail. Differences, missing values and formats needing specialist review are explicit. A match does not verify an identity, update your records or approve the transfer. Read-only team members do not have access to this sensitive comparison. To decline, choose **Decline request**, then **Prepare decline**. Review the transfer and select **Send decline response** when ready. The local decision and delivery status are shown separately. If a response is lost, use **Check delivery** or refresh; do not start a new exchange to work around uncertainty. To approve matching information, choose **Prepare approval** in the comparison. The server checks the current decisions, country reviews, recorded recipient wallet and network again. You cannot override the wallet or enter a callback URL here. Then choose **Send approval response**. Preparation and delivery are separate, so an interrupted response can be retried using the same operation without another approval. For backend integrations: | Action | Endpoint under `/api/v1` | Body | |---|---|---| | Get receiving address | `POST /transactions/{id}/travel-rule/exchanges/invitations` | `{ "connectionId": "reviewed-sender", "expectedRevision": 1 }` | | Prepare decline | `POST /transactions/{id}/travel-rule/exchanges/{incomingMessageId}/rejection` | `{ "expectedRevision": 1 }` | | Prepare approval | `POST /transactions/{id}/travel-rule/exchanges/{incomingMessageId}/approval` | `{ "expectedRevision": 1 }` | | Compare selected information | `POST /transactions/{id}/travel-rule/exchanges/{incomingMessageId}/review` | `{ "expectedRevision": 1 }` | | Send prepared response | `POST /transactions/{id}/travel-rule/exchanges/{responseMessageId}/send` | `{}` | Use your secret API key. Receiving-address and approval/decline preparation require their own UUIDv4 `Idempotency-Key`; preserve the exact key and body across retries. Read the current transfer for the correct revision and message IDs. Only a configured, independently verified connection can submit a message to the native mutual-TLS receiver. No customer API accepts an arbitrary peer callback or raw approval. The approved sender can confirm its transaction through the authenticated receiving connection. `exchangeProgress.confirmation` distinguishes `CONFIRMED`, `CANCELLED` and `CONFLICT`, with `settlementStatus: "UNKNOWN"`. This is the sender's report, not proof that a deposit arrived. Use the separate incoming authorisation gate in this guide after your wallet independently verifies the deposit. Never credit or release funds from message status alone. ## Authorise an incoming deposit After the information review and approval, wait for the authenticated sender confirmation. Your wallet must independently verify its transaction identifier, amount, asset, network, recipient, finality and whether it was already credited. The sender's report alone is not chain evidence. Use the [issue and consume endpoints](https://trust.myaza.co/documentation/travel-rule-execution/markdown). Include `incomingTxid` in **both** requests, with the exact transaction identifier your wallet checked. It must match the retained sender confirmation for this transfer revision and approval. Keep the same `recipientAddress` and persist one UUID `externalExecutionId` for the credit job. ```json { "expectedRevision": 3, "recipientAddress": "", "incomingTxid": "" } ``` Consumption also includes `token` and `externalExecutionId`, as in the execution guide. An incoming receipt has `purpose: "INBOUND_CREDIT"`; outgoing receipts have `purpose: "OUTBOUND_TRANSFER"`. Only a fresh `execute_once` result can admit the bound job. Your wallet must enforce duplicate prevention for credits as well as sends. On a replay or missing response, reconcile the existing job instead of crediting again. Myaza does not credit balances, and settlement remains `UNKNOWN` in its receipt. Cancellation, conflicting sender reports, stale policy or country reviews, paused connections and changed recipient evidence block consumption. A message acknowledgement without a sender confirmation is insufficient. Do not send `incomingTxid` for an outgoing transfer or use the outbound confirmation endpoint for an incoming job. --- # Confirm an outgoing transfer ## Tell the receiving institution when your wallet confirms an outgoing transaction After your wallet confirms the **same authorised transaction**, report its identifier from your backend using the original secret API key. Do not send this for a merely broadcast transaction, an uncertain payment, or a different payment job. ```http POST /api/v1/transactions/{id}/travel-rule/execution/confirmation Authorization: Bearer Content-Type: application/json { "expectedRevision": 2, "externalExecutionId": "", "txid": "", "confirmedAt": "2026-09-06T12:00:00.000Z" } ``` Use the original consumed revision. `confirmedAt` must be at or after consumption and not in the future. `txid` is an asset-specific identifier (letters, digits, underscores or hyphens; at most 256 characters), not an explorer URL. Your system is responsible for checking that it identifies the exact authorised transaction. The response returns `confirmation.messageId`, `confirmedAt`, `source: "CUSTOMER"` and the message delivery `status`. Persist the exact request in your durable job: repeating it returns the same message; changing its transaction ID or timestamp conflicts. There is only one confirmation per consumed execution, regardless of HTTP retries. No extra execution is authorised. Send the prepared message through the existing endpoint: ```http POST /api/v1/transactions/{id}/travel-rule/exchanges/{confirmation.messageId}/send Authorization: Bearer Content-Type: application/json {} ``` Myaza uses the institution's authenticated callback from the approval that preceded execution. Your request cannot choose another URL, institution, amount or asset. The transaction identifier is encrypted in the exchange journal; audit entries and webhooks contain metadata only. Peer and retention checks still apply, even when the original clearance has expired. `ACKNOWLEDGED` means the institution received the confirmation message, not that Myaza independently verified settlement. `UNCERTAIN` means delivery needs reconciliation; sending again does not repeat the network POST. Subscribe to `travel_rule.exchange.updated` and read the execution or transfer endpoint for the latest status. The dashboard separately shows **Reported by your system** and **Transfer confirmation · Message received**. Settlement remains `UNKNOWN` until independent evidence is available. The operator must provision execution signing configuration, reviewed institution connections and country-specific requirements before enabling live use. Product templates and local tests do not qualify a country or prove live interoperability. --- # Receive Travel Rule updates ## Webhook events Subscribe to the Travel Rule events in **Developers → Webhooks**: | Event | Meaning | |---|---| | `travel_rule.required` | The active policy requires Travel Rule information. | | `travel_rule.data.pending` | Required information is still missing. | | `travel_rule.assessment.completed` | The current validation generation completed. | | `travel_rule.data.completed` | The required information package is complete. | | `travel_rule.mismatch.detected` | Identity, sanctions, VASP or wallet evidence needs action. | | `travel_rule.failed` | Validation failed because of a service problem. | | `travel_rule.exchange.updated` | Preparation, delivery or an authenticated institutional reply changed. Read current clearance; this event does not grant execution permission. | Every event includes the assessment ID, validation generation, status, field coverage, exact API paths and the same plain-language `summary`. Webhooks never contain the regulated party values submitted to Myaza. Use the shared [webhook authentication](https://trust.myaza.co/documentation/webhook-authentication/markdown) and [testing](https://trust.myaza.co/documentation/webhook-testing/markdown) guides. Verify the signature against raw bytes, reject stale signatures and deduplicate the event ID before applying an action. Delivery can be repeated or arrive after a newer revision. Fetch the current transfer before deciding what to do; a historical event never grants fresh wallet authority. --- # Self-hosted wallets ## Self-hosted wallets In the Travel Rule rule library, install **Unhosted wallet transfers** and turn on **Signed-wallet authorisation** in the draft. This applies to all amounts in that rule and requires an approved address plus a signed wallet proof from the last 24 hours. Publish the draft separately. The exact policy still needs the relevant country reviews before execution is available. Use the existing Address Book APIs to add the wallet, request its signed-message challenge, verify the signature and approve the address record. Both transfer parties must have current Myaza-verified customer records; submit their names and customer identifiers with the transaction. Supported Ethereum-compatible personal wallets use this path; contract wallets and other signature formats remain held. Add `selfHostedAddressId` to both [execution requests](https://trust.myaza.co/documentation/travel-rule-execution/markdown): ```json { "expectedRevision": 1, "recipientAddress": "0x1111111111111111111111111111111111111111", "selfHostedAddressId": "your-approved-address-record" } ``` For outgoing transfers, that record identifies the recipient wallet. For incoming transfers, it identifies the sender wallet; also include the same `incomingTxid` in both requests. Add the issued `token` and your stable `externalExecutionId` when consuming. Execute only on `execute_once`; reconcile the existing job on retries. Your wallet verifies the chain transaction and finality before crediting a deposit. No information exchange with an institution is fabricated for a self-hosted wallet. --- # Test and troubleshoot ## Test in Sandbox Use a Sandbox secret key and set `sandboxScenario` on a transaction request. Start with these scenarios: - **Travel Rule not required** for an explicit below-threshold result. - **Missing beneficiary information** to test the information collection loop. - **Travel Rule potential sanctions** to test a review outcome and specialist investigation. - **Travel Rule confirmed sanctions** to test a stopped transfer. After the request completes, open the transaction, inspect the summary, submit missing information when requested and verify the matching webhook deliveries. Scenario traffic is labelled and non-billable, but it still uses the governed Travel Rule assessment path. Travel Rule scenarios require a compatible published policy. Install the Sandbox Travel Rule preset under **Risk Intelligence → Rules & Policies → Fraud rules → Travel Rule**, then publish its draft before testing. For an API-driven deterministic test, set the top-level `sandboxScenario` to `TRAVEL_RULE_MISSING_BENEFICIARY`, only in this recovery test. The scenario is included in the request hash, follows the real policy and evidence path, and is non-billable. It is accepted only in Sandbox and development. Production returns `422 sandbox_scenario_not_available_in_production`; remove the field for live traffic. Ordinary Sandbox requests without a scenario use configured pricing and are billable. ## Acceptance checklist - Complete data: one transaction, no duplicate information submission. - Missing data: collect only the named fields and retain the original transaction. - Response loss: reuse the exact request and idempotency key; do not charge or execute twice. - Information accepted: still check current clearance and wallet authorisation. - Invalid or expired webhook signature: reject it without changing customer access. - Review or block: enforce the outcome in your application and make the reason visible to authorised staff. - Live activation: test an actual institution channel and your wallet enforcement separately from synthetic scenarios. --- # Webhook authentication Verify every delivery before parsing or acting on its JSON. Myaza signs the exact request bytes with the secret assigned to that endpoint. ## Signed headers | Header | Example | Required handling | |---|---|---| | `X-Myaza-Signature-V2` | `t=1786441200,v1=8df1…` | Parse the Unix timestamp and every `v1` candidate. Multiple `v1` values are present during secret rotation. | | `X-Myaza-Timestamp` | `1786441200` | Same signed timestamp, exposed separately for diagnostics. | | `X-Myaza-Replay-Window` | `300` | Endpoint replay window in seconds. Reject signatures outside it. | | `X-Myaza-Event` | `risk.signal.created` | Event type for routing. The signed JSON remains authoritative. | | `X-Myaza-Event-Id` | `evt_…` | Stable business-deduplication key. | | `X-Myaza-Delivery` | `del_…` | Endpoint delivery record ID. | | `X-Myaza-Correlation-Id` | `corr_…` | Optional end-to-end correlation value when the producer supplied one. | | `X-Myaza-Webhook-Version` | `v2` | Endpoint body contract. | | `X-Myaza-Signature` | `sha256=…` | Exact-body compatibility signature. New integrations should verify V2. | ## Verification algorithm 1. Read the raw HTTP request body as bytes. 2. Parse `t` and all `v1` values from `X-Myaza-Signature-V2`. 3. Reject a missing, invalid, too-old or implausibly future timestamp. 4. Compute lowercase hex `HMAC-SHA256(".", endpointSecret)`. 5. Compare the expected value with every supplied `v1` using a constant-time comparison. 6. Only after a match, parse the JSON, deduplicate on `id`, enqueue it durably and return `2xx`. Do not re-serialise parsed JSON before verification. Whitespace and property order change the signed bytes. ## Node.js example ```js import crypto from "node:crypto"; import express from "express"; const app = express(); app.post("/webhooks/myaza", express.raw({ type: "application/json" }), (req, res) => { const parts = String(req.header("X-Myaza-Signature-V2") || "").split(","); const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2); const candidates = parts .filter((part) => part.startsWith("v1=")) .map((part) => part.slice(3)); if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.status(401).send("Expired signature"); } const expected = crypto .createHmac("sha256", process.env.MYAZA_WEBHOOK_SECRET) .update(`${timestamp}.`) .update(req.body) .digest("hex"); const valid = candidates.some((candidate) => candidate.length === expected.length && crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)) ); if (!valid) return res.status(401).send("Invalid signature"); const event = JSON.parse(req.body.toString("utf8")); // Persist event.id before dispatching asynchronous business work. res.status(200).send("OK"); }); ``` ## Secret rotation During the configured grace period Myaza signs with the active secret and every unexpired grace secret. The V2 header therefore carries more than one `v1` value. Keep both endpoint secrets available and accept the request if either expected signature matches. Remove the old secret only after its dashboard grace period ends. ## Failure responses Return `401` for missing, stale or invalid signatures. Return `2xx` only after the event is durably accepted. Other non-`2xx` responses and timeouts are retried according to the [delivery schedule](https://trust.myaza.co/documentation/webhooks/markdown#delivery-semantics). --- # Verification webhooks Verification events describe one individual verification from processing to a terminal result. All five names use the same `data` schema, so a handler can upsert one record by `verificationId` and branch on `type` and `status`. ## Lifecycle | Event | When it fires | Recommended handling | |---|---|---| | `verification.started` | The verification enters processing. | Record the IDs and show a processing state. Do not treat it as a completed check. | | `verification.completed` | The verification completes successfully. | Persist the result and continue the approved customer flow. | | `verification.failed` | The check completes with a failed identity, data or biometric result. | Show the public `reason`; route according to your policy. | | `verification.not_found` | The submitted identity cannot be found. | Ask the customer to verify the identifier or use another supported method. | | `verification.error` | A verification service error stops processing. | Keep the customer retryable and use `reasonCode` for support diagnostics. | ## Complete successful payload ```json { "version": "2026-08-11", "id": "evt_4d6628b920d940d48ae7ac2e", "type": "verification.completed", "createdAt": "2026-08-15T12:14:37.150Z", "deliveryId": "del_01j9verification01", "data": { "verificationId": "ver_sample0000000000", "requestId": "req_sample_verified", "externalId": "your_user_123", "workflowId": "wf_sample0000000000", "workflowVersion": 3, "status": "VERIFIED", "reason": null, "reasonCode": null, "verificationMethod": "sandbox", "assuranceLevel": null, "idType": "bvn", "country": "NG", "subjectType": "individual", "idNumber": "00000000001", "business": null, "questionnaire": null, "proofOfAddress": null, "nfc": null, "emailVerification": null, "phoneVerification": null, "deviceIntel": null, "userData": { "firstName": "Amina", "lastName": "Okafor", "middleName": null, "dateOfBirth": "1992-04-18", "gender": "female" }, "facialMatch": true, "facialConfidence": 92, "dataMatch": true, "media": { "selfie": "/api/verifications/ver_sample0000000000/media/selfie", "livenessVideo": "/api/verifications/ver_sample0000000000/media/liveness-video" }, "environment": "SANDBOX", "createdAt": "2026-08-15T12:14:31.004Z" } } ``` Media values are absolute authenticated URLs in a live delivery. Fetch them with your API key. The illustrative relative paths above show the resource shape without exposing a deployment host. ## Event-specific examples The repeated identity result fields follow the complete schema above. These fixtures show the fields that distinguish the other four event types. ```json [ { "version": "2026-08-11", "id": "evt_started_01", "type": "verification.started", "createdAt": "2026-08-15T12:14:31.004Z", "deliveryId": "del_started_01", "data": { "verificationId": "ver_sample0000000000", "requestId": "req_sample_pending", "externalId": "your_user_123", "workflowId": "wf_sample0000000000", "workflowVersion": 3, "status": "PENDING", "reason": null, "reasonCode": null, "verificationMethod": "sandbox", "assuranceLevel": null, "idType": "bvn", "country": "NG", "subjectType": "individual", "idNumber": "00000000001", "environment": "SANDBOX", "createdAt": "2026-08-15T12:14:31.004Z" } }, { "version": "2026-08-11", "id": "evt_failed_01", "type": "verification.failed", "createdAt": "2026-08-15T12:15:03.421Z", "deliveryId": "del_failed_01", "data": { "verificationId": "ver_failed_01", "requestId": "req_sample_failed", "externalId": "your_user_124", "workflowId": "wf_sample0000000000", "workflowVersion": 3, "status": "FAILED", "reason": "The user's selfie does not match the photo on file with the government database (match confidence 38%, minimum 70% required).", "reasonCode": "selfie_mismatch", "verificationMethod": "sandbox", "assuranceLevel": null, "idType": "bvn", "country": "NG", "subjectType": "individual", "idNumber": "00000000002", "facialMatch": false, "facialConfidence": 38, "dataMatch": false, "environment": "SANDBOX", "createdAt": "2026-08-15T12:14:58.050Z" } }, { "version": "2026-08-11", "id": "evt_not_found_01", "type": "verification.not_found", "createdAt": "2026-08-15T12:16:18.120Z", "deliveryId": "del_not_found_01", "data": { "verificationId": "ver_not_found_01", "requestId": "req_sample_not_found", "externalId": "your_user_125", "workflowId": null, "workflowVersion": null, "status": "NOT_FOUND", "reason": "The BVN number was not found in the government database.", "reasonCode": "identity_not_found", "verificationMethod": "gov_db", "assuranceLevel": null, "idType": "bvn", "country": "NG", "subjectType": "individual", "idNumber": "00000000003", "environment": "SANDBOX", "createdAt": "2026-08-15T12:16:11.004Z" } }, { "version": "2026-08-11", "id": "evt_error_01", "type": "verification.error", "createdAt": "2026-08-15T12:17:40.902Z", "deliveryId": "del_error_01", "data": { "verificationId": "ver_error_01", "requestId": "req_sample_error", "externalId": "your_user_126", "workflowId": "wf_sample0000000000", "workflowVersion": 3, "status": "ERROR", "reason": "A temporary system error prevented this verification from being processed. No charge was applied.", "reasonCode": "system_error", "verificationMethod": "gov_db", "assuranceLevel": null, "idType": "bvn", "country": "NG", "subjectType": "individual", "idNumber": "00000000004", "environment": "SANDBOX", "createdAt": "2026-08-15T12:17:35.418Z" } } ] ``` ## Field reference | Field | Type | Nullable | Values and meaning | |---|---|---:|---| | `verificationId`, `requestId` | string | No | Stable verification and request identifiers. | | `externalId` | string | Yes | Your customer reference supplied with the request. | | `workflowId` | string | Yes | Published workflow that configured the run. | | `workflowVersion` | integer | Yes | Exact published version that ran. Pair it with `workflowId`. | | `status` | string | No | `PENDING`, `VERIFIED`, `FAILED`, `NOT_FOUND` or `ERROR`. | | `reason`, `reasonCode` | string | Yes | Populated for failure outcomes. Codes are stable for routing; copy is suitable for display. | | `verificationMethod` | string | Yes | `gov_db`, `document_ocr` or `sandbox`. Internal source names are deliberately hidden. | | `assuranceLevel` | string | Yes | For a verified result: `chip`, `gov_db` or `document`. | | `idType`, `country` | string | No | Submitted ID type and ISO 3166-1 alpha-2 country. | | `subjectType` | string | No | `individual` or `business`. Business fields are documented separately. | | `idNumber` | string | Yes | Submitted identifier where the method uses one. | | `business` | object | Yes | Customer-safe registry result for business checks. | | `questionnaire`, `proofOfAddress`, `nfc` | object | Yes | Workflow sub-results when those steps ran. | | `emailVerification`, `phoneVerification` | object | Yes | Contact possession sub-results when enabled. | | `deviceIntel` | object | Yes | Device, IP and soft-signal result when enabled. | | `userData` | object | Yes | Individual identity fields. Null for a business subject. | | `facialMatch`, `dataMatch` | boolean | Yes | Biometric and identity-data match results. | | `facialConfidence` | number | Yes | Facial match confidence from the completed check. | | `media` | object | Yes | Authenticated URLs for captured media. | | `environment` | string | No | Environment that produced the event. | | `createdAt` | ISO 8601 string | No | Verification record creation time. This differs from the envelope event time. | ## Related APIs and events Read the verification resource when you need the latest state or full nested sub-results. `workflow.run.completed`, `credits.deducted`, `entity.created` and `identity.resolved` can refer to the same customer journey, but they are separate logical events and can arrive in a different order. See [Authentication](https://trust.myaza.co/documentation/webhook-authentication/markdown) for signature verification and [Webhook Testing](https://trust.myaza.co/documentation/webhook-testing/markdown) for signed simulator delivery. --- # Business verification webhooks These events connect a completed KYC or KYB journey to the durable entity, identity and workflow records created for it. They can arrive independently of `verification.completed`, so deduplicate each event by envelope `id` and join records by the IDs in `data`. ## Events and handling | Event | When it fires | Recommended handling | |---|---|---| | `entity.created` | A successful Myaza verification creates an entity. | Store the entity ID against your customer. | | `entity.imported` | An externally verified entity is created through an import or Identity API call. | Store its provenance and follow the import job if `jobId` exists. | | `identity.resolved` | An entity is linked to a global identity. | Add the identity ID without replacing the entity ID. | | `entity.reverification_due` | The entity reaches its re-verification due date. | Start a new check before `dueAt`. | | `workflow.run.completed` | A workflow reaches a terminal customer outcome. | Persist the version, outcome and tags. | | `workflow.run.failed` | A workflow stops because of an engine fault. | Keep the journey retryable and inspect the run. | | `workflow.action` | A configured workflow webhook action executes. | Route the action by `nodeId`; do not infer a final outcome. | | `key_person.completed` | A director or beneficial-owner verification resolves. | Update that person inside the parent business review. | ## Complete entity example ```json { "version": "2026-08-11", "id": "evt_entity_created_01", "type": "entity.created", "createdAt": "2026-08-15T13:06:44.002Z", "deliveryId": "del_entity_created_01", "data": { "entityId": "ent_2405a1d091cf154b1ecfc9689e75f37b", "orgId": "org_01j9myaza", "environment": "SANDBOX", "externalUserId": "business_1048", "type": "business", "kycProvenance": "MYAZA_VERIFIED", "verificationId": "ver_business_1048", "createdAt": "2026-08-15T13:06:43.711Z" } } ``` ## Event-specific `data` contracts Insert each object below into the shared V2 envelope. Optional fields appear only when the producing operation has them. ```json [ { "type": "entity.imported", "data": { "entityId": "ent_imported_01", "identityId": "idn_01", "externalUserId": "customer_882", "environment": "SANDBOX", "jobId": "imp_01" } }, { "type": "identity.resolved", "data": { "entityId": "ent_imported_01", "identityId": "idn_01", "environment": "SANDBOX", "matchedExisting": true } }, { "type": "entity.reverification_due", "data": { "entityId": "ent_2405a1d091cf154b1ecfc9689e75f37b", "externalUserId": "business_1048", "environment": "PRODUCTION", "dueAt": "2026-09-15T00:00:00.000Z" } }, { "type": "workflow.run.completed", "data": { "runId": "wfr_01", "workflowId": "wf_kyb_01", "workflowVersion": 7, "verificationId": "ver_business_1048", "entityId": "ent_2405a1d091cf154b1ecfc9689e75f37b", "environment": "SANDBOX", "outcome": "APPROVE", "tags": ["registry-match", "screening-clear"], "completedAt": "2026-08-15T13:06:44.002Z" } }, { "type": "workflow.run.failed", "data": { "runId": "wfr_02", "workflowId": "wf_kyb_01", "workflowVersion": 7, "verificationId": "ver_business_1049", "environment": "SANDBOX", "error": "A workflow action could not be completed.", "failedAt": "2026-08-15T13:11:04.192Z" } }, { "type": "workflow.action", "data": { "runId": "wfr_01", "workflowId": "wf_kyb_01", "verificationId": "ver_business_1048", "entityId": "ent_2405a1d091cf154b1ecfc9689e75f37b", "nodeId": "node_notify_legal", "note": "Notify the compliance operations queue", "environment": "SANDBOX" } }, { "type": "key_person.completed", "data": { "keyPersonId": "kp_01", "verificationId": "ver_business_1048", "personVerificationId": "ver_person_01", "role": "DIRECTOR", "isApplicant": false, "status": "VERIFIED", "environment": "SANDBOX" } } ] ``` ## Field reference | Field | Type | Nullable | Meaning | |---|---|---:|---| | `entityId`, `identityId` | string | `identityId` can be null | Organisation-scoped entity and resolved global identity IDs. | | `externalUserId` | string | No when present | Your stable customer reference. | | `type` | string | No | `individual` or `business`. | | `kycProvenance` | string | No | Verification provenance, for example `MYAZA_VERIFIED`. | | `workflowId`, `runId`, `verificationId` | string | Event-dependent | IDs for the exact journey. | | `workflowVersion` | integer | Event-dependent | Published workflow version that ran. | | `outcome` | string | Event-dependent | Terminal workflow outcome such as `APPROVE`, `DECLINE` or `REVIEW`. | | `tags` | string[] | No on completion | Workflow-generated classification tags. | | `role` | string | No for key people | Business relationship, for example `DIRECTOR`. | | `status` | string | No for key people | Result status, for example `VERIFIED` or `FAILED`. | | `environment` | string | No | `SANDBOX` or `PRODUCTION`. | | `createdAt`, `completedAt`, `failedAt`, `dueAt` | ISO 8601 string | Event-dependent | Resource lifecycle time. | Business verification result fields such as registry records, directors and ownership remain on the verification resource. Behaviour and reauthentication events are documented under [Event Monitoring](https://trust.myaza.co/documentation/webhook-event-monitoring/markdown). ## Related APIs and events Use entity, verification and workflow-run read APIs for current state. Related events include `verification.completed`, `screening.completed`, `credits.deducted` and `risk.assessment.completed`. They are not guaranteed to arrive in that order. --- # Risk Intelligence webhooks Risk Intelligence turns checks and observed activity into explainable assessment, signal, level and decision events. `risk.signal.created` is the supported signal event. There is no `risk.signal.detected` or signal-updated webhook. ## Events | Event | When it fires | Recommended handling | |---|---|---| | `risk.assessment.completed` | An assessment finishes with a score, level and decision. | Upsert by `assessmentId`; use the decision for routing. | | `risk.assessment.failed` | The assessment engine cannot produce a result. | Keep the subject retryable and inspect `failureCode`. | | `risk.signal.created` | New durable risk evidence is recorded. | Append the immutable signal; do not overwrite earlier evidence. | | `risk.level.changed` | Aggregate risk moves between levels. | Re-evaluate access and monitoring policy. | | `risk.decision.changed` | The customer decision changes. | Apply the newest current decision after reading the resource if events arrive out of order. | ## Verification device and face findings When verification observes a device shared across identities or a legacy face finding without the opt-in workflow rule below, Myaza creates a review alert under **Risk Intelligence → Investigations → Review Queue**. No custom signal policy is required. Subscribe your organisation endpoint to `risk.signal.created` and `alert.created` in the same environment as your verification. These findings include `signalType` (`device.multi_entity` or `biometric.face_match`; legacy `biometric.face_duplicate` is also supported), `signalId`, `verificationId`, `alertId` and `subject.externalUserId`, your own customer reference. The signal recommends review; it does not block an account, merge identities or confirm fraud. Match images and other customers' identifiers are not included in the webhook. Verify the webhook signature, store its event ID once, and use `externalUserId` to route the right customer to your review process. Retryable delivery keeps the same event ID. A compliance officer can inspect and resolve the alert using the existing investigation workflow. More than one ordinary device is not, by itself, a fraud finding; activity rules assess new-device and shared-device behaviour. Face-reuse detection requires the deployed face model and eligible verification captures. No match result is fabricated when the model or capture is unavailable. The search runs after a successful verification, within the same organisation, environment and face model, excluding the same entity. Production and development captures are indexed by default. Sandbox stock personas are excluded unless the deployment explicitly enables sandbox indexing. A missing finding does not prove that an account is unique. ### Review one face across multiple accounts Open **Workflow builder → Presence Intelligence → Rules**, turn on **Review face reuse**, and set the number of **other accounts** that should trigger review. Publish the workflow when ready. A threshold of 2 means at least two other accounts: the current customer and repeated checks on one account do not count. This edits the same rules used by Decisioning. For a custom graph, use `verification.faceMatchedAccounts` (number) and `verification.faceSearchStatus` (`complete` or `unavailable`). The Presence control also routes an unavailable search to review. Similarity is evidence to investigate, not proof of fraud. With this rule enabled, review stays on the verification. **No separate Review Queue item is created.** A match sends `risk.signal.created` with `reviewDestination: "verification"` and `recommendedAction: "follow_workflow_decision"`, without an `alertId`. The final `workflow.run.completed` event includes the workflow outcome, `externalUserId`, and a summary such as `"faceReuse": { "status": "complete", "matchedAccountCount": 2 }`. An unavailable search has a null count, not zero. The deployed face engine and eligible captures must be available. Sandbox stock personas are not evidence of real duplicates; indexing remains deployment-controlled. Existing published workflows are unchanged until you enable and publish this rule. ### Apply the workflow decision in your platform 1. Send your stable `externalUserId` with each customer's verification. The raw verification result describes the identity check, not permission to keep using an account. A later face finding does not rewrite that result. 2. Subscribe to `risk.signal.created` for findings and `workflow.run.completed` for the final workflow outcome. Do not grant access based only on the earlier `verification.completed` event while Decisioning is still running. 3. For independent ongoing findings, a **signal-triggered policy** can match `biometric.face_match` (and the legacy alias where needed). Branch on `signal.type` and `signal.strength`, then use a review outcome or a step-up challenge. A similarity score alone is not proof of fraud and should not automatically merge customers or impose a permanent ban. 4. Subscribe to `workflow.run.completed` for your policy's outcome. Your backend applies your access policy using the customer reference. Review can mean a temporary hold; a confirmed decision can lead to a restriction. Myaza cannot disable a login, account or wallet hosted on your own platform. Workflow completion notifications have durable intent saved with the completed run and retry with a stable event ID. Notification replay does not reapply an old disposition. Read the current customer/decision state before relaxing restrictions, especially when events arrive late or out of order. A delivered outcome describes that run, not proof that every later customer-state change has been applied. Screening uses its own `screening.match` and `screening.completed` events; see [Screening webhooks](/docs/webhook-screening). ## Complete assessment example ```json { "version": "2026-08-11", "id": "evt_risk_assessment_01", "type": "risk.assessment.completed", "createdAt": "2026-08-15T10:42:13.914Z", "deliveryId": "del_risk_assessment_01", "data": { "riskAssessmentId": "ras_01j9risk001", "assessmentId": "ras_01j9risk001", "entityId": "ent_2405a1d091cf154b1ecfc9689e75f37b", "subjectType": "INDIVIDUAL", "score": 72, "level": "HIGH", "decision": "REVIEW", "checks": ["SANCTIONS", "PEP"], "signals": ["SANCTIONS_MATCH"] } } ``` ## Event-specific `data` contracts ```json [ { "type": "risk.assessment.failed", "data": { "riskAssessmentId": "ras_02", "assessmentId": "ras_02", "entityId": "ent_02", "subjectType": "BUSINESS", "failureCode": "provider_unavailable" } }, { "type": "risk.signal.created", "data": { "entityId": "ent_02", "riskSignalId": "sig_01", "signalId": "sig_01", "assessmentId": "ras_02", "subjectType": "BUSINESS", "signalType": "SANCTIONS_MATCH", "strength": 0.91, "detectedAt": "2026-08-15T11:02:00.000Z" } }, { "type": "risk.level.changed", "data": { "entityId": "ent_02", "subscriptionId": "msub_01", "previousLevel": "MEDIUM", "currentLevel": "HIGH" } }, { "type": "risk.decision.changed", "data": { "entityId": "ent_02", "subscriptionId": "msub_01", "previousDecision": "REVIEW", "currentDecision": "BLOCK" } } ] ``` ## Field reference | Field | Type | Nullable | Values and meaning | |---|---|---:|---| | `riskAssessmentId`, `assessmentId`, `entityId`, `riskSignalId`, `signalId`, `subscriptionId` | string | Event-dependent | Stable customer-addressable resource IDs. `riskAssessmentId`/`assessmentId` and `riskSignalId`/`signalId` intentionally identify the same resources. | | `subjectType` | string | No on assessment-originated events | `INDIVIDUAL` or `BUSINESS`. | | `score` | number | Absent on failure | Normalised risk score. | | `level`, `previousLevel`, `currentLevel` | string | Event-dependent | Current transition values such as `LOW`, `MEDIUM`, `HIGH` or `CRITICAL`. | | `decision`, `previousDecision`, `currentDecision` | string | Event-dependent | `ALLOW`, `REVIEW` or `BLOCK`. | | `checks`, `signals` | string[] | No on completed assessments | Checks completed and signal types produced by the assessment. | | `failureCode` | string | No on failure | Stable diagnostic code. | | `signalType` | string | No | Evidence class such as `NEW_DEVICE`, `IP_RISK`, `SANCTIONS_MATCH`, `BEHAVIOURAL_ANOMALY`, `COUNTERPARTY_RISK` or `WALLET_RISK`. | | `strength` | number | Producer-dependent | Confidence from `0` to `1` for assessment-originated signals. | | `severity`, `confidence`, `activityId`, `decisionId` | string, number, string, string | Producer-dependent | Present on behaviour or transaction-originated signals instead of `strength` and `assessmentId`. Severity is `LOW`, `MEDIUM`, `HIGH` or `CRITICAL`; confidence is `0` to `1`. | | `detectedAt` | ISO 8601 string | No | Time the evidence was observed. | ## Semantics and related resources Assessments are snapshots; signals are append-only evidence; level and decision events are material transitions. Ordering is not guaranteed, so read the entity risk resource before irreversible action. Screening, monitoring, fraud and alert events can refer to the same assessment without sharing a delivery order. --- # Activity decision webhooks Fraud rules can assess transactions and non-transaction activity through the same outcome contract. ## Events | Event | Use it for | | --- | --- | | `fraud.activity.assessed` | Apply the first decision for a non-transaction activity. | | `fraud.decision.changed` | Replace the previous decision with the new effective decision. | | `entity.step_up_requested` | Send or display the hosted reauthentication challenge. | | `entity.step_up_completed` | Continue only when `passed` is true. | | `entity.biometric_enrolled` | Record that biometric reauthentication is available. | | `entity.biometric_authenticated` | Apply the resolved biometric result. | ## Assessment example ```json { "id": "evt_activity_01", "event": "fraud.activity.assessed", "createdAt": "2026-08-15T14:20:09.000Z", "data": { "externalActivityId": "login_928401", "activityId": "act_01", "decisionId": "dec_01", "decision": "review", "summary": { "outcome": "review", "title": "Review required", "reason": "A new device and unusual location were detected.", "score": 78, "riskLevel": "high", "matchedRules": 2, "nextAction": { "type": "review_activity", "label": "Review activity" } } } } ``` Use the summary for the immediate answer. Detailed rule and screening evidence remains available in the activity response for audit and investigation. ## Step-up example ```json { "event": "entity.step_up_requested", "data": { "stepUpId": "stu_01", "externalUserId": "customer_1", "url": "https://trust.myaza.app/reauth/stu_01", "expiresAt": "2026-08-15T14:35:09.000Z", "environment": "SANDBOX" } } ``` Treat the hosted challenge URL as sensitive. Deduplicate every webhook by its envelope `id` and use the related read API whenever current state matters. --- # Transaction decision webhooks Myaza sends one concise summary when a transaction is assessed and again if its effective decision changes. ## Events | Event | Use it for | | --- | --- | | `fraud.transaction.assessed` | Apply the first transaction decision. | | `fraud.decision.changed` | Replace the previous decision with the new effective decision. | | `event.flagged` | Legacy compatibility for non-allow decisions. New integrations should use the events above. | ## Example ```json { "id": "evt_transaction_01", "event": "fraud.transaction.assessed", "createdAt": "2026-08-15T15:02:12.884Z", "data": { "externalTransactionId": "txn_928401", "activityId": "act_tx_01", "decisionId": "dec_tx_01", "decision": "review", "summary": { "outcome": "review", "title": "Review required", "reason": "A high-risk transaction rule matched.", "score": 82, "riskLevel": "high", "matchedRules": 1, "nextAction": { "type": "review_activity", "label": "Review transaction" } } } } ``` Use `data.summary` for normal integration logic: 1. Deduplicate by the envelope `id`. 2. Branch on `summary.outcome`. 3. Display `summary.reason` when a person needs context. 4. Follow `summary.nextAction` when it is not `none`. The same payload can include detailed rule, screening, billing and Travel Rule evidence. Store that evidence for audit or open the transaction read API when you need it, but do not recompute Myaza's decision from those details. Delivery order is not guaranteed. A later `fraud.decision.changed` event is authoritative for the effective decision. --- # Screening webhooks Screening event names are list-agnostic. Inspect `data.type` and the linked screening resource to distinguish sanctions, PEP, adverse media and wallet screening. Internal source records and raw watchlist documents are not included in webhook payloads. ## Events | Event | When it fires | Recommended handling | |---|---|---| | `screening.completed` | A screening run completes. | Persist status and match count. | | `screening.match` | A non-clear match needs review. | Open or update review using the screening ID and alert ID. | | `screening.insufficient_data` | The customer profile cannot support the check. | Collect the missing customer information before retrying. | | `screening.failed` | A screening run cannot complete. | Inspect the public reason code and `retryScheduled`. | | `screening.stale` | The profile changed while the check ran. | Wait for or trigger the fresh run. | | `screening.adjudicated` | An analyst confirms or clears a match. | Apply the recorded outcome to your review workflow. | | `screening.adjudication_overdue` | A match passes its review deadline. | Escalate to the assigned compliance queue. | ## Complete match example ```json { "version": "2026-08-11", "id": "evt_screening_match_01", "type": "screening.match", "createdAt": "2026-08-15T16:20:00.000Z", "deliveryId": "del_screening_match_01", "data": { "screeningId": "scr_01", "entityId": "ent_01", "subject": { "entityId": "ent_01", "externalUserId": "customer-123", "identityId": "idn_01", "type": "individual" }, "type": "SANCTIONS", "status": "POTENTIAL_MATCH", "assurance": "STANDARD", "reasonCode": "name_plus_one", "matchCount": 2, "alertId": "alt_01", "environment": "SANDBOX" } } ``` ## Field reference | Field | Type | Nullable | Values and meaning | |---|---|---:|---| | `screeningId`, `entityId` | string | No | Screening and subject Entity IDs. | | `subject` | object | Yes | Customer-safe Entity correlation, including your `externalUserId` when available. | | `type` | string | No | `SANCTIONS`, `PEP`, `ADVERSE_MEDIA` or `WALLET`. | | `status` | string | No | Current screening state such as `CLEAR`, `POTENTIAL_MATCH`, `INSUFFICIENT_DATA`, `FAILED` or `STALE`. | | `assurance`, `reasonCode` | string | Yes | Customer-safe quality level and explanation. | | `matchCount` | integer | Yes | Matches in this result. | | `alertId` | string | Yes | Review alert opened or reused for a non-clear result. | | `outcome` | string | Yes | Analyst outcome on `screening.adjudicated`. | | `retryScheduled` | boolean | Yes | Whether a failed or stale check will retry. | | `environment` | string | Yes | Environment that produced the result. | Retrieve `GET /api/v1/monitoring/screenings/{screeningId}` for current match records, attempts and the customer-safe adjudication history. In the dashboard, screening evidence is shown in the customer, risk decision, transaction, or investigation that gives it context. Internal source identity, attempt IDs, profile digests, billing flags, datasets and raw responses are intentionally absent. --- # Continuous Monitoring webhooks For a minimal integration, subscribe to `monitoring.delta.detected`. Add `monitoring.run.failed` when your operations team must react to an incomplete check. Do not subscribe to high-volume `monitoring.run.completed` unless your system needs every successful run. ## Events | Event | When it fires | | --- | --- | | `monitoring.subscription.started` | Monitoring becomes active for an Entity. | | `monitoring.subscription.paused` | Scheduled monitoring is paused. | | `monitoring.subscription.resumed` | A paused subscription becomes active. | | `monitoring.subscription.ended` | A subscription is permanently turned off. | | `monitoring.subscription.updated` | An Entity's policy or frequency changes. | | `monitoring.run.completed` | One monitoring run completed. | | `monitoring.run.failed` | One monitoring run could not complete. | | `monitoring.delta.detected` | Monitoring found one or more material changes. | `monitoring.failed` is not a public event name. ## Exact V2 delta envelope ```json { "version": "2026-08-11", "id": "evt_monitoring_delta_01", "type": "monitoring.delta.detected", "createdAt": "2026-08-26T17:00:00.000Z", "deliveryId": "del_monitoring_delta_01", "data": { "subscriptionId": "sub_01", "entityId": "ent_01", "externalUserId": "customer_42", "subject": { "entityId": "ent_01", "externalUserId": "customer_42", "identityId": "identity_01", "type": "individual" }, "detectedAt": "2026-08-26T17:00:00.000Z", "deltas": [ { "type": "SANCTIONS_MATCH", "direction": "ADDED", "severity": "HIGH" } ] } } ``` This is the complete V2 envelope. Product fields live directly inside `data`. ## Run failure example ```json { "version": "2026-08-11", "id": "evt_monitoring_failed_01", "type": "monitoring.run.failed", "createdAt": "2026-08-26T17:04:00.000Z", "deliveryId": "del_monitoring_failed_01", "data": { "subscriptionId": "sub_01", "entityId": "ent_01", "externalUserId": "customer_42", "failureCode": "provider_unavailable", "failedAt": "2026-08-26T17:04:00.000Z" } } ``` ## Public data contract | Field | Events | Meaning | | --- | --- | --- | | `subscriptionId` | All | Customer-addressable monitoring subscription. | | `entityId`, `externalUserId`, `subject` | Entity-related events | Customer correlation without internal source data. | | `status` | Subscription lifecycle | `active`, `paused` or `ended`. | | `frequency` | Subscription updated | Effective frequency, policy default, Entity override and source. | | `materialChange`, `completedAt` | Run completed | Whether the successful run found a material change and when it completed. | | `failureCode`, `failedAt` | Run failed | Stable failure classification and time. | | `deltas`, `detectedAt` | Delta detected | Customer-visible material changes and detection time. | Internal `policyVersionId`, `monitoringRunId` and `assessmentId` fields are not included. Read current state and history from `GET /api/v1/monitoring/subscriptions/{subscriptionId}`. Verify the raw request body, deduplicate by envelope `id`, return `2xx` quickly, and use the read API whenever current state matters. --- # Alerts & Investigations webhooks Alerts group risk occurrences that need attention. Investigations are the owned human-review records opened from one or more alerts. The product and API use the name Investigation. Webhook event names retain the established `case.*` prefix so existing receivers do not break. ## Alert events | Event | When it fires | Recommended handling | |---|---|---| | `alert.created` | A new actionable alert opens. | Create a linked review item. | | `alert.updated` | Decision, score, occurrence count or status changes. | Update the existing item by `alertId`. | ```json { "version": "2026-08-11", "id": "evt_alert_01", "type": "alert.created", "createdAt": "2026-08-15T17:32:00.000Z", "deliveryId": "del_alert_01", "data": { "alertId": "alt_01", "entityId": "ent_01", "status": "OPEN", "decision": "REVIEW", "riskScore": 86, "occurrences": 1, "resolution": null } } ``` ```json [ { "type": "alert.updated", "data": { "alertId": "alt_01", "entityId": "ent_01", "status": "IN_REVIEW", "decision": "REVIEW", "riskScore": 89, "occurrences": 3, "resolution": null } }, { "type": "alert.updated", "data": { "alertId": "alt_01", "entityId": "ent_01", "status": "RESOLVED", "decision": "ALLOW", "riskScore": 22, "occurrences": 3, "resolution": "FALSE_POSITIVE" } } ] ``` ## Investigation events | Event | When it fires | |---|---| | `case.created` | An investigation opens from related evidence. | | `case.assigned` | An owner is assigned. | | `case.review_started` | Human review begins. | | `case.escalated` | Priority or review tier is escalated. | | `case.resolved` | An analyst records a disposition. | | `case.closed` | Operational work closes. | | `case.reopened` | A closed investigation returns to review. | | `case.sla_at_risk` | The due time is approaching. | | `case.overdue` | The due time has passed. | Every event uses the standard webhook envelope. Route it by `data.caseId`, then retrieve `/api/v1/investigations/{caseId}` when your workflow needs the complete current Investigation. `caseId` is retained inside webhook payloads as a stable compatibility field. ```json { "version": "2026-08-11", "id": "evt_investigation_01", "type": "case.assigned", "createdAt": "2026-08-15T17:35:00.000Z", "deliveryId": "del_investigation_01", "data": { "id": "evt_case_assigned_inv_01_current", "caseId": "inv_01", "entityId": "ent_01", "subject": { "type": "entity", "id": "ent_01" }, "assigneeId": "usr_reviewer_01", "occurredAt": "2026-08-15T17:35:00.000Z" } } ``` ## Field reference | Field | Type | Nullable | Meaning | |---|---|---:|---| | `alertId`, `caseId`, `entityId` | string | No where present | Stable alert, investigation and subject IDs. | | `status` | string | No | Current lifecycle status shown in the examples. | | `decision` | string | Yes | Alert decision such as `ALLOW`, `REVIEW` or `BLOCK`. | | `riskScore` | number | Yes | Alert risk score. | | `occurrences`, `alertCount` | integer | No | Grouped evidence or linked alert counts. | | `resolution`, `disposition` | string | Yes | Recorded alert resolution or investigation outcome. | | `priority` | string | No | For example `LOW`, `MEDIUM`, `HIGH` or `CRITICAL`. | | `assigneeId` | string | Yes | Assigned user ID. | | `dueAt` | ISO 8601 string | Yes | SLA deadline. | Private notes, attachments, analyst evidence and SAR filing state are not included. There are no customer-facing `sar.*` events in the current catalogue. --- # Wallet Intelligence webhooks Wallet Intelligence uses the shared Screening and Risk Intelligence contracts. There is no separate public `wallet.*` webhook namespace today. ## Events to subscribe to - `screening.completed`, `screening.insufficient_data`, `screening.failed` and `screening.stale` for a wallet screening run. - `screening.match` when a wallet match needs review and `screening.adjudicated` when an analyst records the outcome. - `risk.signal.created` for durable wallet, counterparty or network evidence included by a risk assessment. - `risk.level.changed`, `risk.decision.changed` or `alert.created` when that evidence changes the customer-visible outcome. Inspect `data.type` on screening events and `data.signalType` on risk-signal events rather than inventing event names such as `wallet.screening.match` or `wallet.risk.detected`. ## Example wallet match ```json { "version": "2026-08-11", "id": "evt_d87ad93ae9f9495d92e0c043231fc303", "type": "screening.match", "createdAt": "2026-08-15T11:24:09.120Z", "deliveryId": "del_01j9wallet01", "data": { "screeningId": "scr_01j9wallet01", "entityId": "ent_01j9customer01", "identityId": "idt_01j9identity01", "type": "WALLET", "status": "POTENTIAL_MATCH", "assurance": "STANDARD", "reasonCode": "wallet_match", "matchCount": 1, "alertId": "alt_01j9wallet01" } } ``` The webhook does not expose internal source identity, raw blockchain attribution, counterparties or a recommended action. Retrieve the screening and related alert when your workflow needs current review detail. ## Handling Deduplicate on `id`. Upsert the screening by `screeningId`; do not treat repeated deliveries as new matches. An adjudicated or clear result is a lifecycle transition, not permission to erase prior audit evidence. See [Screening](https://trust.myaza.co/documentation/webhook-screening/markdown) and [Risk Intelligence](https://trust.myaza.co/documentation/webhook-risk-intelligence/markdown) for field definitions and related events. --- # Billing webhooks Billing events describe posted credit changes. API-key events provide a security audit signal but never expose the secret key. ## Events and payloads | Event | When it fires | Recommended handling | |---|---|---| | `credits.deducted` | Billable verification usage is posted. | Reconcile by `verificationId`. | | `credits.low` | Balance crosses the configured threshold. | Notify billing owners once per logical event ID. | | `credits.topped_up` | A top-up posts successfully. | Reconcile the new balance. | | `credits.granted` | Promotional credit is granted. | Record the grant and expiry policy. | | `credits.promo_expired` | Unused promotional credit expires. | Reconcile the reduction without treating it as usage. | | `api_key.created` | A key is created. | Audit the name, environment, type and prefix. | | `api_key.revoked` | A key is revoked. | Remove dependent access and investigate unexpected revocation. | ```json { "version": "2026-08-11", "id": "evt_credit_deducted_01", "type": "credits.deducted", "createdAt": "2026-08-15T18:00:00.000Z", "deliveryId": "del_credit_deducted_01", "data": { "verificationId": "ver_01", "amount": "0.2000", "currency": "USD" } } ``` ```json [ { "type": "credits.low", "data": { "orgId": "org_01", "balance": "20.0000", "threshold": "25.0000", "currency": "USD" } }, { "type": "credits.topped_up", "data": { "orgId": "org_01", "amount": "500.0000", "balanceAfter": "1020.0000", "currency": "USD" } }, { "type": "credits.granted", "data": { "orgId": "org_01", "amount": "50.0000", "balanceAfter": "1070.0000", "currency": "USD", "grantId": "grt_01" } }, { "type": "credits.promo_expired", "data": { "orgId": "org_01", "amount": "10.0000", "balanceAfter": "1060.0000", "currency": "USD", "grantId": "grt_01" } }, { "type": "api_key.created", "data": { "apiKeyId": "key_01", "name": "Production server", "environment": "PRODUCTION", "keyType": "SECRET", "keyPrefix": "sk_live_8p4" } }, { "type": "api_key.revoked", "data": { "apiKeyId": "key_01", "name": "Production server", "environment": "PRODUCTION" } } ] ``` ## Field reference | Field | Type | Nullable | Meaning | |---|---|---:|---| | `orgId`, `verificationId`, `grantId`, `apiKeyId` | string | Event-dependent | Related organisation, usage, grant and key IDs. | | `amount`, `balance`, `balanceAfter`, `threshold` | string | Event-dependent | Fixed-scale decimal currency amounts with four fractional digits. Parse with a decimal library; do not use binary floating-point for ledger arithmetic. | | `currency` | string | No for credit events | ISO 4217 currency code such as `USD`. | | `name` | string | No for key events | Customer-visible key name. | | `environment` | string | No for key events | `SANDBOX` or `PRODUCTION`. | | `keyType` | string | No on creation | Public key classification, for example `SECRET` or `PUBLISHABLE`. | | `keyPrefix` | string | No on creation | Non-secret prefix for identifying the key. The full secret is never delivered. | Use billing ledger and API-key read endpoints for authoritative current state. Financial side effects must be idempotent by envelope `id`, while reconciliation should also use the referenced verification, grant or top-up record. --- # Webhook testing Test with a sandbox endpoint before creating a production subscription. The simulator builds the same envelope, signs the same raw bytes and creates a real delivery record, so your receiver exercises production verification and retry behaviour without running an entire product flow. ## Recommended test sequence 1. Create a sandbox endpoint and copy its `whsec_…` secret into your receiver's secret store. 2. Choose one event from each family your integration handles. 3. Send a simulated delivery from the endpoint's **Testing** action. 4. Confirm your receiver verifies `X-Myaza-Signature-V2`, persists `id`, and returns `2xx` quickly. 5. Inspect the endpoint delivery history and correlate it with `deliveryId` or `X-Myaza-Delivery`. 6. Resend the delivery and confirm your business side effect happens only once. 7. Temporarily return a non-`2xx` response and confirm a retry is scheduled. 8. Rotate the secret and confirm both V2 `v1` signatures are accepted during the grace period. 9. If migrating from an alias, subscribe to the canonical name and alias together and confirm the shared event `id` prevents duplicate work. ## Catalogue-driven fixtures `GET /api/v1/webhooks/catalogue` returns every supported event and its generated sample. Generate typed fixtures from this response rather than copying an invented schema into a test suite. ```bash curl -H "Authorization: Bearer sk_test_…" \ https://trust.myaza.app/api/v1/webhooks/catalogue ``` Samples show realistic public field shapes, but sample identifiers are not reusable API resources. Runtime events can contain additional optional fields documented on each event-family page. ## Idempotency assertion Use the envelope `id`, not `deliveryId`, as the unique business key: ```sql insert into received_myaza_events (event_id, event_type, payload) values (:id, :type, :payload) on conflict (event_id) do nothing; ``` A manual resend creates another delivery record but preserves the logical event ID. A compatibility alias also shares the canonical event's ID. ## Ordering assertion Do not write a test that assumes a cross-type sequence such as `risk.signal.created` always arriving before `alert.created`. Delay or reverse two accepted events and confirm your handler either applies them by resource version/current API state or processes them independently. ## Production readiness checklist - HTTPS endpoint uses a valid public certificate and does not redirect. - Raw-body signature verification and replay rejection are covered by tests. - Event IDs have a durable unique constraint. - Slow processing runs outside the request. - Unknown event types are safely stored or ignored and still acknowledged. - Alerts exist for repeated delivery failures and dead-lettered deliveries. - Secrets are environment-scoped, stored outside source code and rotation-tested.