# 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.
- [Status and reliability](https://trust.myaza.co/documentation/status-and-reliability/markdown): Where to check live service status, and how to get Myaza Trust's measured availability for your security review.
- [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, expire & cancel](https://trust.myaza.co/documentation/api-manage-records/markdown): DELETE /verifications/:id, DELETE /checks/*/:id, DELETE /face/checks/:id, POST /sessions/:id/expire and POST /sessions/:id/cancel: remove a record, end a live session early, or stop a session or a processing verification and take that back.
- [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.
- [API explorer](https://trust.myaza.co/documentation/api-explorer/markdown): Search public v1 endpoints and inspect request and response fields.
- [On-Demand Wallet Screening API](https://trust.myaza.co/documentation/api-wallet-screenings/markdown): Screen a wallet address with a secret API key and retrieve its risk result.
- [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.
- [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): A store-review kit for apps that turn on background presence verification: how to frame it for your product, the Play Console answers, the in-app disclosure, a 30-second video script, Apple review notes and what to do if you are rejected.
- [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.
- [Lists](https://trust.myaza.co/documentation/lists/markdown): Blocklists, the face allowlist, the High-risk countries list, and the custom lists you create for your workflow rules.
- [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.
- [User Verification](https://trust.myaza.co/documentation/user-verification/markdown): Launch your first individual verification with a hosted link or an 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.
- [Business verification quickstart](https://trust.myaza.co/documentation/business-verification-quickstart/markdown): Create a KYB workflow, send a company verification link, and receive its result.
- [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.
- [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): Submit your first Sandbox transaction and read its risk decision.
- [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.
- [Customer screening](https://trust.myaza.co/documentation/screening/markdown): Sanctions, PEP, and adverse-media watchlist screening for your entities.
- [Crypto transfers and deposits](https://trust.myaza.co/documentation/crypto-operations/markdown): Review crypto transfers, match deposit observations and maintain your address book.
- [Transaction monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown): Send complete transaction relationships and customer activity for real-time screening and risk decisions.
- [Fraud decision settings](https://trust.myaza.co/documentation/monitoring-rules/markdown): Configure how events are scored, thresholds, money limits, built-in rules, and custom rules.
- [Import past transactions](https://trust.myaza.co/documentation/import-past-transactions/markdown): Preview a historical batch, then submit it through Transaction Monitoring.
- [Ongoing customer monitoring](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.
- [On-Demand Wallet Screening](https://trust.myaza.co/documentation/wallet-screening/markdown): Check one wallet address, understand its exposure, and act on your rules.
- [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): Handle wallet and crypto transaction screening 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.
## Blog
Articles from the Myaza Trust team: https://trust.myaza.co/blog (RSS: https://trust.myaza.co/blog/rss.xml)
- [How to manage workspace access and costs with Myaza Trust](https://trust.myaza.co/blog/how-to-workspace-operations): Manage team access, environments and cost controls deliberately. Give each teammate the permissions needed for their job and keep Sandbox preparation separate from Production activity. (2026-10-04, by Charles Archibong)
- [How to test and troubleshoot an integration with Myaza Trust](https://trust.myaza.co/blog/how-to-integration-testing): Test the complete integration, including failures and recovery. Choose the supported fixture for the API family you use, then inspect both Myaza’s record and your application’s behaviour. (2026-10-04, by Charles Archibong)
- [How to receive results reliably with Myaza Trust](https://trust.myaza.co/blog/how-to-result-delivery): Receive Myaza results reliably with a signed webhook receiver. Verify the exact bytes, store each event durably and recover retries without repeating the customer action. (2026-10-04, by Charles Archibong)
- [How to connect your customer records with Myaza Trust](https://trust.myaza.co/blog/how-to-customer-book): Connect the customers already in your application to Myaza records using stable references. Retain what you know about their verification source rather than upgrading imported claims into verified facts. (2026-10-04, by Charles Archibong)
- [How to add verification to your app with Myaza Trust](https://trust.myaza.co/blog/how-to-capture-integration): Add verification to your app using a published workflow and a supported SDK or hosted session. Keep capture in the customer journey and final result handling on your backend. (2026-10-04, by Charles Archibong)
- [How to verify a business with Myaza Trust](https://trust.myaza.co/blog/how-to-verify-business): Verify a business against a supported registry and choose the additional evidence your onboarding policy needs. Company existence and authority to represent it are different questions. (2026-10-04, by Charles Archibong)
- [How to check and monitor address presence with Myaza Trust](https://trust.myaza.co/blog/how-to-address-presence): Verify address presence over a supported observation window. Configure capture, native reporting and customer consent together, then distinguish verified, failed and inconclusive evidence. (2026-10-04, by Charles Archibong)
- [How to review existing customer addresses with Myaza Trust](https://trust.myaza.co/blog/how-to-address-triage): Review the address evidence already in your customer book. Upload a permitted CSV, inspect the quote and use the resulting grades to choose the right next capture or presence check. (2026-10-04, by Charles Archibong)
- [How to manage crypto destinations and observations with Myaza Trust](https://trust.myaza.co/blog/how-to-crypto-operations): Keep crypto destination records and deposit observations organised. A deposit observation, customer match, screening decision and settled funds are separate facts. (2026-10-04, by Charles Archibong)
- [How to set up Travel Rule exchange with Myaza Trust](https://trust.myaza.co/blog/how-to-travel-rule): Set up your organisation’s Travel Rule information-exchange journey, test it and follow the displayed activation requirements. Every transfer still needs its own current authorisation before your wallet acts. (2026-10-04, by Charles Archibong)
- [How to screen a crypto wallet with Myaza Trust](https://trust.myaza.co/blog/how-to-wallet-screening): Screen a saved crypto wallet and inspect the decision with its exposure evidence. Keep blockchain risk, address approval, signed control and transfer clearance separate. (2026-10-04, by Charles Archibong)
- [How to measure decision quality with Myaza Trust](https://trust.myaza.co/blog/how-to-decision-quality): Measure decision quality using confirmed downstream outcomes. Review fraud capture and customer friction without silently changing the live policy. (2026-10-04, by Charles Archibong)
- [How to review and investigate findings with Myaza Trust](https://trust.myaza.co/blog/how-to-investigate-findings): Turn a risk alert into owned casework. Review the reason, gather evidence and record a clear outcome that the next team member can understand. (2026-10-04, by Charles Archibong)
- [How to understand customer risk with Myaza Trust](https://trust.myaza.co/blog/how-to-customer-risk): Read customer risk with its evidence and assessment basis. Customer risk helps explain a customer’s recorded state; it is different from the risk decision for a specific payment. (2026-10-04, by Charles Archibong)
- [How to assess transaction risk with Myaza Trust](https://trust.myaza.co/blog/how-to-assess-transactions): Assess transaction risk before your application moves money. Submit an exact amount, the right direction and the parties involved, then use the decision and its explanation. (2026-10-04, by Charles Archibong)
- [How to assess account activity with Myaza Trust](https://trust.myaza.co/blog/how-to-assess-activity): Assess logins, account recovery and other non-payment activity. Send the actual customer and device context, read the returned decision and enforce it in your own app. (2026-10-04, by Charles Archibong)
- [How to keep customer screening up to date with Myaza Trust](https://trust.myaza.co/blog/how-to-monitor-customers): Continuous monitoring rechecks selected customer evidence over time. Configure a policy, enrol eligible customers and give material changes a clear review owner. (2026-10-04, by Charles Archibong)
- [How to screen customers for risk with Myaza Trust](https://trust.myaza.co/blog/how-to-screen-customers): Screen customers for sanctions, politically exposed person and adverse-media findings. Start with a correctly linked customer, run the supported checks, then review possible matches with the evidence available. (2026-10-04, by Charles Archibong)
- [How to investigate connected customers with Myaza Trust](https://trust.myaza.co/blog/how-to-connected-customers): Investigate connections that may explain suspicious activity. Shared devices, instruments and locations help you ask better questions; they do not establish that every connected customer is the same person. (2026-10-04, by Charles Archibong)
- [How to manage reviewed lists with Myaza Trust](https://trust.myaza.co/blog/how-to-review-lists): Use organisation lists to apply reviewed evidence consistently. Keep the decision to add an entry separate from the rules that decide what a list match does. (2026-10-04, by Charles Archibong)
- [How to search for matching customers with Myaza Trust](https://trust.myaza.co/blog/how-to-face-search): Search an authorised face photo against the eligible faces in your organisation. Use this for investigation and review, and keep it distinct from comparing two photos or proving a login. (2026-10-04, by Charles Archibong)
- [How to verify a customer again with Myaza Trust](https://trust.myaza.co/blog/how-to-reverify-customer): Ask an existing applicant to redo a finished verification, either fully or only for selected steps. Re-verification keeps the verification ID and records a new attempt so your team can explain what changed. (2026-10-04, by Charles Archibong)
- [How to compare two faces with Myaza Trust](https://trust.myaza.co/blog/how-to-face-comparison): Compare two face photos when you need a 1:1 similarity verdict. This check is separate from liveness and from searching a customer book. (2026-10-04, by Charles Archibong)
- [How to confirm a returning customer with Myaza Trust](https://trust.myaza.co/blog/how-to-returning-customer): Protect a returning-customer journey with a subject-bound face re-authentication workflow. Compare the current live capture with an eligible enrolment or an authorised reference photo, then let your backend decide whether to continue the login. (2026-10-04, by Charles Archibong)
- [How to confirm address evidence with Myaza Trust](https://trust.myaza.co/blog/how-to-confirm-address): Collect a map pin and the address details your team needs. Address Intelligence corroborates available evidence and explains its strength; a customer’s typed address remains their claim. (2026-10-04, by Charles Archibong)
- [How to collect customer declarations with Myaza Trust](https://trust.myaza.co/blog/how-to-customer-declarations): Collect the declarations your policy needs, such as source of funds or expected activity. A questionnaire gives you recorded customer answers; it does not independently verify those answers. (2026-10-04, by Charles Archibong)
- [How to confirm contact details with Myaza Trust](https://trust.myaza.co/blog/how-to-contact-verification): Verify contact access with an email or phone code. Use the workflow step for capture journeys, or the standalone API when your app needs a separate contact check. (2026-10-04, by Charles Archibong)
- [How to add device intelligence with Myaza Trust](https://trust.myaza.co/blog/how-to-device-intelligence): Device Intelligence adds context from the device and network used during capture. Configure the findings your team cares about and use them with other evidence rather than treating every shared device as fraud. (2026-10-04, by Charles Archibong)
- [How to strengthen identity evidence with Myaza Trust](https://trust.myaza.co/blog/how-to-identity-evidence): NFC verification adds authenticated chip evidence from a supported passport or identity card. Set it up in a mobile capture workflow and understand when the result can, and cannot, raise assurance. (2026-10-04, by Charles Archibong)
- [How to confirm a live person with Myaza Trust](https://trust.myaza.co/blog/how-to-live-person): Liveness helps establish that a real person is in front of the camera. Enable it in the workflow customers use, choose a method, and route captures your policy cannot accept. (2026-10-04, by Charles Archibong)
- [How to verify a new customer with Myaza Trust](https://trust.myaza.co/blog/how-to-verify-customer): Verify a new customer with a published individual workflow. The customer completes the document and selfie journey; your backend receives the outcome and applies your onboarding policy. (2026-10-04, by Charles Archibong)
- [How to detect duplicate customers with Myaza Trust](https://trust.myaza.co/blog/how-to-duplicate-customers): Set up face search in your onboarding workflow, decide what happens when one person uses another account, and check that your app handles the result. (2026-10-04, by Charles Archibong)
- [Myaza Trust joins NVIDIA Inception](https://trust.myaza.co/blog/myaza-trust-joins-nvidia-inception): Matching a face to one document is a contained problem. Searching it against every identity a business has verified is not. Why our fraud work is becoming a compute problem, and why Myaza Trust has joined NVIDIA Inception. (2026-09-29, by Charles Archibong)
- [White-label verification: putting your own logo on every verification screen](https://trust.myaza.co/blog/white-label-verification-branding): You can replace the “Protected by Myaza” footer on every verification screen with your own logo. It costs $0.10 per production verification, and your organisation needs $750 of paid credit the first time a custom logo goes live. After that, anyone on your team can change it at any balance. (2026-09-28, by Charles Archibong)
- [Perpetual KYC: keeping customer records current without re-onboarding everyone](https://trust.myaza.co/blog/perpetual-kyc): Perpetual KYC means refreshing a customer's record when something changes, not re-checking everyone on a calendar. The FATF asks that CDD data be kept up to date, particularly for higher-risk customers; events and risk decide when and how much you ask for. (2026-09-27, by Charles Archibong)
- [Screening crypto wallet addresses: what it catches and where it stops](https://trust.myaza.co/blog/crypto-wallet-screening): Wallet screening catches addresses that a sanctions authority has published, matched exactly. It misses unlisted addresses controlled by the same person, funds that passed through intermediaries, and anything published after your last check. (2026-09-27, by Charles Archibong)
- [PEP screening: what a match means and what to do next](https://trust.myaza.co/blog/pep-screening-what-a-match-means): A PEP match means you must confirm the person and classify them, not refuse them. Foreign PEPs always need senior management approval, source of wealth and funds, and enhanced monitoring; domestic PEPs need it only when your risk assessment says the relationship is higher risk. (2026-09-27, by Charles Archibong)
- [Cutting sanctions screening false positives without missing real matches](https://trust.myaza.co/blog/reducing-sanctions-false-positives): Most sanctions false positives come from thin customer data, not oversensitive matching. Screen with verified date of birth, nationality and ID numbers, record why each match was cleared so it does not return, and leave fuzzy matching alone. (2026-09-27, by Charles Archibong)
- [FATF's payment transparency changes: what payment firms should prepare for](https://trust.myaza.co/blog/fatf-payment-transparency-changes): In June 2025 the FATF revised Recommendation 16: standard originator and beneficiary data above USD/EUR 1,000, a defined start to the payment chain and new checks against misdirected payments. Countries are expected to implement by the end of 2030. (2026-09-27, by Charles Archibong)
- [The Travel Rule explained for teams launching crypto transfers](https://trust.myaza.co/blog/travel-rule-explained): The Travel Rule requires the sending VASP to collect originator and beneficiary information, pass it to the receiving VASP immediately and securely, and make it available to authorities. The hard parts are finding the counterparty, handling self-hosted wallets and jurisdictions that disagree. (2026-09-27, by Charles Archibong)
- [FATF's 2026 virtual assets update: what VASP compliance teams should take from it](https://trust.myaza.co/blog/fatf-virtual-assets-update-2026): The FATF's seventh targeted update, published 16 July 2026, finds most jurisdictions now have Travel Rule laws but few enforce them, and asks VASPs to strengthen wallet screening, unhosted-wallet due diligence and scrutiny of offshore platforms. (2026-09-27, by Charles Archibong)
- [FATF's June 2026 grey list: what it means for onboarding in affected markets](https://trust.myaza.co/blog/fatf-grey-list-june-2026): On 19 June 2026 the FATF added Bosnia and Herzegovina and Iraq to its grey list and removed Algeria and Namibia. A grey listing is a risk factor to weigh, not an instruction to apply enhanced due diligence or to exit a market. (2026-09-27, by Charles Archibong)
- [Where verification flows lose people, and how to fix each step](https://trust.myaza.co/blog/reducing-verification-drop-off): People abandon verification at predictable moments: an unexplained start, the camera permission prompt, document capture, the desktop that has no camera, and being asked to redo everything after one failure. Measure abandonment per step, then fix the step, not the whole flow. (2026-09-27, by Charles Archibong)
- [When one ID is not enough: verifying two or three IDs in one run](https://trust.myaza.co/blog/multi-id-verification): Ask for more than one ID when a single ID's weakness is exactly your risk, and choose IDs that come from independent sources. Then set the pass policy (all, at least two, or any one) by asking what you would accept if one check could not confirm. (2026-09-27, by Charles Archibong)
- [Age checks that hold up: from a birthday field to a verified date of birth](https://trust.myaza.co/blog/age-verification-from-government-id): A reliable age check uses a date of birth established by an identity check (the government record, the document or its chip), compares it with a per-country limit, and fails closed when no date can be read. A typed birthday proves nothing, and a face gives an estimate with a margin of error. (2026-09-27, by Charles Archibong)
- [Verifying addresses where streets have no numbers](https://trust.myaza.co/blog/verifying-addresses-without-street-numbers): Where streets have no numbers, stop matching address strings. Capture a location (a map pin with the applicant's own directions and details), then corroborate it at the level of the area against independent records, and grade how strongly it is supported. (2026-09-27, by Charles Archibong)
- [Proof of address: what a utility bill does and does not prove](https://trust.myaza.co/blog/proof-of-address-documents): A proof-of-address document shows that an address was printed on a document, often with a name, at a date. Give it the weight of corroborating evidence, not verification, and set rules for name, recency and document type that fit each market. (2026-09-27, by Charles Archibong)
- [Proving the person applying can act for the business](https://trust.myaza.co/blog/verifying-kyb-applicant): Registration numbers are public, so anyone can type a real company's number. To know the applicant represents the business, verify who they are, check them against the officers the register names, and, where they are not named, ask for evidence of their authority. (2026-09-27, by Charles Archibong)
- [When the owner is another company: handling layered ownership in KYB](https://trust.myaza.co/blog/layered-company-ownership): A corporate shareholder is never the beneficial owner; it is a branch of the ownership chain you have not finished. Screen it as an entity, multiply stakes down the chain to find effective ownership, trace the branches that can matter, and escalate the ones you cannot resolve. (2026-09-27, by Charles Archibong)
- [Finding the people behind a company: a practical guide to beneficial ownership](https://trust.myaza.co/blog/beneficial-ownership-checks): Identify beneficial owners by reconciling two imperfect lists, the register and the applicant's own disclosure, then testing each person against an ownership threshold and, where nobody crosses it, against control by other means. Verify each person to a depth set by their role. (2026-09-27, by Charles Archibong)
- [What a business registry check tells you, and what it leaves out](https://trust.myaza.co/blog/business-registry-verification): A registry lookup confirms that a company with that number exists and shows what it last filed. It does not prove the applicant represents it, who really owns it, or that it trades as it says. Each gap needs its own check. (2026-09-27, by Charles Archibong)
- [Not every pass is equal: using assurance levels in onboarding decisions](https://trust.myaza.co/blog/kyc-assurance-levels): A chip pass, a government database pass and a document-only pass are different strengths of evidence. Decide in advance what each tier may do, route on the tier together with the photo the face was matched against, and let customers move up a tier rather than failing them. (2026-09-27, by Charles Archibong)
- [Setting a face-match threshold without guessing](https://trust.myaza.co/blog/setting-face-match-thresholds): A face-match threshold is a policy choice between wrongly accepting impostors and wrongly rejecting real customers. Set the pass mark from labelled pairs of your own traffic, add a bounded review band just below it, and revisit both as your users and cameras change. (2026-09-27, by Charles Archibong)
- [What reading an ePassport chip proves, and what it can't](https://trust.myaza.co/blog/epassport-nfc-chip-verification): Reading an ePassport chip can prove the data was signed by the issuing state and has not been altered, and with Active Authentication that the chip is not a copy. It cannot prove the person holding the passport is its owner; only a face match against the chip photo does that. (2026-09-27, by Charles Archibong)
- [Why a liveness result from the phone is not enough](https://trust.myaza.co/blog/server-side-liveness-verification): A liveness result produced on the phone is a claim made by a device the attacker may control. Treat it as input, keep the recorded evidence, and re-check that evidence on the server before the result counts. (2026-09-27, by Charles Archibong)
- [Gesture or screen-flash liveness: choosing the right challenge](https://trust.myaza.co/blog/gesture-vs-flash-liveness): Gesture challenges suit broad consumer populations and defeat photos and pre-recorded video. Screen-flash challenges add a random colour sequence that a replayed or injected video cannot contain. Use both where the account is valuable enough to justify the extra seconds. (2026-09-27, by Charles Archibong)
- [NIN and BVN checks in Nigeria: what each one proves](https://trust.myaza.co/blog/nin-bvn-verification-nigeria): A NIN check confirms a national identity recorded by NIMC; a BVN check confirms an identity enrolled in the Nigerian banking system; a vNIN is a privacy-preserving token for a NIN. Each proves the record exists, and only a live selfie matched to the record photo proves the applicant owns it. (2026-09-27, by Charles Archibong)
- [Document checks or government database checks: which does your onboarding need?](https://trust.myaza.co/blog/document-vs-government-database-checks): Reading a document tells you it looks genuine; checking the government record tells you the identity was issued. Use the record wherever one exists, and remember that neither proves the applicant owns the identity until a live face is matched to a trusted photo. (2026-09-27, by Charles Archibong)
- [What identity verification actually checks, and what it doesn't](https://trust.myaza.co/blog/what-identity-verification-checks): A KYC check proves three narrow things at one moment: the identity exists, the evidence for it is genuine, and the person in front of the camera is its owner. It says nothing about intent, and it starts ageing the day it passes. (2026-09-27, by Charles Archibong)
- [Sending events for real-time risk scoring: an integration guide](https://trust.myaza.co/blog/sending-events-for-real-time-risk-scoring): Call the transactions endpoint for money movement and the activities endpoint for logins and account changes, from your backend, before you act. Send complete parties and stable IDs, then route on the summary outcome: allow, review or block. (2026-09-27, by Charles Archibong)
- [Adding identity verification to a React Native or Flutter app](https://trust.myaza.co/blog/mobile-identity-verification-react-native-flutter): The component call is the easy part. A mobile integration is mostly native build setup, camera permission, mounting a dashboard workflow so changes skip the app store, and keeping results on your server rather than on the phone. (2026-09-27, by Charles Archibong)
- [Mapping verification statuses to your product's states](https://trust.myaza.co/blog/mapping-verification-statuses): Drive your product from status, the outcome, and store checkStatus beside it as the explanation. Map each of the ten status values to one state in your own system, and take the latest status from verification.status_updated or polling. (2026-09-27, by Charles Archibong)
- [Idempotent verification requests: avoiding duplicate checks and duplicate charges](https://trust.myaza.co/blog/idempotent-verification-requests): Give every logical verification one requestId, save it before you send the request, and reuse it on every retry. A repeated requestId returns the existing verification instead of creating and charging a second one. (2026-09-27, by Charles Archibong)
- [Publishable and secret keys: keeping verification data off the client](https://trust.myaza.co/blog/publishable-and-secret-api-keys): Put the publishable key in your app and keep the secret key on your server. A publishable key can start a verification and read minimal status, never identity data, so plan for it to leak and limit what a leak costs. (2026-09-27, by Charles Archibong)
- [Testing a verification integration end to end in sandbox](https://trust.myaza.co/blog/testing-verification-in-sandbox): Use the published sandbox test IDs, whose last digits pick the outcome, to drive every result your code must handle, then test your webhook receiver with signed simulated events before switching to live keys. (2026-09-27, by Charles Archibong)
- [Changing a verification flow without shipping an app release](https://trust.myaza.co/blog/changing-verification-flows-without-a-release): Mount the SDK with a workflow ID and the flow's configuration is fetched from the server on each verification. Re-publishing changes the steps, checks, copy and decision rules for the next verification, with no app release. (2026-09-27, by Charles Archibong)
- [How decision graphs turn checks into approve, review or decline](https://trust.myaza.co/blog/decision-graphs-explained): When a verification finishes, the workflow's decision graph gathers the results, walks branches over a closed set of fields, and lands on approve, review or decline. The outcome sets the customer's disposition without rewriting what the checks found. (2026-09-27, by Charles Archibong)
- [Designing your product around asynchronous verification results](https://trust.myaza.co/blog/designing-for-asynchronous-verification): Treat submission as the start of a state machine, not the end of a request. Show a pending state keyed on the verification ID, update it from webhooks, and let the status change more than once. (2026-09-27, by Charles Archibong)
- [Verifying webhook signatures and handling retries correctly](https://trust.myaza.co/blog/verifying-webhook-signatures): Verify the V2 signature over the raw request bytes, reject timestamps outside the replay window, record the event ID under a unique constraint before doing any work, and return 2xx only once the event is durably stored. (2026-09-27, by Charles Archibong)
- [SDK embed or hosted link: choosing how to integrate verification](https://trust.myaza.co/blog/sdk-or-hosted-link): Embed the SDK when verification happens inside your product; send a per-applicant hosted link when it happens outside it. Either way, run a published workflow so the choice is about delivery, not about the flow. (2026-09-27, by Charles Archibong)
- [Handling biometric data under African data protection laws](https://trust.myaza.co/blog/biometric-data-protection-africa): All three laws treat biometric data as a special category. Nigeria names facial images and limits sensitive data to listed grounds, Kenya treats biometric processing as high risk, and POPIA prohibits it unless an authorisation applies. Plan a lawful ground, a DPIA and minimisation. (2026-09-27, by Charles Archibong)
- [What device and IP signals can and cannot tell you](https://trust.myaza.co/blog/device-and-ip-signals-limits): Device and IP signals describe the handset and connection, not the person. Emulators and datacentre IPs are strong warnings; shared devices, IP velocity and IP location are weak. Use them as corroboration, and trust IP location at country level only. (2026-09-27, by Charles Archibong)
- [Account takeover: step up the challenge instead of blocking the customer](https://trust.myaza.co/blog/account-takeover-step-up): When a login looks like account takeover, do not guess. Let the session continue with limited rights, and ask for proof that only the verified person can give, such as a fresh selfie matched to their enrolment, before any payout, beneficiary change or password reset goes through. (2026-09-27, by Charles Archibong)
- [Finding the same person behind many accounts](https://trust.myaza.co/blog/finding-duplicate-accounts): Rank shared artefacts by what they prove. A repeated verified ID number means the same person. A repeated face is strong evidence for review. A shared device, phone or address is common in families, so treat it as corroboration and count distinct people before acting. (2026-09-27, by Charles Archibong)
- [Deepfakes and injected camera feeds: how identity checks are attacked](https://trust.myaza.co/blog/deepfakes-and-injection-attacks): Attackers either hold fake media up to a real camera (a presentation attack) or feed it straight into the data stream (an injection attack). Each needs its own defence, and the strongest checks add evidence a camera cannot supply. (2026-09-27, by Charles Archibong)
- [Preparing customer due diligence for the EU AML Regulation](https://trust.myaza.co/blog/eu-aml-regulation-2027-readiness): The EU AML Regulation applies directly from 10 July 2027 and replaces national CDD rules with one text. Build to the Regulation now: its data list, verification routes, 25% ownership test and refresh ceilings are fixed, while AMLA's detailed CDD standards were still in draft as of September 2026. (2026-09-27, by Charles Archibong)
- [Companies House ID verification: the November 2026 deadline and what KYB teams will see](https://trust.myaza.co/blog/companies-house-identity-verification-deadline): Since 18 November 2025, new UK directors and PSCs must verify their identity with Companies House, and existing ones within a 12-month transition to mid-November 2026. It adds register signals for KYB teams, but does not show that the person applying to you is that director. (2026-09-27, by Charles Archibong)
- [CBN's 2026 BVN framework changes: what onboarding teams should note](https://trust.myaza.co/blog/cbn-bvn-framework-changes-2026): From 1 May 2026, under a CBN circular dated 12 March 2026, only adults can enrol for a BVN, the linked phone number can change only once, banks must hold BVNs tied to suspicious transactions on a 24-hour watchlist, and database access is limited to licensed institutions. (2026-09-27, by Charles Archibong)
- [CBN's baseline standards for automated AML: preparing your stack](https://trust.myaza.co/blog/cbn-automated-aml-baseline-standards): The CBN's baseline standards, issued on 10 March 2026, set mandatory minimum requirements for automated systems that detect, analyse and report suspicious activity in real time. The CBN has said compliance is assessed at the level of the institution, so buying a tool is a start, not an answer. (2026-09-27, by Charles Archibong)
- [goAML explained: how reports reach financial intelligence units](https://trust.myaza.co/blog/goaml-reporting-explained): goAML is software built by the UN Office on Drugs and Crime for financial intelligence units. Reporting entities register on their FIU's goAML portal and submit reports by web form or as XML files that follow the goAML schema, using the FIU's own codes. (2026-09-27, by Charles Archibong)
- [Writing a suspicious activity report an FIU can act on](https://trust.myaza.co/blog/writing-a-suspicious-activity-report): A useful SAR or STR narrative answers who, what, when, where, why and how in one chronological account, explains why the activity is unusual for this customer, and gives facts an analyst can follow without opening your systems. (2026-09-27, by Charles Archibong)
- [From alert to decision: running an investigation queue that keeps up](https://trust.myaza.co/blog/alert-triage-and-case-management): Keep two queues with two jobs. Triage alerts quickly and label every one; open an investigation only when the evidence needs an owner, group it by customer, prioritise by risk and deadline, and close it with a recorded outcome and rationale. (2026-09-27, by Charles Archibong)
- [Backtest before you switch on: testing monitoring rule changes safely](https://trust.myaza.co/blog/backtesting-monitoring-rules): Replay recent scored events through the proposed configuration, then read each decision that would change as well as the count. A backtest shows what a change would have done to the past; your written expectation is what tells you whether that is the right result. (2026-09-27, by Charles Archibong)
- [Transaction monitoring rules that find risk instead of noise](https://trust.myaza.co/blog/transaction-monitoring-rules): A monitoring rule earns its place when it maps to a risk you actually carry, fires on data you actually send, and produces alerts an analyst can close with a reason. Tune rules against labelled outcomes, never against alert counts alone. (2026-09-27, by Charles Archibong)
---
# 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:
- [User verification](https://trust.myaza.co/documentation/user-verification/markdown) for a hosted or embedded individual flow.
- [Business verification](https://trust.myaza.co/documentation/business-verification-quickstart/markdown) for a KYB workflow and company link.
- [Risk Intelligence quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown) for a server-side transaction decision.
- [On-Demand Wallet Screening](https://trust.myaza.co/documentation/wallet-screening/markdown) for an explicit wallet check.
## 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
- [User verification](https://trust.myaza.co/documentation/user-verification/markdown) or [Business verification](https://trust.myaza.co/documentation/business-verification-quickstart/markdown): launch a workflow and receive the result.
- [Verification API quickstart](https://trust.myaza.co/documentation/quickstart/markdown): a full individual verification through HTTP.
- [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.
---
# Status and reliability
Myaza Trust is monitored every minute from Johannesburg, London, Frankfurt, Amsterdam, Virginia and California by an independent monitoring service that runs outside our own infrastructure.
## Live status
The [status page](https://status.myaza.co) shows the current state of each service, active incidents and scheduled maintenance. It stays up even if Myaza itself does not. You can subscribe there to be emailed when we post an incident or maintenance update.
## Availability record
The [reliability record](/reliability) shows measured production availability over the last 7, 30, 60 and 90 days, with a daily timeline, each service's median response time, monthly history, incidents and the methodology. A day with failed checks and no declared incident is marked as such rather than hidden.
For a procurement or security review, send the [shareable report](/reliability/report?period=90d), or download it as a PDF from that page. Any past period can be requested with `?from=YYYY-MM-DD&to=YYYY-MM-DD`.
The same data is available as JSON:
```bash
curl "https://trust.myaza.app/api/public/reliability?period=90d"
curl -o report.pdf "https://trust.myaza.co/api/reliability/report?period=90d"
```
## How availability is calculated
Availability is the share of checks that succeeded, over complete UTC days. A check is one request from one region; a slow but successful answer counts as available and is reported separately. Checks that failed during scheduled maintenance are **not** excluded. Days before monitoring began are shown as having no data, never as available.
These figures describe observed availability. They are not a service level agreement; contractual commitments, where agreed, are set out in your agreement.
---
# 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. |
| `metadata.endUser` | object | no | Secret keys only. Your user's own connection, when you call this endpoint from your backend: `ipAddress`, `userAgent` and optionally `acceptLanguage`. See [Calling from your backend](#calling-from-your-backend). |
\* 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.
## Calling from your backend
When you call this endpoint from your server with a secret key, the request reaches us from **your server**, not your user. We don't treat your server as your user's device: its address and user agent are kept for the record only, and no Device Intelligence signals are raised from them.
To have your user's device and network analysed (country, network type, VPN and Tor checks, blocklists), pass their connection as you received it:
```json
"metadata": {
"requestId": "order_1001",
"endUser": {
"ipAddress": "102.89.34.10",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) ...",
"acceptLanguage": "en-NG,en;q=0.9"
}
}
```
- Send the address your own server saw for your user, not your server's address.
- A value we can't read (for example an IP address that isn't valid) is ignored. It never makes the request fail.
- `endUser` is ignored on a publishable key. Those calls come from your user's own device, so we already see their connection.
- Without `endUser` or a device fingerprint, Device Intelligence has nothing to analyse, and it is not charged.
## 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 automated checks found (`pending`, `verified`,
`failed`, `not_found`, `error`). Act on `status`; keep `checkStatus` as the
explanation. The two differ whenever your workflow or a reviewer decided something
the checks alone would not have, for example `processing` with
`checkStatus: "verified"` while your workflow waits on a screen. Before submission
there is no `checkStatus`, because no checks have run. See
[Status and checkStatus](https://trust.myaza.co/documentation/verifications/markdown#status-and-checkstatus) for every combination.
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 the
> person was accepted despite what the checks found, by a reviewer or by one of your
> workflow rules, and `reason`/`reasonCode` below say what they were accepted
> 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`, `checkStatus`, `country`, `idType`,
`createdAt`, and `workflow`, which [workflow](https://trust.myaza.co/documentation/workflows/markdown) was responsible for the submission.
`status` is the outcome to act on; `checkStatus` is what the automated checks found.
They differ whenever your workflow or a reviewer decided something the checks alone
would not have. See [Status and checkStatus](https://trust.myaza.co/documentation/verifications/markdown#status-and-checkstatus).
### `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**.
### Checks that did not pass (`checkStatus`: `failed`, `not_found` or `error`)
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).
On a run that verifies several IDs, each check in `multiId.checks` carries its own
`recordPhoto` and `documentPortrait` as media URLs (`id-check--photo` and
`id-check--portrait`), and each entry in `supportingDocuments` carries a `url`
for its file. These links follow your workflow's photo switches and returned-data
settings, the same as the webhook: a photo you have switched off comes back as
`null`.
## 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, expire & cancel](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`. To stop one reversibly instead, cancel it with `POST /api/kyc/sessions/:id/cancel` and uncancel it later. See [Delete, expire & cancel](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.
## Server SDK errors (package preview)
The server SDK is not yet available on the public npm registry. For current
integrations, handle the HTTP status and error code as described above. When
the package is published, its typed errors will let your backend 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 outcome to act on (what the checks, your workflow or a reviewer concluded); `checkStatus` is what the automated checks themselves found, so approving a failed check (an exception) stays distinguishable from approving a passing one. See [Status and checkStatus](https://trust.myaza.co/documentation/verifications/markdown#status-and-checkstatus). 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
For manual crypto investigations, open **Spot Checks → Wallet** in the dashboard. Enter a wallet address for an on-demand risk result and case actions. Backend integrations can use the [On-Demand Wallet Screening API](https://trust.myaza.co/documentation/api-wallet-screenings/markdown). [Transaction Monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown) uses the same engine to screen crypto counterparties automatically.
```
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. |
| `externalUserId` | no | Your own reference for the person. A verified check is saved on the customer record with that reference. |
| `createEntity` | no | Defaults to `true`: a verified check is saved as a customer record. Without `externalUserId`, it is saved on the record that already holds this ID number, or as a new record. Send `false` to keep the result in spot-check history only. If you later send the same ID number with your own reference (a verification, a check or the entity API), the record created here is renamed to that reference rather than duplicated. |
```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. |
| `externalUserId` | no | Your own reference for the business. A verified check is saved on the customer record with that reference. |
| `createEntity` | no | Defaults to `true`: a verified check is saved as a customer record. Without `externalUserId`, it is saved on the record that already holds this registration number, or as a new record. Send `false` to keep the result in spot-check history only. If you later send the same ID number with your own reference (a verification, a check or the entity API), the record created here is renamed to that reference rather than duplicated. |
| `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, expire & cancel
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.
## Cancel a session or a processing verification
```
POST /api/kyc/sessions/:id/cancel
POST /api/kyc/sessions/:id/uncancel
```
Stops a verification midway, and takes that back. The `:id` is either a session's or a verification's (a verification keeps its session's id), and either can be cancelled:
- **A session the applicant has not submitted.** They can no longer continue: the hosted link, the SDK and `/session/start` answer `409 session_cancelled` until you uncancel it. What they had entered is kept, so after an uncancel they carry on where they left off. Your endpoints receive `session.cancelled`, then `session.uncancelled`.
- **A verification that is still processing.** That is one that is queued, waiting for credit, or holding its decision on screening, key people or an address check. Nothing further runs on it until you uncancel it, and then it carries on from where it stopped. Your endpoints receive `verification.status_updated` with `status: "cancelled"`, and again when it is uncancelled.
While a person's latest session for a workflow is cancelled, the SDK does not start them over either. Uncancel it, or create a new session for them, to let them try again.
**Money.** Once any check has run (a registry lookup, a document read, a government lookup, the device analysis), cancelling refunds nothing. If nothing has run yet, the amount reserved for it is refunded. Uncancelling never charges twice.
Optional body for a cancel: `{ "reason": "Customer asked us to stop" }` (up to 500 characters). It goes to your audit log and is never sent to a webhook.
```bash
curl -X POST "https://trust.myaza.app/api/kyc/sessions/cmt1gfjce05g547grqbmjsiqf/cancel" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer asked us to stop" }'
```
### Response `200 OK`
```json
{
"id": "cmt1gfjce05g547grqbmjsiqf",
"kind": "verification",
"status": "cancelled",
"previousStatus": "processing",
"changed": true,
"refunded": false
}
```
`kind` is `attempt` for a session that had not submitted and `verification` for one that had. `changed` is `false` when it was already cancelled; the call is safe to repeat. An uncancel answers the same shape, with the status it went back to.
## 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. |
| 409 | `verification_running` | Cancel only: its checks are running right now. They finish within a minute or two; try again then. |
| 409 | `not_processing` | Cancel only: it has finished, or the session has already ended. |
| 409 | `not_cancelled` | Uncancel only: it is not cancelled. |
| 409 | `session_expired` | Uncancel only: the session ran out of time while it was cancelled. Create a new session instead. |
| 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. |
---
# API explorer
Search the [interactive API explorer](/documentation/api-explorer) to find v1 endpoints and inspect their fields. It reads Myaza's public v1 OpenAPI contract and does not send requests or ask for your API key. For `/api/kyc` identity integrations, start with [User Verification](https://trust.myaza.co/documentation/user-verification/markdown) or [Business Verification](https://trust.myaza.co/documentation/business-verification-quickstart/markdown). For v1, see [On-Demand Wallet Screening](https://trust.myaza.co/documentation/api-wallet-screenings/markdown) for a copyable first request.
---
# On-Demand Wallet Screening API
`POST /api/v1/wallet-screenings` screens one address. Use a secret `sk_test_` or `sk_live_` key on your backend. The key selects the environment. Use a new `Idempotency-Key` for each intended screening and keep that key for exact retries. A replay returns the same screening without another screening call or charge.
```bash
curl -X POST "https://sandbox.trust.myaza.app/api/v1/wallet-screenings" \
-H "Authorization: Bearer $MYAZA_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wallet-customer-42-20260930" \
-d '{"network":"ethereum","address":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","asset":"USDT","sandboxScenario":"wallet.sanctions.direct"}'
```
`network` is the chain, such as `ethereum` or `TRX`; `asset` is separate, so USDT and USDC are never network values. Network identifiers may contain letters, numbers, periods, underscores, colons and hyphens, up to 40 characters. Myaza translates known aliases and forwards other well-formed identifiers as supplied, without a fixed allowlist. The configured provider determines network and address acceptance. `sandboxScenario` works only with a test key. Optionally add `subject: {"externalUserId":"customer_42"}` to link an existing customer and enable linked alerts and cases.
With the Sandbox scenario above and the default direct-sanctions rule, these
selected response fields show what to inspect first. IDs and timestamps vary:
```json
{
"screening": {
"id": "wsc_test_example",
"environment": "sandbox",
"kind": "address",
"status": "screened",
"wallet": {
"address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"blockchain": "ETH",
"asset": "USDT"
},
"risk": { "score": 92, "level": "critical" },
"decision": "block",
"exposures": [
{ "category": "SANCTIONS", "direction": "source", "type": "direct", "hops": 0, "share": 65 },
{ "category": "EXCHANGE", "direction": "source", "type": "direct", "hops": 0, "share": 35 }
],
"fundFlow": {
"kind": "exposure_map",
"totalExposures": 2,
"truncated": false,
"nodes": [
{ "id": "exposure-0", "exposurePosition": 0, "category": "SANCTIONS", "direction": "source", "type": "direct", "hops": 0, "share": 65, "entityName": "OFAC SDN wallet", "isHighRisk": true },
{ "id": "exposure-1", "exposurePosition": 1, "category": "EXCHANGE", "direction": "source", "type": "direct", "hops": 0, "share": 35, "entityName": "Sandbox Exchange", "isHighRisk": false }
]
},
"rulesTriggered": [
{ "name": "Direct sanctions exposure", "version": 1, "action": "BLOCK" }
]
}
}
```
The response also contains the full permitted profile, exposure details, rule
conditions, case links and timestamps. `block` is Myaza Trust's API value for a
decline decision. The `rulesTriggered` array records the matched rule version.
```ts
const response = await fetch(`${process.env.MYAZA_API_BASE_URL}/wallet-screenings`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MYAZA_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'wallet-customer-42-20260930',
},
body: JSON.stringify({ network: 'ethereum', address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }),
});
const { screening } = await response.json();
console.log(screening.id, screening.decision, screening.rulesTriggered);
```
Responses use the Myaza Trust contract: `screening.id`, `environment`, `wallet`, `risk`, `decision`, `exposures`, `rulesTriggered`, `provider`, `screenedAt` and `createdAt`. The provider section contains only identity and references, never raw screening data. `decision` is `allow`, `review` or `block`. A new result returns `201`, an idempotent replay `200` and an in-progress request `202`.
## Wallet verification & screening
The address overview uses the saved screening evidence. These fields are also
returned for each counterparty wallet in a [transaction assessment](https://trust.myaza.co/documentation/monitoring-events/markdown#transaction-assessment-response).
| Address overview | Response field |
|---|---|
| Screening target | `kind` (`address` for a wallet address) |
| Wallet address | `wallet.address` |
| Provider | `provider.upstreamProvider` when returned, such as `merklescience` (Merkle Science); `provider.name` identifies the integration, such as `didit` |
| Score | `risk.score`, out of 100 |
| Risk level | `risk.level`, the Myaza severity band |
| Blockchain | `wallet.blockchain` |
| Owner | `profile.owner` |
| Owner type | `profile.ownerType` and optional `profile.ownerSubtype` |
| User | `profile.user` |
| User type | `profile.userType` and optional `profile.userSubtype` |
| First transaction time | `profile.firstTransactionAt` |
| Latest transaction time | `profile.latestTransactionAt` |
| Date added | `profile.providerDateAdded` |
Owner and user labels are separate provider attributions, not proof of wallet
control. Missing fields are `null`; Myaza does not copy owner into user or
substitute the screening date for a missing transaction date. Existing
`profile.entityLabel` and `profile.entityType` fields remain available.
`provider.riskLevel` preserves the provider's severity separately from Myaza's
risk band. An empty profile does not mean the wallet is clean.
Use `GET /api/v1/wallet-screenings/{id}` to read one result and `GET /api/v1/wallet-screenings?limit=50&cursor=...` for scoped history. A different organisation or environment cannot read the screening.
`GET /api/v1/wallet-screenings/{id}/report` downloads a Myaza PDF of the saved decision, exposures and triggered rule versions. It does not rerun screening and is not cryptographically signed. For a completed Didit address result, `GET /api/v1/wallet-screenings/{id}/signed-report` renders the vendor-signed PDF from the retained exact provider response. It returns `404` when no signed report exists and `502` when the vendor cannot render it. Neither route exposes the raw provider JSON. Both require the same scoped secret key as the result read.
`fundFlow` is a bounded exposure map: the first 40 normalised exposure rows, with `totalExposures` and `truncated` indicating whether more were retained in `exposures`. It does not claim a verified transfer path or expose the provider's raw graph.
| Result | Next step |
|---|---|
| `201` created | Save `screening.id` and act on `decision`. |
| `200` replay | Reuse the original result; no second screening or charge was created. |
| `202` in progress | Read the returned screening or assessment status before acting. |
| `422` invalid wallet or malformed network | Correct the input (`invalid_wallet` or `invalid_network`). No provider call or billable usage was made. |
| `409` idempotency conflict | Use the original payload with that key, or choose a new key for a new screening. |
If the provider rejects a network after receiving the request, Myaza records the failed attempt. This is separate from pre-call input validation; do not blindly retry it or treat it as a clean result.
Operational uncertainty becomes a reviewable result, never a clean wallet. To
receive updates, subscribe to [Wallet Intelligence webhooks](https://trust.myaza.co/documentation/webhook-wallet-intelligence/markdown).
---
# 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).
---
# Address Intelligence
Address strings in our markets are free prose: "3rd house after the yellow kiosk, off Adeola Odeku" is a real address and no string comparison will ever match it against a utility bill. Address Intelligence sidesteps the string entirely. The applicant places a **map pin** on where they live, optionally adds **directions** and an **entrance photo**, and the platform corroborates the pin against the evidence it already holds: the government record's address, the [Proof of Address](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) document, and the submission IP's country.
The result is a **soft sub-result**. It never changes the verification's own pass/fail status; it feeds [decisioning](https://trust.myaza.co/documentation/decisioning/markdown), the webhook, and the dashboard's verification detail, where the pin renders on a map beside the tier badge that says how strongly it is corroborated. It is always the applicant's own claim, never presented as a verified fact.
On a **business (KYB) flow** the pin is the **business premises**, checked against the registry's registered address, and the entrance-photo slot is not offered.
## Turning it on
Address collection is a workflow step: toggle **Address Intelligence** in the builder (it sits after Proof of Address on KYC flows, and right after the business-details step on KYB flows). The config block:
```json
{
"addressCollection": {
"enabled": true,
"requirePin": false,
"photo": "optional",
"directions": "optional",
"attestPresence": true
}
}
```
| Field | Meaning |
|---|---|
| `enabled` | Adds the step to the flow. |
| `requirePin` | When `true`, the step cannot be skipped, and a submission without a pin is refused with `422 address_collection_required`. Default `false`: the applicant may skip. |
| `photo` | The entrance-photo slot: `off`, `optional` (default) or `required`. Ignored on KYB flows, which never offer the slot. |
| `directions` | The free-text directions field, same three modes. |
| `propertyFields` | The default for every typed address detail (number, street, unit, building name, neighbourhood, city, state, area code): `off`, `optional` (default) or `required`. `required` here means the house or flat **number** must be given — the addressing fact — while the building name stays optional. |
| `fields` | Per-field overrides on the same three modes, keyed by `propertyName`, `propertyNumber`, `street`, `unit`, `neighbourhood`, `city`, `state`, `postcode`. A field set here wins over `propertyFields`, so you can require a city without requiring a plot number. |
| `attestPresence` | Take a one-shot GPS fix from the device when the applicant confirms. This is the evidence behind the `attested` tier. Best effort: a denied permission or a slow fix costs the tier, never the flow. |
| `presence` | Phase 2: multi-day presence verification of the pin. `{ "enabled": true, "windowDays": 10, "minNights": 3 }` mints a **watch** when the verification passes; see below. With it on, the step shows a plain-language consent notice (how it works, the person's control, data protection) and the success screen sets expectations: keep location on, periodic at-address checks, a notification when confirmed. |
The workflow is authoritative. If the step is off, a submitted address block is dropped (never stored, never billed); if `requirePin` is set, the server enforces it whatever the client did. A field set to `off` is stripped at submit rather than stored, and a `required` field left blank is refused with **`422 missing_address_fields`**, which names the offending keys in `missing`.
Require carefully. Someone in an unnumbered compound genuinely cannot answer a required house number, and a required field they cannot fill is a dead end rather than better data. The applicant is the only honest source of a plot number; the map can supply the street and the area, which is why those prefill.
## Assurance tiers
Every collected pin resolves to a tier, strongest first:
| Tier | Meaning |
|---|---|
| `attested` | The person provably stood at the pin: the device's own GPS fix at confirmation was within a tolerance of the pin (250 m, widened to the fix's reported accuracy, capped at 1 km). |
| `corroborated` | An independent record names the pin's locality: the government record's address, or the Proof of Address document. |
| `null` | A collected claim with nothing yet backing it. Still useful for delivery, field visits, and future re-checks. |
Corroboration is deliberately **locality-level** (city/area, not street): street-level comparison of free-prose addresses manufactures `no_match` verdicts out of formatting. IP evidence stays **country-level**, because mobile carrier networks route thousands of subscribers through a handful of gateways and the city an IP resolves to is the gateway's, not the person's.
## What comes back
The verification result and the completion webhook carry `data.addressIntel` (null when the step did not run):
```json
{
"addressIntel": {
"status": "collected",
"tier": "attested",
"pin": { "lat": 6.4281, "lng": 3.4219, "accuracy": 12 },
"plusCode": "6FR5CCHC+6Q",
"property": { "name": "Sunrise Villa", "number": "11", "unit": "Flat 4" },
"claimed": { "neighbourhood": "Oniru", "city": "Lagos", "state": "Lagos", "postcode": "106104" },
"composedAddress": "Flat 4, Sunrise Villa, 11 Adeola Odeku Street, Oniru, Lagos, Lagos, 106104",
"capturedAtAddress": true,
"distanceMeters": 38,
"corroboration": {
"govRecord": "match",
"poaDocument": "unknown"
},
"geocode": { "city": "Lagos", "state": "Lagos", "country": "NG" },
"derivedAddress": "Adeola Odeku Street, Victoria Island, Lagos",
"directions": "black gate opposite the kiosk",
"entitiesAtSamePin": 0,
"entitiesAtSamePin30d": 0,
"heldMatch": true,
"knownAddresses": 4,
"corroborationSources": { "govRecord": "held" },
"checkedAt": "2026-08-25T12:00:00.000Z"
}
}
```
- `status` and `tier` are add-only vocabularies; Phase 2's presence verification will extend both.
- `plusCode` is the pin's 10-digit [Plus Code](https://plus.codes), the open, offline-computable address handle for places without street addresses. Derived from the pin, never an input, and typeable into any maps app.
- `derivedAddress` is a street-level display line reverse-geocoded from the pin, the way OkHi builds its formatted addresses. It deliberately **never carries a house number**: map data holds street names, not plot numbers, and inventing one would dress a guess as a fact. Null where the map knows too little to say anything.
- `property` and `claimed` are what the applicant typed in the edit-details form: `property` carries the building name, number and unit; `claimed` carries their own corrections to the area fields (neighbourhood, city, state, area code). They are claims, recorded verbatim and clearly labelled as such — they never feed the corroboration verdicts, which stay on the map's own answer. `composedAddress` merges the claims with the derived street into the full line, unit first. Two provenances in one string, which is why it is labelled composed and never presented as an official record. Null when the applicant typed nothing.
- `capturedAtAddress` / `distanceMeters` report the attest fix against the pin (`null` when no fix was taken).
- Each corroboration source answers `match`, `no_match` or `unknown` (nothing to compare against). On a KYB flow `govRecord` is the **premises verdict**: whether the pin's locality agrees with the registry's registered address.
- `entitiesAtSamePin` counts **other** subjects of yours whose pin lands on the same map cell, and `entitiesAtSamePin30d` the same count over the trailing 30 days. Shared households are ordinary, so a small all-time number means little; a large **recent** one is the multi-accounting signal worth routing to review. It is a review flag, **never** an identity link and never a block.
- When a submission carries nothing to corroborate against, the verdicts fall back to the address evidence the subject's entity already holds from earlier verifications. `corroborationSources` says where each verdict's evidence came from (`submission` or `held`), `heldMatch` answers whether the pin agrees with anything already held (null when nothing is held), and `knownAddresses` counts the entity's address-evidence records.
- The entrance photo arrives as `media.addressPhoto`, fetched like every other media URL with a secret key.
## Decisioning
The `address.*` fields join the condition namespace, so a graph can route on them:
| Field | Type |
|---|---|
| `address.collected` | boolean (null when the step did not run) |
| `address.tier` | `attested` / `corroborated` / null |
| `address.capturedAtAddress` | boolean |
| `address.distanceMeters` | number |
| `address.govRecordMatch` | `match` / `no_match` / `unknown` |
| `address.poaMatch` | `match` / `no_match` / `unknown` |
| `address.entitiesAtSamePin` | number |
| `address.entitiesAtSamePin30d` | number |
| `address.heldMatch` | boolean (null when the entity holds nothing to compare) |
| `address.knownAddresses` | number |
A typical pattern: fast-track when `address.tier exists`, and send `address.entitiesAtSamePin >= 3` or `address.govRecordMatch equals no_match` to review. Conditions on null values fail closed, so a flow without the step never trips an address rule.
## Which map renders where
There is nothing to configure and no map vendor account to bring. Google Maps is bundled with the platform:
| Surface | Map |
|---|---|
| **Hosted links** (including key-person invites, step-up and re-run links) | **Google Maps**, automatically. The hosted pages run on our origin, so the platform key stays locked to it. |
| **Embedded web SDK** on your own site | **Google Maps**, automatically, through a small frame served from our origin (the map itself always runs on our pages, which is what keeps the key safe on any domain). No setup on your side. |
| **Mobile SDKs** (React Native, Flutter) | **Google Maps**, automatically, inside a small in-app web view served from our origin, on the same footing as an embedded web mount. React Native needs the optional `react-native-webview` peer; Flutter bundles `webview_flutter`. Without the web view, or when the page does not load, the built-in picker renders instead. |
If the map vendor ever fails to load, or the frame cannot verify the page embedding it, the step falls back to the built-in OpenStreetMap picker rather than going blank — the flow never depends on Google to finish. The interaction is identical everywhere: the map moves under a fixed centre pin, so the applicant drags the world, not the marker. Google surfaces render **satellite imagery with labels**, because "find your roof" is answerable in a dense compound where "find your street on a road map" often is not.
## SDKs and permissions
All three SDKs render the step, including hosted links: a draggable map (fixed centre pin), an **address search box** (type "11 Bassey Street", pick a candidate, and the pin lands there with the house number prefilled; search runs on explicit submit and is available whenever the platform's geocoder is on), a "Use my current location" shortcut, an **edit-details sheet** where every part of the address is correctable — number, street, unit, building name, directions, neighbourhood, city, state and area code, prefilled from the map's own answer, each optional or required as the workflow sets it, with the flow's country shown read-only — plus the entrance photo. The pin always works by dragging alone, so **location permission is never required to finish the flow**; it powers only the shortcut and the attest fix.
On every SDK the step is a short guided flow rather than one long screen: find the address (search, with as-you-type suggestions where the platform has Google Places configured, ranked so streets near the applicant come first), confirm the pin (the edit-details sheet opens over the map), show the entrance, then review and confirm. A returning applicant with a confirmed pin lands straight on the review screen. The React Native and Flutter SDKs walk the same four steps as the web SDK, with the map and the entrance framing sized so Continue stays on screen on a small phone.
A field the workflow marks **required** is enforced in the app on all three SDKs: Continue on the pin step opens the details sheet naming what is still needed, required fields carry an asterisk, and the review step's confirm stays disabled until they are filled. A required field the applicant left on its map prefill counts as filled.
### Street View entrance framing
Where a workflow enables it (Address step, "Street View entrance"), an applicant can frame their entrance in Google Street View instead of, or as well as, taking a photo: they pan the street imagery until the entrance sits inside a frame, and the platform fetches that exact view server-side. It is stored as its own capture, separate from the applicant's photo, and both the dashboard and the webhook `media` object label which is which (`media.addressPhoto` is the applicant's own photo; `media.addressStreetView` is the framed street imagery).
It runs on hosted links, on embedded mounts and in the mobile SDKs alike: an embedded or mobile SDK never receives a maps key, so it frames the imagery inside a page served from our own origin, which the platform hands it at mount (React Native needs the optional `react-native-webview` peer for this; without it the entrance photo is the route).
`required` removes the skip affordance while imagery exists, but it is a client-side nudge, not a gate. Street View coverage is not guaranteed anywhere, so where the pin has no coverage the card explains and the photo remains the route; the server never refuses a submission over a missing frame.
- **Web**: the browser's standard geolocation prompt. Nothing to configure.
- **React Native**: the SDK's Expo config plugin adds the iOS usage string and Android foreground location permissions by default (`location: false` in the plugin options opts out).
- **Flutter**: add the platform permission strings to your host app; see the [Flutter SDK](https://trust.myaza.co/documentation/sdk-flutter/markdown) setup. iOS crashes on the permission request if `NSLocationWhenInUseUsageDescription` is missing.
## Sandbox and billing
The step bills through the `address_collection` component when a submission carries an address block the workflow accepts; sandbox and development runs are free. On a [test ID](https://trust.myaza.co/documentation/sandbox-testing/markdown) submission the result is canned: `attested` when a device fix rode along, else `corroborated`, so both tiers are exercisable at no cost.
**Selecting the outcome.** Outside production, `metadata.sandboxOutcome` accepts the address tokens `address_attested`, `address_corroborated`, `address_collected` and `address_mismatch` — the same contract as the business lookup's `verified`/`not_found` pin. The token decides the judged fields (tier, captured-at-address, corroboration verdicts) while the geocoded lines stay real; `address_mismatch` is the one your review-routing rules exist for. Production ignores the token silently. On hosted dev and sandbox flows the review step also shows a **Test result** control that sends the token for you.
## Presence verification
The strongest tier goes beyond the moment of capture: with `addressCollection.presence.enabled`, a passing verification mints a **presence watch** that runs over the following days (the `windowDays`, default 10) and resolves to one of three verdicts:
| Verdict | Meaning | Billed? |
|---|---|---|
| `verified` | Enough distinct nights (default 3) or qualifying days of at-pin evidence. The `address.presence_verified` trust signal fires. | Yes |
| `failed` | An integrity contradiction was caught: repeated mock-location evidence at the fence. Absence is never a failure; only caught spoofing is. | Yes |
| `inconclusive` | The window lapsed without enough evidence. Not a finding against anyone. | **No** |
> **Presence verification needs setup in your app.** Switching it on in the workflow starts the check, but only a phone can collect the evidence. The SDK does two things by itself when the flow is submitted: it sends the first report, and it asks the person to allow location all the time so the phone can report with your app closed. That background part only works if your app has the background location setup below. Every other report has to come from your app calling the presence reporter when it opens. If your app has neither, every watch collects one day, never reaches what it needs, and ends `inconclusive`.
### What your app has to do
1. **Run the flow in the React Native or Flutter SDK.** A hosted link or the web SDK runs in a browser, which cannot report presence, so no watch starts there.
2. **Pass your user reference as `userId`** when you mount the flow. The SDK stores the pinned address on the phone under that value, and your app reports with the same value as `externalUserId`. Without one, no watch starts.
3. **Add the background location setup to your app** ([React Native](https://trust.myaza.co/documentation/sdk-react-native/markdown#background-monitoring-os-geofencing), [Flutter](https://trust.myaza.co/documentation/sdk-flutter/markdown#background-monitoring-native-geofencing)). Background monitoring is **on by default**: after the flow is submitted the SDK asks for the "always" location permission and arms the geofence itself (the Flutter SDK does this only in versions after 3.5.0), and from then on the phone reports whenever the person is at the address, even with your app closed. It can only do that if your app declares background location (and, on React Native, calls `registerBackgroundPresence()` at startup). Without that setup the SDK skips the step and nothing is asked. To turn it off for a workflow, switch off **Background monitoring** on the Address step's Presence tab.
4. **Call the presence reporter every time your app opens or comes back to the foreground**, for as long as the watch runs. This covers people who refuse the "always" permission, and phones whose battery saver stops background work. The code is under [Reporting presence from your app](#reporting-presence-from-your-app). From React Native SDK 3.5.0 and Flutter SDK 3.6.0 a report also switches background monitoring on for a person whose check is running without it (someone who verified on an older version): they see the system's "allow all the time" prompt once. Pass `autoBackground: false` to ask at a moment you choose.
5. **Subscribe to the evening reminder and turn it into a push notification**, if people do not open your app every day. See [If your app is not opened often](#if-your-app-is-not-opened-often).
6. **Check it is working.** After a day or two, [`GET /api/kyc/address/presence/:externalUserId`](#knowing-a-check-is-running) or the Presence monitoring page in the dashboard should show days going up. Days stuck at 0 mean no report is arriving, and the reporter's `reason` tells you why. See [If days stay at 0](#if-days-stay-at-0).
**How much evidence a watch needs.** With the default policy a watch verifies on 3 nights or 5 days at the address within 10 days. Background monitoring counts a full night or day for every stay of 30 minutes or more, with nothing asked of the person. A report from your app counts a little over half of one (0.6), because it shows a moment rather than a stay, so with the reporter alone the person has to open your app at home on **5 different evenings** (between 20:00 and 06:00, phone time) or on **9 different days** within the window.
Evidence arrives two ways, weighted strongest first: OS geofence dwell (background monitoring, on by default, weight 1.0) and **foreground one-shot checks** (your app calls the SDK's presence reporter when it opens, no background permission needed, weight 0.6). Both are evaluated on the device. There is deliberately no permissionless network tier: a country-level IP cannot evidence an address, so someone who declines location entirely is unverifiable by presence, and Proof of Address is their honest route.
**Where a watch can start.** Evidence only ever comes from a phone, so a watch is started only when one could report against it: the flow ran in the **React Native or Flutter SDK**, you passed your user reference as `userId`, and the subject is a person rather than a business. A flow that runs in a browser (a hosted link or the web SDK), carries no user reference, or verifies a business starts no watch, and the result's `addressIntel.presence` says so with `arranged: false` and an `unavailableReason` (`browser_flow`, `unknown_client`, `no_user_reference` or `business_subject`). The browser flow also does not tell the applicant a check is running.
**Privacy is structural**: the phone evaluates the geofence on-device against the pin it stored at capture, and only the derived record (calendar day + dwell + a night flag) is ever transmitted. Raw location traces never leave the device, and the server stores per-day aggregates only.
### Knowing a check is running
A presence watch takes days to resolve, so you are told when it starts as well as when it ends. **`entity.address_presence_started`** fires once, the moment the watch is minted, whether that was a passing verification, your own `POST /api/identity/entities/:externalUserId/address/verify` call, the dashboard, a re-attestation, or the next cycle of an always-on chain (`startedVia` says which). It carries the `watchId`, your `externalUserId`, the `policy` the watch has to clear (`windowDays`, `minNights`, `minDays`) and the `deadlineAt` it must decide by, so you can show a real progress bar rather than a bare "in progress". Like every presence payload it carries no pin and no coordinate.
Re-minting for a customer who is already being monitored returns the live watch and fires nothing, because no new monitoring began.
**There is deliberately no per-night progress webhook.** Observations are per-day aggregates by design, and pushing each one to your endpoint as it lands would turn a compliance check into a location feed about your customer. Progress is a pull instead, at whatever cadence you want it:
- `GET /api/identity/entities/:externalUserId/address` (secret key, your backend) returns the live `presence` block with `status`, `nights` and `days`.
- `GET /api/kyc/address/presence/:externalUserId` (publishable key, your app) returns the same state plus a 0..1 `progress.score`, for showing the person where their own check stands.
So: two pushes per watch, one when it starts and one when it decides, and a pull for everything between them.
While a watch is open, [`GET /api/kyc/status/:id`](https://trust.myaza.co/documentation/api-verification-status/markdown) also reports `waitingOn: "address_presence"` on the verification it belongs to, so a row that is still `processing` says why rather than looking stuck.
### Holding approval until the address is confirmed
By default a verification is decided on the instant capture and can be approved while the watch that was meant to confirm the address is still running. **Hold approval until the address is confirmed**, on the Presence tab of the Address Intelligence step, stops that: the decision waits for the watch, approves a confirmed address, declines caught spoofing, and sends an unresolved one to review.
The switch writes the Presence wait step into your decision graph, so it is the same thing as adding that step on the canvas and you can do either. Set how long the decision waits (up to 30 days); make it at least as long as the watch's own window, or the decision gives up before the evidence is in and everything routes to review. If your graph already routes on the presence result in its own way the switch leaves it alone and says so, because rewriting it could change decisions you wrote deliberately.
### Reporting presence from your app
The SDK sends the first report itself the moment the flow is submitted (when location is allowed), so the watch gets its first day, and its first night when the person submits at night. **Everything after that comes from this call.** Make it when your app starts and every time it returns to the foreground, for the signed-in user:
```tsx
// React Native: call this from your root component.
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native';
export function usePresenceReporting(userId: string | null) {
useEffect(() => {
if (!userId) return;
const report = () =>
reportAddressPresence({ apiKey: 'pk_live_…', externalUserId: userId });
report(); // the app has just opened
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') report(); // back in the foreground
});
return () => sub.remove();
}, [userId]);
}
```
```dart
// Flutter: mix this into your root widget's State.
class _AppState extends State with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_reportPresence(); // the app has just opened
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _reportPresence(); // back in the foreground
}
Future _reportPresence() async {
final userId = currentUserId; // your signed-in user's reference
if (userId == null) return;
await MyazaAddressPresence.report(apiKey: 'pk_live_…', externalUserId: userId);
}
}
```
Use the **same value** you passed as `userId` when the flow ran, and your **publishable** key. It is safe to call on every open for every user: it never throws and never blocks, it returns straight away (without touching location) for a user this phone holds no address for, and a phone only holds one for users who ran a presence flow on it. Several reports on the same day count once. Calling it again yourself when the flow submits is harmless too: the watch is created a few seconds after a submission is accepted, and a report from a pin captured in the last 15 minutes waits for it (up to 90 seconds) rather than arriving first.
Every call returns a `reason`. Log it: it is the quickest way to see why a watch is not filling.
| React Native | Flutter | What it means | What to do |
|---|---|---|---|
| `reported` | `reported` | The person was at the address; the day (and night, if it is between 20:00 and 06:00) was counted. | Nothing. |
| `outside_fence` | `outsideFence` | The person is somewhere else right now. Nothing is sent: being away is never held against anyone. | Nothing. Normal on most opens. |
| `no_pin` | `noPin` | This phone holds no address for this user. The flow ran on another device or in a browser, ran without this `userId`, or the stored address expired (45 days after capture, unless the watch is always-on). | Check the flow and this call use the same `userId`. |
| `no_watch` | `noWatch` | No check is running for this user: it has finished, or none was started. | Nothing. |
| `stopped` | `stopped` | Your organisation stopped monitoring this user. The SDK switched background location off and forgot the stored address. | Nothing. |
| `no_fix` | `noFix` | Location permission was refused, or the phone could not work out where it is. | Explain why you need location, then send the person to settings (`openLocationSettings()`). |
| `services_off` | `servicesOff` | The phone's location switch is off. | `openLocationSettings('services')`. |
| `network_error` | `networkError` | Myaza could not be reached. | Nothing. The next open tries again. |
#### If days stay at 0
Check these in order. The first one that applies is usually the cause.
1. **No `userId` on the flow.** No address is stored on the phone and no watch starts. The result's `addressIntel.presence.unavailableReason` reads `no_user_reference`.
2. **The flow ran in a browser** (a hosted link or the web SDK). No watch starts; the reason reads `browser_flow`.
3. **Your app does not declare background location**, or on React Native does not call `registerBackgroundPresence()` at startup. Only app-open reports can count.
4. **The person refused location, or location is switched off.** The reporter answers `no_fix` or `services_off`.
5. **Flutter SDK 3.5.0 or older.** On iPhone and on Android 11 and later, those versions never asked the person to allow location all the time, so background monitoring stayed off unless they chose it in Settings. Update the SDK; see [Flutter background monitoring](https://trust.myaza.co/documentation/sdk-flutter/markdown#background-monitoring-native-geofencing). From 3.6.0 the presence reporter asks people who verified on an older version, the next time your app opens.
6. **The stay has not been reported yet.** With background monitoring on, a stay is sent when the person leaves or at a check-in, the first one 35 minutes after they arrive. See [When background evidence is sent](#when-background-evidence-is-sent). Opening the app sends an app-open report at once.
7. **React Native: `expo-background-task` was installed without a rebuild.** The periodic check-in needs the package's native code in the app build. SDK 3.4.0 and older closed the app when background monitoring was switched on in that state; later versions skip the periodic check-in instead. Rebuild the app after installing it.
8. **No watch is running for that user.** The reporter answers `no_watch`, and the status endpoint answers `not_started`. That is also the answer for a user reference the server does not know, so check the reference matches the one the flow used.
Reports the server does not accept are dropped without an error, so the status endpoint and the reporter's `reason` are the two places to look.
The check runs on the phone: only the day and a night flag are sent, never a coordinate. A report made with a faked location is sent **flagged**, because evidence of spoofing is worth more to the watch than silence. Call `clearPresencePin(externalUserId)` when the person signs out.
### If your app is not opened often
With the reporter alone, a check only fills when the person opens your app at home, and a night only counts between 20:00 and 06:00 on their phone. Background monitoring removes that need, but some people refuse the "always" permission. For them, the way to get the app opened at the right moment is a push notification from you, and Myaza tells you when to send one.
**`entity.address_presence_reminder`** is sent each evening, between 20:00 and 22:00 at the person's own local time, for every running check that has no evidence for that night yet. It is sent at most once per check per evening. It is not sent when tonight is already counted, when the check has finished, or when background monitoring is already reporting for that phone. Myaza never contacts the person itself: we hold no push token, and a message from us would arrive from a name they do not know.
How to integrate it:
1. **Subscribe a webhook endpoint** to `entity.address_presence_reminder` (Developers, then Webhooks, in the dashboard, or the webhooks API).
2. **When it arrives, find your user by `externalUserId` and send them a push notification straight away.** The evening is when a report earns a night, so do not queue it for later. Honour the person's notification settings; if they have turned your notifications off, skip it. For example:
- Title: "Confirm your address"
- Body: "Open the app while you're at home tonight to finish checking your address."
3. **Nothing else is needed on the device.** Tapping the notification opens your app, the reporter you call on app open (step 4 above) runs, and if the person is at home that night is counted. You do not need to pass anything from the notification into the SDK. If you deep link somewhere, make sure the reporter still runs on that path.
4. **Test it in sandbox.** Outside production a reminder is sent once, a few minutes after a check starts, whatever the time of day, so you can see it arrive and build against it. On your webhook's page in the dashboard, **Send test event** delivers a sample.
The payload carries ids, progress and the deadline, never a location:
```json
{
"event": "entity.address_presence_reminder",
"data": {
"watchId": "aw_…",
"entityId": "ent_…",
"externalUserId": "user_42",
"localDay": "2026-10-01",
"reason": "no_evidence_tonight",
"progress": { "nights": 1, "days": 2 },
"policy": { "windowDays": 10, "minNights": 3, "minDays": 5 },
"deadlineAt": "2026-10-08T09:14:02.000Z",
"alwaysOn": false,
"environment": "PRODUCTION"
}
}
```
`localDay` is the person's own calendar day the reminder is for. `progress` counts the nights and days already observed, against the `policy` the check has to meet by `deadlineAt`. The person's local time comes from their phone at the time of the flow, or from the address when the phone did not say.
Two more levers, both on the Address step's Presence tab: a **longer window** (up to 30 days) gives more chances to catch the occasional evening, and **fewer nights required** lowers the bar, at the cost of weaker evidence. For someone who will not share location at all, [Proof of Address](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) is the honest route.
### Holding the decision on it
Add the **Presence** wait step in the decision graph (or start from the `presence-verified` template): the run parks until the watch resolves, then `address.presenceStatus`, `address.presenceNights` and `address.presenceDays` route the outcome. The wait skips cleanly when no watch was minted, so it is safe on mixed traffic. You can also drive it server-to-server: `POST /api/identity/entities/:externalUserId/address/verify` mints a watch for an existing entity, `…/address/revoke` withdraws it, and the `entity.address_verified` / `entity.address_presence_failed` webhooks carry the resolution.
Both resolution webhooks say which evidence produced the verdict:
```json
"evidence": {
"tier": "background",
"background": { "days": 4, "nights": 3 },
"foreground": { "days": 1, "nights": 0 },
"flaggedDays": 0,
"backgroundMeetsPolicy": true
}
```
Each day and night is credited to the strongest evidence seen that day, so the two sources never count the same day twice. `tier` is the strongest evidence that credited any day (`background`, `foreground`, or `null` when nothing qualified). `flaggedDays` counts days reported with a faked location, which is what a `failed` verdict rests on. `backgroundMeetsPolicy` answers the question the counts alone cannot: whether the background evidence on its own met the check's bar. If your risk policy only accepts addresses confirmed with the app closed, require it to be `true`; if app-open evidence is enough for you, ignore it.
Sandbox fast-forwards the whole lifecycle: outside production the window runs in **minutes**, and the persona tokens `presence_verified`, `presence_fail` and `presence_inconclusive` on `externalUserId` produce canned streams through the real scorer. A plain user gets no canned stream, so you can drive your own reporter calls end to end, free.
## Book triage
Before anything is deployed in your app, you can grade your **existing book**: upload a CSV (an `address` column, plus optional `customer_id`, `name`, `country`) at Compliance → Address triage in the dashboard, and each row comes back as `corroborated` (the evidence already held agrees on a locality — nothing to do), `confirmable_in_app` (a customer relationship exists, so a short re-capture flow confirms it: send a step-up link, or let the address step run on their next verification; there is no capture to reuse, so it is a short flow rather than one tap), or `needs_presence` (presence verification is the route to evidence). The distribution chart is the remediation plan; the CSV export carries every row. Metered per row via the `address_triage` component; runs of up to 5,000 rows.
## Stability, re-attestation and the certificate
Once presence data flows, three more surfaces build on it:
- **Stability attestation** — `GET /api/identity/entities/:externalUserId/address` answers the tier, months of tenure at the current address, a `strong`/`moderate`/`weak` stability grade, and the move count over 24 months. An origination-time pricing input.
- **Re-attestation** — `POST …/address/reattest` runs a short-window watch that answers "still at this address: yes / no / unknown". Address-status answers only, under the consent taken at onboarding; never live whereabouts. Billed on resolution like any watch.
- **Navigation packet** — `GET …/address/packet` returns the pin, the entrance-photo URL and the directions your flow collected: your own data, packaged for a delivery or recovery team.
- **Verifiable certificate** — the dashboard's entity Address card issues a PDF certificate whose QR resolves to a public, tokenised status page. The page answers the tier and confirmation dates **only** — never the address, the name, or a coordinate — and the underlying events sit on a tamper-evident, hash-chained audit log.
One privacy rule worth knowing: the pin is **never** used to link or merge identities, and same-pin density only ever flags for review. Coordinates stay your customer's collected data; they ride your webhook and your dashboard, and nothing else.
### Address-only workflows (standalone, no identity step)
Address Intelligence also runs **without any identity verification**, for books
whose KYC already happened elsewhere (imports, a previous partner). Set
`scope: "address"` on a workflow (or start from the **Address-only
verification** template) and the flow becomes: consent, optional contact
checks, the address evidence you asked for, done. No ID step, no biometrics;
publish rejects every identity-evidence field on such a workflow, and the
consent screen stops claiming captures that will never happen.
**You choose how the address is verified.** There are two honest ways and the
workflow picks either or both:
| On the rail | What the applicant does | What you get |
|---|---|---|
| Address step | Drops a pin on their home, optionally a door photo and directions | The pin, its tier, corroboration against the evidence you hold, and presence monitoring if you want it |
| Proof of address | Uploads a utility bill, bank statement or similar | The document read, its name match and recency, and the address extracted from it |
Turning one off is a switch on the rail, and the builder locks whichever is the
last one left on: a flow that verifies neither would verify nothing, so that is
the only combination publish refuses. A document-only flow never opens a map,
and a pin-only flow never asks for a document.
Bill accordingly: each step is priced on its own, so you are never charged for
the one you did not turn on.
Because there is no ID to name a country, the applicant declares their own,
and the flow corrects itself as evidence arrives. It opens on the country the
visitor's IP suggests, which is a guess and treated as one: the moment the pin
resolves to a country, that replaces the guess, and picking a search result
replaces even an explicit choice, because it is the newest and most specific
thing the applicant has said. An accepted-country list on the Proof of Address
step bounds all of it, so the flow never defaults to a market you do not take.
Country search is **biased**, never restricted: a wrong guess must not make the
right address unfindable.
Submissions carry `idType: "address"` (the workflow is required, exactly as
KYB flows require theirs) and bill only the address-side components:
`address_collection`, plus proof of address, contact OTPs, the questionnaire
and device intelligence where enabled. The row completes with the
corroboration verdicts and tier on `addressIntel` (also returned by
`GET /api/kyc/verifications/:id`), and never fails on a corroboration gap; the
decision layer is where you branch, including holding on the presence watch.
The linking contract is the important part for an existing book: mint a
session per customer (`POST /api/kyc/sessions` with their `externalUserId`,
or share the hosted link) and the capture lands on **the entity you already
imported**, writing the pin and nothing else. An `EXTERNAL_VERIFIED`
provenance, the identity link and the profile your partner's KYC established
all survive untouched; a customer with no existing record gets a plain
`UNVERIFIED` entity, which is exactly what it is. Presence verification,
always-on monitoring, certificates and triage then work exactly as below.
When capture happens on a hosted web link but presence should run in your own
app, hand the SDK the pin yourself: `savePresencePin(externalUserId, { lat,
lng })` (React Native) / `savePresencePin(externalUserId, lat, lng)` (Flutter)
is the org-side handoff that arms the foreground and background tiers.
### Always-on monitoring
Set `presence.alwaysOn: true` on the workflow's address step and presence verification stops being a one-off: each resolved check automatically starts the next cycle, so the address keeps being confirmed month after month. Coverage is billed **per entity per year**, priced by cadence: the first presence check bills on its own resolution as usual, and every renewal cycle after it is covered by the year charge. `presence.cadenceDays` picks from a closed menu — monthly (`30`), every two months (`60`), quarterly (`90`, the default), every six months (`180`) or yearly (`365`) — each with its own rate-card row (`address_monitoring_30` through `address_monitoring_365`), so a monthly book pays more per year than a yearly one. There is deliberately no back-to-back mode. In year one a monitored entity costs the capture plus the first check plus the year of coverage (quarterly example: ₦48 + ₦240 + ₦800, about ₦1,088 all-in); every year after is the coverage fee alone, and only the FIRST cycle ever bills the per-check price, so presence and monitoring never charge twice for one piece of work. Per-day observation rows are retained for 365 days after a watch resolves and then pruned; the watch's counters, verdict and score remain the durable record. Results arrive through the same `entity.address_verified` and `entity.address_presence_failed` webhooks, so "this person is no longer staying at the address" arrives as an ordinary resolution with `verdict: "inconclusive"`. A short balance pauses the chain at its next renewal and it resumes automatically after a top-up. Monitoring stops when you stop it (see [Stopping monitoring](#stopping-monitoring)), when a check fails on tampered evidence, or when the entity leaves your book. On mobile, pair it with the background tier and the OS confirms residency with no further user action. React Native: install `expo-task-manager`, set the config plugin's `location: "always"`, call `registerBackgroundPresence()` at your app root and `enableBackgroundPresence()` after capture. Flutter: declare the background-location entries in your own manifest and Info.plist, then call `MyazaBackgroundPresence.enable()`; the plugin's native side survives app death, and on Android a reboot too. Declaring background location changes your app's store review posture, which is why it is opt-in at every layer on both platforms; the [Background Location Declarations](https://trust.myaza.co/documentation/background-location-declarations/markdown) page carries the ready-to-paste Play Console and App Review texts.
#### When background evidence is sent
The background tier also records a stay **while the person is still there**, not only when they leave. The first time is 35 minutes after the person arrives, and after that once every three hours (SDK versions before this change waited three hours for the first one too). At those points any confirmed reading inside the circle records the stay so far and starts a new one: an app open with `reportAddressPresence()` / `MyazaAddressPresence.report()`, each reading of the Android foreground service, a background job on Android (Flutter, built in: about 40 minutes after an arrival, then every couple of hours; React Native, with the optional `expo-background-task`: about every 40 minutes), each iOS launch, and iOS background refresh when you opt in (see each SDK's README). The phone's operating system decides when a background job actually runs, so these are "not before" times.
**Nothing is sent while a stay is only running.** Background evidence reaches the server when the person leaves the circle or at one of those check-ins, never in between. A person who verified ten minutes ago and has not moved shows 0 days, correctly. To tell them so, read the open stay on the phone with `presenceStay(externalUserId)` (React Native) or `presenceStay()` (Flutter): it returns when they arrived and the earliest time the next report can go out. Someone who hardly leaves home is therefore still credited day by day, and one exit the phone failed to report can no longer cost more than the hours since the last check-in. None of this needs a permission beyond the background location the tier already holds.
### Knowing which tier is running
### Stopping monitoring
Your team can stop monitoring a customer at any time; the customer cannot stop it themselves. In the dashboard, open **Risk Intelligence → Presence checks**, tick the customers (or open one) and choose **Stop monitoring**. From your backend, call `POST /api/identity/entities/:externalUserId/address/revoke` with an optional `{ "reason": "…" }`.
A stop does two things: the check that is running ends without a result (its status becomes `revoked` and it is not billed), and always-on monitoring stops renewing, including when it was waiting between checks. Results already recorded stay as they are, and a year of always-on cover already paid for is not refunded. There is no resume: start a new check from the customer's Address card or with `…/address/verify`.
You receive `entity.address_presence_stopped`, and the phone learns the next time your app opens: the status endpoint answers `stopped: true`, and the presence reporter then switches off background location for that customer, forgets the stored pin and returns the reason `stopped` instead of reporting. Myaza support can also stop monitoring on your behalf; that appears in your audit log with their reason.
A person can revoke the location permission in Settings and nothing tells your app. Both SDKs expose `presenceStatus(externalUserId)`, which answers with the tier that is ACTUALLY running (`background`, `foreground` or `none`), the stored-pin state, the phone's location-services toggle (`locationServicesEnabled`: off, nothing can run whatever the permissions say, and the reporter answers `services_off`), both permission states, whether the geofence is armed and whether the Android foreground service is running; `openLocationSettings()` is the recovery path, since neither OS allows re-prompting in-app after a denial, and on Android `openLocationSettings('services')` opens the toggle itself. The dashboard's presence table shows the same fact from the server's side: each watch carries an evidence-tier chip derived from the sources its observations actually arrived on, so a watch quietly running on app-open moments alone, with no geofence reporting, is visible at a glance.
### Showing the person where the check stands
Somebody kept from a feature until their address is verified should be able to see the progress in your app, and a webhook relayed through your backend is the long way round. `GET /api/kyc/address/presence/:externalUserId` (publishable key, handoff-capable) answers:
```json
{
"status": "in_progress",
"watchId": "aw_…",
"startedAt": "2026-09-03T09:12:00.000Z",
"deadlineAt": "2026-09-13T09:12:00.000Z",
"decidedAt": null,
"progress": { "score": 0.6, "nightsObserved": 3, "daysObserved": 4 },
"tier": "foreground",
"alwaysOn": false,
"nextCycleAt": null
}
```
`status` is one of `not_started`, `in_progress`, `verified`, `failed`, `inconclusive`, `expired` or `revoked` (add-only). `stopped` is `true` when your team stopped monitoring and nothing has started since (see [Stopping monitoring](#stopping-monitoring)). `progress.score` is the completion of the watch's bar on **weighted** evidence: five foreground nights and three geofence nights both read `1`, so a foreground-only phone never looks further along than the scorer will credit it; the raw counts ride beside it for context. `tier` is what is actually feeding the watch. The endpoint is enumeration-safe (an unknown user, a business entity and a user with no watch all answer the identical `not_started` shape) and PII-light by construction: never the pin, the address or a coordinate. Poll it on app open, and send a notification of your own when it turns `verified` to bring the person back.
### The Android foreground service
A geofence alone is not reliable on Android once a manufacturer's battery manager decides an app is idle: transitions are dropped, nothing says so, and the watch quietly lapses to inconclusive. The phones on that list (Tecno, Infinix, itel, Xiaomi, Oppo, Vivo) are the ones the Nigerian and Kenyan markets carry. A foreground service, with its persistent notification, is the one thing those managers leave alone, and it is what OkHi's own integration guidance recommends for the same markets.
Both mobile SDKs offer it as a third opt-in beside the geofence: React Native `enableForegroundService({ …, notification: { title, body } })` (the `location: "always"` plugin setting declares the two permissions it needs) and Flutter `MyazaPresenceService.enable(…, notification: PresenceNotification(…))` (you declare the service and its permissions in your own manifest; the [Background Location Declarations](https://trust.myaza.co/documentation/background-location-declarations/markdown) page has the block). While it runs, a low-power fix every ten minutes is turned into the same enter/exit spans the geofence folds, on the same stored state, so the two never double-count a stay; the queue flushes while the process is alive; a fence the OS dropped is re-armed; and on Android a reboot restarts it. Android only: iOS region monitoring is reliable on its own, and continuous background updates there would be the heavier posture for nothing. The notification is yours to word, and it is on screen for days, so word it honestly.
---
# Background Location Declarations
Turning on the background presence tier means your app declares background location, and both app stores review that declaration. This page is the kit: the framing, the text to paste, the video to record and what to do if a reviewer pushes back.
You only need any of it if you call `enableBackgroundPresence()` (React Native) or `MyazaBackgroundPresence.enable()` (Flutter). The SDK never adds background location to your app on its own. Without it, presence verification still runs on app-open readings (see [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification)), and nothing on this page applies.
## Frame it around your feature, not around verification
Reviewers approve background location when it powers something the **user** gets from **your app**, and when the app would not work properly without it. "We use a verification SDK" is not a reason. "You cannot open a full account with us until your home address is confirmed, and confirming it takes several days" is.
So before you write anything, fill in three blanks. Every text below uses them:
| Placeholder | What to write | Example (a digital bank) |
|---|---|---|
| `{{App}}` | Your app's name as it appears in the store | Acme Money |
| `{{Feature}}` | The feature the user unlocks or keeps by confirming their address | Full account (higher limits) |
| `{{Why}}` | Why your business must confirm the address before giving them that feature | Our banking licence requires a verified residential address before we can lift account limits |
Pick the row closest to your business:
| Your business | `{{Feature}}` | `{{Why}}` |
|---|---|---|
| Bank, wallet or neobank | Full account, higher transaction limits, without an agent visiting your home | A verified residential address is a condition of our licence for full accounts (in Nigeria, the CBN's Tier 3) |
| Savings, investment or crypto exchange | Withdrawals above the basic limit | Our regulator requires a verified address before higher withdrawals |
| Insurance | Policy issue | The insured address must be confirmed before cover starts |
| Merchant or agent onboarding | Merchant account activation | We confirm a merchant's premises before they can take payments |
**Personal-loan apps cannot use this on Google Play.** Play's Financial Services policy forbids personal-loan, earned-wage-access and loan lead-generation apps from requesting precise location (`ACCESS_FINE_LOCATION`), and geofencing needs it. If your Play listing is a personal-loan app, use [Proof of Address](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) instead. The same limit affects the address step's own location fix, so talk to us before enabling address capture in such an app.
Say what is actually true for your business. If a verified address is a regulatory requirement for you, name it: reviewers treat a legal obligation that the user benefits from (a working account) very differently from a nice-to-have.
### The questions the reviewer is answering
Google tells its reviewers to ask these. Your texts and video should answer each one without the reviewer having to guess:
1. **Is background location important to the app's core functionality?** Yes: without it the customer cannot get {{Feature}} in the app.
2. **Does it deliver clear value to the user?** They get {{Feature}}, confirmed from their phone instead of by a field agent or a paper bill.
3. **Would users expect it?** They started the address confirmation themselves, after a screen explaining it.
4. **Could the app do the same in the foreground?** No. Living somewhere can only be shown over several nights, when the app is closed; a single check while the app is open proves only that the person visited once.
## What the SDK actually does
Everything you declare must match this. Reviewers test apps, and a declaration that under-describes what the app does is the fastest way to have it removed.
- With the user's consent, the app registers **one geofence** around the address the user placed on a map.
- The operating system wakes the app when the phone enters or leaves that area.
- From time to time (no more often than about every 40 minutes), and whenever the user opens the app, the SDK takes **one low-power location reading** to confirm whether the phone is still at the address. It is used only for that check.
- On the device, the SDK works out which days the user was at the address and whether they were there at night. Only those daily summaries are sent (a date, minutes present, night yes or no). Coordinates are never sent after the address is captured, and no location history is kept anywhere.
- Checking stops when the address is confirmed (or, for ongoing monitoring, when your organisation stops it), when the user withdraws consent, or when they turn location off.
Fill every form truthfully. A declaration that does not match what the app does can get the app removed, and repeated violations can get a developer account banned.
## Give people a switch
Both stores look for a way to stop background location from inside your app, and saying you have one makes the declaration stronger. Put a switch where the user manages their account (for example "Address confirmation: On") and call `disableBackgroundPresence()` (React Native) or `MyazaBackgroundPresence.disable()` (Flutter) when they turn it off. Turning it back on calls the matching enable function. The texts below mention the switch; remove that sentence if you do not add one.
## Google Play
### 1. The in-app disclosure (required before the permission prompt)
Play requires a disclosure inside your app, shown **before** the system asks for "Allow all the time", in normal use (not only in your privacy policy). It must say that the app collects location, that this happens in the background, and what for.
The SDK's address screens already carry a disclosure ("How it works": *your phone confirms it is at this address over the coming days, even when the app is closed. Only day-level summaries ever leave your phone, never your movements*). Keep those screens. Because the disclosure must be **your app's** statement, also show this sentence, in your app, on the screen where the user chooses to confirm their address:
> {{App}} collects location data to confirm your home address for {{Feature}}, even when the app is closed or not in use. Only daily summaries are used, never your movements, and checking stops once your address is confirmed.
Give it one button that continues ("Continue") and one that does not ("Not now"). "Not now" must leave the user in a working app, ideally with another way to confirm their address. Google's rules for this screen: it must be in the app itself (not the store listing or your website), it must not look like a system dialog, it must be readable by a teenager, and the accept button should not be a vague "Got it" or "OK".
### 2. Location permissions declaration
Play Console: **App content → Sensitive app permissions → Location permissions**. The form asks for three things.
**App purpose** (one or two sentences about your app as a whole):
> {{App}} is [what your app is, for example "a digital bank for everyday payments and savings"]. Customers confirm their home address in the app to get {{Feature}}.
**One feature that needs location in the background** (describe one feature only):
> Address confirmation for {{Feature}}. {{Why}}. With the customer's explicit in-app consent, the app registers a single geofence around the address the customer placed on a map. Based on when the phone enters and leaves that area over several days, and an occasional low-power location reading (no more often than about every 40 minutes, and when the app is opened) that only checks whether the phone is still at that one address, the app confirms that the customer lives there. Location is evaluated on the device: the app sends only daily summaries (date, minutes present, whether night hours were included). No location history is collected, coordinates are not transmitted after the address is captured, and checking stops when the address is confirmed. Customers can turn address confirmation off at any time with a switch in the app. Background access is required because living at an address can only be shown over several days and nights, most of which the app is not open for.
**Video:** the link from step 3.
If the form asks whether this is core functionality, answer yes: without it the customer cannot get {{Feature}} through the app.
### 3. The video (30 seconds)
Record on a real Android phone, portrait, with no editing beyond trimming. Keep it to 30 seconds or less. Upload it where the reviewer can open it without signing in (an unlisted YouTube link is preferred; a shared Google Drive MP4 is also accepted) and paste the link into the form.
| Time | What the screen shows | What to do |
|---|---|---|
| 0 to 4 s | Your app's home screen, then the {{Feature}} entry point | Tap into {{Feature}} ("Upgrade account", "Apply for a loan") |
| 4 to 9 s | Your disclosure sentence from step 1 | Pause so it can be read, then tap Continue |
| 9 to 13 s | The SDK's "Let's confirm your address" screen and its "How it works" panel | Open "How it works" so the text is visible |
| 13 to 18 s | The map with the pin | Place the pin, confirm the address |
| 18 to 23 s | The system prompt, then "Allow all the time" in the permission screen | Choose "Allow all the time" |
| 23 to 27 s | The success screen: the address check is running | Hold on it |
| 27 to 30 s | Your "Address confirmation" switch | Show that it can be turned off |
The three things the reviewer is looking for, in this order: the disclosure, the permission prompt, and the feature that uses it.
### 4. Data safety form
- **Location, precise:** collected; not shared (Myaza processes it only on your behalf, which Google Play does not count as sharing).
- **Purposes:** App functionality; Fraud prevention, security and compliance.
- **Optional:** Yes, if a user who declines can still use the rest of your app.
- **Encrypted in transit:** Yes.
### 5. The Android foreground service (only if you turned it on)
If you also call `enableForegroundService()` (React Native) or `MyazaPresenceService.enable()` (Flutter), your app runs a location foreground service with a persistent notification, and from Android 14 Play asks for a second declaration (**App content → Foreground service permissions**, type Location).
When the form asks for the use case, choose user-initiated location sharing: the customer started the address confirmation themselves.
Most apps should not turn this on. It exists for phones whose battery managers stop background work, and the periodic check-in above now covers much of that without it. Google has also announced that **geofencing stops being an accepted use of a location foreground service from 27 January 2027**, so expect this tier to be retired before then. If you do use it:
```xml
```
> {{App}} runs a location foreground service, with a visible notification, only while an address confirmation the customer consented to is in progress for {{Feature}}. It takes a low-power location reading every few minutes, checks on the device whether the phone is at the one address the customer confirmed, and sends daily summaries only (date, minutes present, whether night hours were included). It stops when the address is confirmed, when the customer withdraws consent, or when the check ends.
## Apple App Store
Apple has no declaration form. Review reads your purpose strings, your App Review notes and your privacy label.
**Purpose strings** (state the purpose, not the mechanism):
```xml
NSLocationWhenInUseUsageDescriptionUsed to confirm your home address for {{Feature}}.NSLocationAlwaysAndWhenInUseUsageDescriptionLets {{App}} confirm your home address over the next few days, even when the app is closed. Only daily summaries are used, never your movements.
```
If you opt into the periodic iOS check-in, also add `co.myazahq.kyc.presence.checkin` under `BGTaskSchedulerPermittedIdentifiers` and `fetch` under `UIBackgroundModes`. Do not add the `location` background mode: region monitoring does not need it, and it invites questions.
Apple asks for no capability beyond these for region monitoring. Some vendors' guides ask for the "Location updates" background mode as well; this SDK does not use continuous location updates, so leave it off.
**App Store description** (optional, one line near the end of your description):
> {{App}} asks for location access "Always" only to confirm your home address for {{Feature}}: it checks whether your phone is at that one address over a few days, and you can turn it off at any time.
**App Review notes:**
> {{App}} confirms a customer's residential address before giving them {{Feature}}. {{Why}}. The "Always" location permission is used for one purpose: with the customer's consent, one region is monitored around the address they placed on a map, and the app occasionally confirms whether the phone is inside it. Only daily summaries (date, minutes present, night yes or no) leave the device. There is no location history and no tracking of where the customer goes, and the customer can turn it off with a switch in the app. To test: sign in with [demo account], open [path to {{Feature}}], and the consent screen appears before any permission prompt.
Apple also asks apps to offer an alternative to people who decline. Keep another route open for them: [Proof of Address](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) in the same workflow, or the app-open check-ins, which need only while-in-use location.
**Privacy label:** Location, Precise; used for App Functionality (and Fraud Prevention if you list it); linked to the user; not used for tracking.
## If you are rejected
| What the reviewer says | What it usually means | What to do |
|---|---|---|
| "Not core functionality" | The declaration reads as a vendor feature, not yours | Rewrite around `{{Feature}}` and `{{Why}}`; show in the video that the user cannot get {{Feature}} without it |
| "Prominent disclosure missing" | Your disclosure appears after the prompt, or only in the privacy policy | Show your disclosure sentence before the prompt, in the video, with Continue and Not now |
| "Video does not show the feature" | The video stops at the permission prompt | End on the success screen showing the check running |
| "Declaration does not match app behaviour" | Something runs that you did not mention (often the foreground service) | Declare it, or turn it off |
| "Foreground location would be enough" | The texts do not explain why several days matter | Add question 4's answer above to the feature text and say it in the video |
| Rejected although you removed the feature | An older build on another track (internal, closed, open testing) still has the permission | Remove the permission from every track, or declare it for all of them |
Reply to the rejection with the corrected text and a new video link rather than resubmitting silently. If background location is still refused, you lose nothing essential: presence verification carries on from app-open readings, it just takes more opens to finish, and [Proof of Address](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) remains available.
## Checklist
- [ ] `{{App}}`, `{{Feature}}` and `{{Why}}` filled in and true for your business
- [ ] Your disclosure sentence shows before the permission prompt, with a working "Not now"
- [ ] An in-app switch turns address confirmation off (and the texts mention it)
- [ ] Android manifest has `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` and `ACCESS_BACKGROUND_LOCATION` (React Native: `{ "location": "always" }` in the config plugin adds them)
- [ ] Play: location declaration text, 30-second video link, data safety form
- [ ] Your privacy policy is linked both in Play Console and inside the app, and mentions location for address confirmation
- [ ] Your Play listing is not a personal-loan app (see the note under the business table)
- [ ] Foreground service: off, or declared with its own form
- [ ] iOS: both purpose strings, App Review notes with a demo path, privacy label
---
# 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. |
| `session.cancelled` | You (or Myaza support) cancelled a session before it was submitted. The applicant cannot reopen it until it is uncancelled. |
| `session.uncancelled` | A cancelled session was uncancelled, so the applicant can carry on. |
| `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_presence_stopped` | Somebody stopped presence monitoring for a customer: your team (dashboard or API) or Myaza support, never the customer. Carries `wasMonitoring` (a running check was ended; `false` means always-on monitoring between checks was ended and its last result stands), `alwaysOn`, `stoppedBy` (`user`, `api_key` or `platform`), `stoppedAt` and the progress so far. The reason is never included. |
| `entity.address_presence_reminder` | An evening reminder, sent between 20:00 and 22:00 at the person's local time, for a running presence check with no evidence for tonight yet. At most once per check per evening. Turn it into a push notification so the person opens your app at home. See [If your app is not opened often](https://trust.myaza.co/documentation/address-intelligence/markdown#if-your-app-is-not-opened-often). |
| `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 they answer different questions. The full explanation, with every combination, is in [Status and checkStatus](https://trust.myaza.co/documentation/verifications/markdown#status-and-checkstatus). In short:
**`status`** is the outcome: should this person be onboarded, rejected, or are we still waiting? Update your own record from it. It uses the same vocabulary as the dashboard and `GET /api/kyc/status/:id`.
| `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 automated checks found: `pending`, `verified`, `failed`, `not_found` or `error`. Your workflow's decision rules and your reviewers never change it. It only resets to `pending` when the applicant resubmits a verification that was sent back.
The two agree when the checks alone decided the outcome. They differ whenever your workflow or a reviewer decided something else, for example `processing` with `checkStatus: "verified"` while your workflow waits on a watchlist screen, or `declined` with `checkStatus: "verified"` when a workflow rule rejected someone whose checks passed.
### Why both
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.
### The status on lifecycle events
`verification.started`, `verification.completed`, `verification.failed`, `verification.not_found` and `verification.error` are sent the moment the checks finish (or start), **before your workflow has decided anything**. Their `status` therefore reflects the checks alone: a `verification.completed` says `approved` because the checks passed.
If your workflow has decision rules, that is not the final answer. The workflow's verdict follows as `verification.status_updated` with `source: "workflow"`, carrying the `status` to act on (`approved`, `declined` or `in_review`). Always take the latest `status` from `verification.status_updated`, not from the first event you received. Without decision rules, the checks are the decision, and the `status` on `verification.completed` stands unless a reviewer later changes 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. |
| `platform` | Myaza support approved or declined it on your behalf, usually at your request. |
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`, `workflow` or `platform`. 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), `workflow` (`id` is the workflow's id) or `platform` (Myaza support; `id` is null). |
| `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. `reviewedById` is null when the workflow or Myaza support 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. |
| `silentCapture1` to `silentCapture3` | Silent capture: up to three unposed photos of the applicant, taken while the camera was already on (a laptop's webcam during document capture, and while they positioned for the selfie). Present unless the workflow switches silent capture off, and withheld when the workflow's Presence Intelligence returned data leaves out silent capture photos. |
| `recordPhoto` | The government ID photo: the photo on the government record, or on the ID's chip, that the selfie was compared with. On a biometric re-authentication it is the reference photo the live selfie was matched against. Present when there is one. |
| `chipPortrait` | The photo read from the ID's NFC chip, present on single-ID verifications when the chip passed authentication. On a multi-ID run, a chip-only ID's photo arrives as its `idCheckPhoto`. |
| `supportingDocument` | The nth supporting document the workflow asked for (1-based, in the order of `supportingDocuments`). |
| `businessDocument` | The nth company document on a business verification (1-based, in the order of `business.documents`). |
Each entry in `supportingDocuments` and `business.documents` also carries `url`, the same link as its `supportingDocument` or `businessDocument` key, so you can fetch a document straight from its result. The entry's `mediaId` is an internal reference and cannot be used to fetch the file. Leaving out the Supporting documents step's **media** returned data withholds those files and sets each `url` to `null`; leaving out the business **documents** withholds the company documents and their files.
### Multi-ID verifications
When a workflow verifies [more than one ID in one run](https://trust.myaza.co/documentation/workflows/markdown), each ID is a check in `data.multiId.checks`, and each check's files are listed in `media` under its own number. The number starts at 1 and follows the order of `multiId.checks`, so the second check's files are the `idCheck2…` keys.
| Key | Description |
|---|---|
| `idCheckPhoto` | The photo on the government record for that ID. The selfie was compared with it. |
| `idCheckPortrait` | The portrait cut from that ID's document, present when the selfie was matched to the photo printed on it. |
| `idCheckFront` | Front of that ID's document. |
| `idCheckBack` | Back of that ID's document, when captured. |
| `idCheckFrontVideo` | Recording captured while scanning that document's front. |
| `idCheckBackVideo` | Recording captured while scanning that document's back. |
Each check also carries its own photos as URLs, so you can read them straight from the check. `multiId.checks` starts at 0 and the keys start at 1, so the first check's `recordPhoto` and `documentPortrait` hold the same URLs as `idCheck1Photo` and `idCheck1Portrait`. Either is `null` when the check has no such photo. A number-only ID (BVN, NIN) has no document, so when its record returned a photo it has only `idCheckPhoto`. `media.recordPhoto` is also present: it is the photo of the first ID that passed (or the first ID, when none passed).
```json
"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",
"recordPhoto": "https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/record-photo",
"idCheck1Photo": "https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/id-check-1-photo",
"idCheck2Photo": "https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/id-check-2-photo"
}
```
Your workflow's returned data (on the ID Verification step) controls these links. The photos (**Government ID photo**, **Chip photo** and **Photo from the ID card**) are sent by default and each can be unticked: unticking **Government ID photo** withholds `recordPhoto` and every `idCheckPhoto`, and so does leaving out the facial match. Where the government ID photo came from the chip (a verification, or one ID of a multi-ID run, completed from the chip alone), it is the same picture as the chip photo, so unticking either one, or leaving out the facial match, withholds both. Leaving out **Document images** withholds each check's documents and portrait. Leaving out **Multiple IDs breakdown** withholds every `idCheck` link along with `multiId`. The `verification.started` event carries none of the per-ID, company document or record photo links; they arrive on the result events. The same settings apply to the per-ID photo and supporting document links in the [verification result](https://trust.myaza.co/documentation/api-verification-result/markdown) (`GET /api/kyc/verifications/:id`). They control what we send: a media URL you already hold keeps working with your secret key.
## 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
## Wallet scenarios
Wallet screening in a customer sandbox uses Myaza Trust's deterministic scenarios. It never performs a live blockchain lookup and never creates billable wallet usage. In **Spot Checks → Wallet**, choose a scenario before screening, or send `sandboxScenario` to the [On-Demand Wallet Screening API](https://trust.myaza.co/documentation/api-wallet-screenings/markdown). The same published sandbox rules run against the simulated exposures, and sandbox cases, alerts and webhook deliveries use the sandbox environment.
Examples include `wallet.clean`, `wallet.low_risk`, `wallet.medium_risk`, `wallet.high_risk`, `wallet.critical`, `wallet.unknown`, `wallet.sanctions.direct`, `wallet.sanctions.1_hop`, `wallet.sanctions.2_hops`, `wallet.mixer.direct`, `wallet.mixer.1_hop`, `wallet.mixer.2_hops`, `wallet.stolen_funds`, `wallet.ransomware`, `wallet.darknet`, `wallet.scam`, `wallet.terrorist_financing`, `wallet.high_risk_exchange`, `wallet.high_risk_jurisdiction`, `wallet.p2p_exchange`, `provider.timeout` and `provider.unavailable`.
`wallet.invalid_address` and `wallet.unsupported_network` produce `422` validation errors before a screening or charge. `wallet.unknown` models a pending screening result and produces a review result. Use a format-valid address and hash with the chosen network; a scenario does not bypass input validation. Production rejects the scenario field with `422`.
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 |
| 25 | identity_suspended | failed | `identity_suspended` | all |
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.
Scenario 25 stands for a government record the issuer has suspended, such as a suspended NIN. It fails with `identity_suspended` and returns no biodata and no face result, as a live suspended record does.
> 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, `RC0000003` is found with no company
status (as CAC returns some new companies) and `RC0000004` is found but inactive. The
last two are for trying a workflow's company-status rules. 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"`, `"not_found"`, `"status_unknown"` or
`"status_inactive"`) 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. |
| **Supporting documents** | Extra documents you need on file, each with the title and guidance you write. A document can be uploaded, signed on screen, or either: choose under **How the applicant provides it**. A signature drawn on screen is kept as an image with the verification. Needs web SDK 3.6.0, React Native SDK 3.5.0 or Flutter SDK 3.6.0 for signing on screen. |
| **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, **on by default**: shared and recovered devices, emulators, rooted phones, app integrity (App Attest / Play Integrity), virtual cameras, VPN, hosting and Tor networks, time zone and expected-IP checks, and repeated attempts. Its Countries tab is an IP geofence and its Rules tab sets approve, review or decline per finding. Branch on `device.*` and `ip.*`. Register your apps under Settings, Mobile apps for app integrity. |
| **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. Each ID is charged for the checks run on it (its lookup, document read, chip read and face match), while the selfie, liveness and other shared checks are charged once. |
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.
## Selfie review
After the liveness capture the SDKs used to show the selfie with Retake and Continue. From web SDK 3.6.0, React Native SDK 3.5.0 and Flutter SDK 3.6.0 they hand straight on to the next step, because the capture already waits for a face that is in the frame and still. To bring the review back, switch on **Presence Intelligence → Show the selfie for review**. A selfie that fails to upload still shows the review, which is where the retry is.
## How the screen-flash check is re-checked
The 3D Flash Check shows a short, random sequence of colours on the screen and looks for each one reflected on the face. The phone reports what it saw, and we then read the recording ourselves on our servers, colour by colour, at the moments the phone says each colour was on.
For the best result ask people to hold the phone close, at about arm's length or nearer, and to step away from bright light such as a window or direct sun. In a bright room the screen's light can be too weak to show on the face. When that happens the phone asks the person to move and tries once more, and if our own reading of the recording is too faint to judge we record it as inconclusive. An inconclusive reading is never held against the person.
## When the liveness recording does not arrive
Every liveness check records a short video, which we re-check on our servers. If that recording never reaches us, or reaches us unreadable, the liveness check cannot be re-checked. That happens with some older phones and browsers, and it is also what a replayed video on a second phone tends to produce. Under **Presence Intelligence → Rules** choose what happens:
- **Review**, the default, keeps the result the checks produced, and a decision rule on `verification.livenessVideoMissing` sends it to a person to approve or decline. The tab offers to add that rule, and publishing warns you if no rule reads the field.
- **Decline** fails the verification with `reasonCode: liveness_video_missing` and asks the person to take the selfie again, usually on another phone or browser.
Over the API, set `livenessVideoMissing: "decline"` on the workflow config. The verification page shows why the recording is missing when the SDK could tell.
## 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.livenessVideoMissing` | `true` when the flow ran a liveness check, its recording did not arrive or could not be read, 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` (other ID numbers seen on the device), `device.matchedPeople` (other people seen on the device), `device.emulator`, `device.attempts24h` (attempts from this device in the last 24 hours), `device.recovered` (the same physical device back under a new install), `device.linkedDeclined` (other people declined for fraud on this device), `device.sessionDevices` (devices behind this session's photos and submission), `device.rooted`, `device.virtualCamera`, `device.attestation` (`verified`, `failed` or `unavailable`; null when the app did not attest), `ip.country`, `ip.type`, `ip.datacenter`, `ip.tor`, `ip.countryMismatch`, `ip.timezoneMismatch` and `ip.expectedMatch` (`exact`, `network`, `operator`, `country` or `different`; null when the session was created without an expected IP address).
What the workflow's own Device Intelligence settings decided, all null unless the verification passed: `device.review` (a finding the workflow sends to review, the flag to route on), `device.ruleResult` (the finding that decided: `clear`, `country_not_allowed`, `app_integrity_failed`, `declined_device`, `virtual_camera`, `emulator`, `rooted_device`, `tor_exit`, `multiple_devices`, `expected_ip_mismatch`, `privacy_network`, `timezone_mismatch`, `recovered_device`, `shared_device`, `repeated_attempts` or `ip_country_mismatch`) and `ip.countryAllowed` (whether the geofence allows the IP's country; null when geofencing is off or the IP has no known country). Setting a finding to Review in the builder adds the `device.review` rule for you.
**`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.
**`list.`**: whether the verification is on one of your [custom lists](https://trust.myaza.co/documentation/lists/markdown#custom-lists): `true`, `false`, or null when the verification has nothing of the list's type.
**`country.*`**: your [High-risk countries](https://trust.myaza.co/documentation/lists/markdown#high-risk-countries) list
`country.documentHighRisk`, `country.ipHighRisk`, `country.phoneHighRisk`, `country.anyHighRisk`. Null when the country is not known.
## 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.
---
# Lists
Lists are sets of values you keep in the dashboard under **Configure → Lists**. Some decline a verification automatically. Others do nothing until one of your [decision rules](https://trust.myaza.co/documentation/decisioning/markdown) asks about them.
| List type | Who creates it | What it does |
|---|---|---|
| Blocklist | Built in, one per entry type | A verification that matches an entry is declined |
| Allowlist | Built in (faces) | A matching face skips the duplicate-face rules |
| High-risk countries | Built in, yours to edit | Nothing by itself. Rules read it through the `country.*` fields |
| Custom | You | Nothing by itself. Rules read it through `list.` |
Lists are separate for each environment. A list you fill in Sandbox does not exist in Production.
Lists are managed in the dashboard. There is no public API for them yet.
## Custom lists
A custom list is a list you name and fill yourself. It holds **one type of entry**, and your workflow rules can ask whether a verification is on it. Typical uses:
- A list of staff email addresses, so internal test sign-ups go to review instead of approval.
- A list of user references you want checked by a person every time.
- A list of countries you serve, so anything else is sent to review.
- A list of office IP addresses, so sign-ups from your own network are easy to tell apart.
A custom list never declines or approves anything on its own. Until a rule reads it, it has no effect.
### Create a list
1. Open **Configure → Lists** and select **Create list**.
2. Give it a name and, if you like, a description.
3. Choose what it holds. This cannot be changed afterwards.
You need the `identity:manage` permission to create or change a list, and `identity:read` to see one.
You can keep up to 50 custom lists per environment, each with up to 10,000 entries.
### Entry types
| Entry type | What you add | What a rule compares it with |
|---|---|---|
| Email address | `name@example.com` | The email address the applicant confirmed with a code |
| Phone number | `+2348012345678`, with the country code | The phone number the applicant confirmed with a code |
| User | Your own user reference, such as `user_12345` | The `externalUserId` you send with the verification |
| Country | A country | The country of the ID, the IP address, or the confirmed phone number |
| IP address | A single IPv4 or IPv6 address | The IP address the verification was submitted from |
| Device | A device fingerprint | The device the verification was submitted from |
| ID number | An ID number, for one ID type | The number of any ID the verification used |
| Business | A registration number, for one country | The registration number of the business being verified |
A few details worth knowing:
- **Email addresses and phone numbers** are stored so they can be matched but not read back. The list shows each one partly hidden. To find one, type it in full.
- **Email and phone lists** only match a contact the applicant proved with a code. A typed, unconfirmed address never matches. Your workflow needs the email or phone verification step for these lists to be useful.
- **User references** are compared exactly as you send them, including upper and lower case.
- **ID numbers** ignore spaces, hyphens, dots and case. The same number on a different ID type is a different document.
- **IP addresses** are single addresses. Ranges are not supported.
- **Device fingerprints** are the ones shown on a verification's Device Intelligence card.
- When your backend submits verifications with a secret key, pass the applicant's connection as described in [Create a verification](https://trust.myaza.co/documentation/api-create-verification/markdown), or IP address and device lists will have nothing to compare.
### Add entries
Open the list and select **Add entries**. Countries are picked from a list. Everything else is typed or pasted, one value per line or separated by commas, up to 500 at a time.
- A value already on the list is left as it is.
- A value that is not valid for the list's type is not added. It stays in the box so you can correct it, and the rest are added.
- For ID numbers, choose the ID type first. For businesses, choose the country of the register first.
### Use a list in a rule
Every custom list has a **key**, made from its name when you create it: "VIP customers" becomes `vip_customers`. A rule reads the list through the field `list.`. The list's page shows the field and lets you copy it.
In the workflow builder, open **Decisioning**, add a rule, and choose the list from the **Lists** group. The field has three possible values:
| Value | Meaning |
|---|---|
| `true` | The verification is on the list |
| `false` | The verification has a value of the list's type, and it is not on the list |
| unknown | The verification has nothing of the list's type, for example no confirmed email. A rule on the field does not match |
For example, to send anyone on a list called "Manual review" to review:
```json
{ "field": "list.manual_review", "op": "eq", "value": true }
```
Because unknown never matches, a rule such as `list.served_countries` `eq` `false` only fires when a country is known and is not on the list. To also catch verifications with no known value, add a second condition using the `exists` operator.
### Rename or delete a list
You can rename a list or change its description at any time. The key does not change, so rules that read the list keep working.
A list cannot be deleted while a workflow rule reads it. Remove the rule from the workflow first, including from its unpublished draft, then delete the list. Deleting a list deletes its entries for good.
Publishing a workflow is refused if one of its rules reads a list that does not exist in that environment. Create the list there, or change the rule.
## High-risk countries
One custom list is built in: **High-risk countries**. It starts with the countries on the FATF call-for-action and increased-monitoring lists, and you can add or remove countries.
Rules read it through four fields:
| Field | True when |
|---|---|
| `country.documentHighRisk` | The verification's own country (the ID's, or the business register's) is on the list |
| `country.ipHighRisk` | The IP address is in a country on the list |
| `country.phoneHighRisk` | The confirmed phone number's country is on the list |
| `country.anyHighRisk` | Any of the three is on the list |
A country that is not known gives an unknown value, which never matches.
## Blocklists
Blocklists are built in, one for each entry type: face, ID number, IP address, user, phone number, email address, device and business. A verification that would otherwise pass and matches an entry is declined.
The applicant is told only that the verification could not be completed. The reason is never shown to them, because it would tell a fraudster what to change. Your webhook and the verification result carry a `reasonCode` naming the list that matched: `face_blocklisted`, `document_blocklisted`, `ip_blocklisted`, `user_blocklisted`, `phone_blocklisted`, `email_blocklisted`, `device_blocklisted` or `business_blocklisted`.
You can add an entry from **Configure → Lists**, or straight from a verification with **Add to blocklist**.
---
# SDKs
## Server SDK for Risk Intelligence
The `@myazahq/trust-sdk` server package is in development and is not yet
available on the public npm registry. For an integration you can run now, use
the [Risk Intelligence HTTP quickstart](https://trust.myaza.co/documentation/risk-intelligence-quickstart/markdown) with a
secret key on your backend. It covers customer registration and a transaction
decision. For an explicit check, use the [On-Demand Wallet Screening API](https://trust.myaza.co/documentation/api-wallet-screenings/markdown).
The unreleased server SDK source includes `myaza.riskIntelligence.walletScreenings.create()`,
`retrieve()` and `list()`, plus saved PDF downloads. Its legacy transaction-reference
methods screen a counterparty address at the Wallet Screening rate, not the
transaction hash. The SDK will forward requests to Myaza; risk scoring and
rules run on the server.
## 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 `