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.
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_hex> | HMAC-SHA256 of the raw body. Verify this. |
X-Myaza-Event | verification.completed | The event type. |
X-Myaza-Delivery | <delivery_id> | Unique per delivery; use it to deduplicate. |
User-Agent | Myaza-Webhooks/1.0 | Identifies the sender. |
Event types
| Event | When it fires |
|---|---|
verification.started | A verification was accepted and processing began. |
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 person approved a verification, declined it, or sent it back to the applicant. See Decisions by a person. |
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 decision run finished; carries the outcome (approve/decline/review). See Decisioning. |
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. |
entity.imported | An entity was created via the direct-create or bulk-import API. |
identity.resolved | An entity was linked to a global identity. |
event.flagged | A monitored event scored to a non-ALLOW decision. |
screening.match | A sanctions / PEP / adverse-media / crypto-wallet screen returned a non-clear result (the payload's type field says which; WALLET for wallet hits). |
alert.created | Event monitoring opened an alert. |
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_verified | A presence watch confirmed the person lives at their claimed address. See Address Intelligence. |
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 blew past its SLA. |
key_person.completed | A KYB key person 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 and Event monitoring. The workflow.* events are documented under Decisioning.
Payload structure
All events share the same envelope; the data object varies by event type.
{
"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,
"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 },
"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:
{
"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 catalogue.
workflowId is the workflow that drove the verification (null for SDK mounts configured with plain props). When the workflow has a decision graph, 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.
An api_key.* event's data instead looks like:
{
"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, submitted the verification (or its legacy userId / metadata.userId) or registered the entity. |
metadata | Your own correlation fields (a loan id, an account id), echoed exactly as you sent them. Bounded at 16 KiB, four levels and 100 keys; Myaza's request controls (requestId, userId, device, sandbox options) are never part of it. |
Myaza's ids (verificationId, entityId, sessionId, alertId, ...) stay exactly where they are; the two fields are added, never substituted. Both are resolved once, when the event is created, and persisted with it, so a retry or a manual redelivery replays the same values even if you later changed the entity. externalId on verification events is the verification source's own record id, kept for compatibility; correlate on externalUserId.
Events about your organisation rather than one of your users carry neither field: api_key.*, credits.low, credits.topped_up, credits.granted, credits.promo_expired and screening.adjudication_overdue. A KYB key person's own verification events resolve to the business's reference (the applicant you know), while events about that person's entity keep the kp_ id you received in keyPeopleInvites.
Status and checkStatus
Every verification payload carries two status fields, and the difference between them matters.
status is what happened. It is the single value to drive your own record off, and it is the same vocabulary the dashboard shows and GET /api/kyc/status/:id returns, so polling and listening can never disagree.
status | Meaning |
|---|---|
not_started | The link exists but nobody has opened it. |
in_progress | The applicant is part-way through. |
processing | Submitted. Checks are running, or a decision has not landed yet. |
in_review | Your workflow asked a person to decide this one. |
awaiting_resubmission | A reviewer sent it back for the applicant to redo some steps. |
approved | Accepted, automatically or by a person. |
declined | Rejected, automatically or by a person. |
abandoned | Opened, then left unfinished past its deadline. |
expired | Timed out without ever being opened. Send a new link. |
error | A fault on our side. You were not charged. |
checkStatus is what the checks found: pending, verified, failed, not_found or error. It never moves once the checks finish, whatever anybody decides afterwards.
Why both
The two are usually the same thing said twice. They come apart exactly when a person overrides the automated result, and that is the case worth being able to see.
Take an applicant whose selfie scored just under the pass mark. The checks fail. Your workflow routes it to review rather than declining outright, a compliance officer looks at the photos, recognises a lighting problem rather than a different face, and approves.
That verification is now:
{
"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.
Decisions by a person
verification.status_updated fires whenever somebody changes a verification's top-line status from the dashboard.
{
"verificationId": "ver_01j9xyz456",
"subjectType": "individual",
"status": "awaiting_resubmission",
"checkStatus": "failed",
"decision": "RESUBMISSION",
"reviewedById": "usr_01j9abc123",
"reviewedAt": "2026-04-28T09:14:00.000Z",
"entityId": "ent_01j9def789",
"resubmission": {
"url": "https://trust.myaza.co/verify/hs_live_abc123",
"steps": ["document-capture"],
"full": false,
"expiresAt": "2026-05-05T09:14:00.000Z"
},
"environment": "PRODUCTION"
}decision is APPROVED, DECLINED or RESUBMISSION.
On a resubmission, resubmission.url is the link to send the applicant. They redo only the steps in steps; full: true means the reviewer asked for the whole flow. We hold no contact details for your users, so delivering that link is yours to do.
The reviewer's internal note is deliberately not included. It is free text somebody wrote about your applicant, so it stays in your dashboard audit log. The short message written for the applicant travels on the resubmission link instead.
A reviewer may change their mind, in which case a second verification.status_updated arrives for the same verification. Treat the latest reviewedAt as current.
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):
curl -H "Authorization: Bearer sk_live_..." \
https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/selfie \
--output selfie.jpgThe 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. |
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.
// 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 / 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', 200Retries
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
2xximmediately, 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(orverificationId). - Store the secret in an environment variable, never in source.
- Test on sandbox before enabling production endpoints.