On this page
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_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. |
Complete public catalogue
| Event | When it fires |
|---|---|
verification.started | The applicant pressed submit. Fires before any check has run. See 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, or your workflow reached its verdict. See 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. |
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 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. |
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 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_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. |
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. |
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.
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, 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, which is sent flat.
{
"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:
{
"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.
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 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.
attempt says which attempt of the verification the event describes. A verification you send back or verify again 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:
{
"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 they answer different questions. The full explanation, with every combination, is in 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:
{
"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. |
workflow | Your workflow 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:
{
"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:
{
"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. |
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 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.
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):
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. |
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 idCheck<n>Photo. |
supportingDocument<n> | The nth supporting document the workflow asked for (1-based, in the order of supportingDocuments). |
businessDocument<n> | 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<n> or businessDocument<n> 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, 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 |
|---|---|
idCheck<n>Photo | The photo on the government record for that ID. The selfie was compared with it. |
idCheck<n>Portrait | The portrait cut from that ID's document, present when the selfie was matched to the photo printed on it. |
idCheck<n>Front | Front of that ID's document. |
idCheck<n>Back | Back of that ID's document, when captured. |
idCheck<n>FrontVideo | Recording captured while scanning that document's front. |
idCheck<n>BackVideo | 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 idCheck<n>Photo. media.recordPhoto is also present: it is the photo of the first ID that passed (or the first ID, when none passed).
"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 idCheck<n>Photo, 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<n> 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 (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.
// 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.