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:

HeaderExamplePurpose
Content-Typeapplication/json
X-Myaza-Signaturesha256=<hmac_hex>HMAC-SHA256 of the raw body. Verify this.
X-Myaza-Eventverification.completedThe event type.
X-Myaza-Delivery<delivery_id>Unique per delivery; use it to deduplicate.
User-AgentMyaza-Webhooks/1.0Identifies the sender.

Complete public catalogue

EventWhen it fires
verification.startedThe applicant pressed submit. Fires before any check has run. See When the applicant submits.
verification.completedVerification succeeded: identity confirmed.
verification.failedCompleted but validations did not pass (e.g. face mismatch, insufficient credit).
verification.not_foundThe ID number was not found in the government database.
verification.errorA system error occurred during verification.
verification.status_updatedA 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.startedAn applicant opened a resumable verification session.
session.resumedAn applicant returned to an existing verification session.
session.abandonedAn opened verification session passed its completion window.
session.expiredAn unopened verification session passed its completion window.
verification.deletedYou deleted a verification or an unfinished attempt (kind says which). See Delete records.
verification.restoredMyaza restored a verification you had deleted, at your request.
session.startedAn applicant opened a verification session for the first time.
session.resumedAn applicant returned to a verification session they had left.
session.abandonedAn applicant opened a verification session and left without submitting it.
session.expiredA verification session expired before the applicant ever opened it.
api_key.createdAn API key was created (security audit).
api_key.revokedAn API key was revoked (security audit).
credits.deductedCredit was deducted from the wallet for a verification.
credits.lowWallet balance dropped below the configured threshold.
credits.topped_upThe wallet was topped up.
workflow.run.completedA workflow decision run finished; carries the outcome (approve/decline/review). See Decisioning.
workflow.actionA webhook action node fired mid-graph.
workflow.run.failedA decision run could not finish (engine fault, not a decline).
entity.createdA completed verification created a monitored entity.
entity.importedAn entity was created via the direct-create or bulk-import API.
identity.resolvedAn entity was linked to a global identity.
entity.updatedYou edited an entity; carries the changed field names, never values.
entity.deletedYou deleted an entity. Its screening pauses.
entity.restoredMyaza restored an entity you had deleted, at your request.
event.flaggedA monitored event scored to a non-ALLOW decision.
screening.matchA sanctions / PEP / adverse-media / crypto-wallet screen returned a non-clear result (the payload's type field says which; WALLET for wallet hits).
alert.createdEvent monitoring opened an alert.
alert.updatedA repeat firing rolled up into an existing open alert.
entity.reverification_dueAn entity's perpetual-KYC renewal is due.
entity.address_presence_startedPresence 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_verifiedA presence watch confirmed the person lives at their claimed address. See Address Intelligence.
entity.address_presence_failedA presence watch resolved without confirming: verdict is failed (an integrity contradiction was caught) or inconclusive (the window lapsed, unbilled).
case.overdueAn investigation case blew past its SLA.
key_person.completedA 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:

MomentEvent
The applicant opens your link, or the SDK is mountedsession.started
They return to a flow they had leftsession.resumed
They press submitverification.started
The checks finishone 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.

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 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:

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:

FieldMeaning
externalUserIdYour 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.
metadataYour 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.

statusMeaning
not_startedThe link exists but nobody has opened it.
in_progressThe applicant is part-way through.
processingSubmitted. Checks are running, or a decision has not landed yet.
in_reviewYour workflow asked a person to decide this one.
awaiting_resubmissionA reviewer sent it back for the applicant to redo some steps.
approvedAccepted, automatically or by a person.
declinedRejected, automatically or by a person.
abandonedOpened, then left unfinished past its deadline.
expiredTimed out without ever being opened. Send a new link.
errorA fault on our side. You were not charged.

checkStatus is what the checks found: pending, verified, failed, not_found or error. It never moves once the checks finish, whatever anybody decides afterwards.

Why both

The two are usually the same thing said twice. They come apart exactly when a person overrides the automated result, and that is the case worth being able to see.

Take an applicant whose selfie scored just under the pass mark. The checks fail. Your workflow routes it to review rather than declining outright, a compliance officer looks at the photos, recognises a lighting problem rather than a different face, and approves.

That verification is now:

json
{
  "status": "approved",
  "checkStatus": "failed",
  "reasonCode": "selfie_mismatch",
  "decision": "APPROVED",
  "reviewedById": "usr_01j9abc123",
  "reviewedAt": "2026-04-28T09:14:00.000Z"
}

Both are true. You onboarded them, and you onboarded them despite a failed face match. If your record only stored approved, you could not answer why that was allowed, which is the question your own regulator asks about every exception. reason and reasonCode stay populated for the same purpose: they describe what was overridden.

Branch on status. Store checkStatus alongside it.

Status changes

verification.status_updated fires every time a verification's top-line status changes because somebody, or something, decided it. source says which:

sourceWhat happened
dashboardA reviewer approved, declined or sent it back from your dashboard, or changed an earlier decision.
apiYour backend did the same through POST /verifications/:id/review.
workflowYour workflow reached its verdict (approve, decline or review), and that moved the status.

Unlike the other events, this one is sent flat: the request body is the object itself, not wrapped in { id, event, createdAt, data }. The event name is in the X-Myaza-Event header, as for every event, and in the body as event beside createdAt, so a body you store describes itself. When the verification has them, externalUserId and metadata are included as well.

A decision made by a person:

json
{
  "event": "verification.status_updated",
  "createdAt": "2026-04-28T09:14:00.000Z",
  "verificationId": "ver_01j9xyz456",
  "subjectType": "individual",
  "attempt": 1,
  "status": "awaiting_resubmission",
  "previousStatus": "in_review",
  "checkStatus": "failed",
  "summary": {
    "reason": "The document photo is too blurry to read. Take it again in good light.",
    "reasonCode": "document_blurry",
    "assuranceLevel": null,
    "facialMatch": null,
    "dataMatch": null
  },
  "source": "dashboard",
  "changedBy": { "type": "user", "id": "usr_01j9abc123" },
  "changedAt": "2026-04-28T09:14:00.000Z",
  "decision": "RESUBMISSION",
  "outcome": null,
  "reviewedById": "usr_01j9abc123",
  "reviewedAt": "2026-04-28T09:14:00.000Z",
  "entityId": "ent_01j9def789",
  "workflowId": "wf_AbC123dEf456",
  "workflowVersion": 3,
  "runId": null,
  "resubmission": {
    "url": "https://trust.myaza.co/verify/hs_live_abc123",
    "steps": ["document-capture"],
    "full": false,
    "expiresAt": "2026-05-05T09:14:00.000Z"
  },
  "environment": "PRODUCTION"
}

A workflow's verdict:

json
{
  "event": "verification.status_updated",
  "createdAt": "2026-04-28T08:02:11.000Z",
  "verificationId": "ver_01j9xyz456",
  "attempt": 1,
  "status": "in_review",
  "previousStatus": "processing",
  "checkStatus": "failed",
  "summary": {
    "reason": "The selfie does not match the photo on the government record for this ID. Take it again in good light, looking straight at the camera.",
    "reasonCode": "selfie_mismatch",
    "assuranceLevel": null,
    "facialMatch": false,
    "dataMatch": true
  },
  "source": "workflow",
  "changedBy": { "type": "workflow", "id": "wf_AbC123dEf456" },
  "changedAt": "2026-04-28T08:02:11.000Z",
  "decision": null,
  "outcome": "review",
  "reviewedById": null,
  "reviewedAt": null,
  "runId": "wfr_01j9xyz789",
  "resubmission": null
}
FieldDescription
event, createdAtThe event name and when the change happened, carried in the body too. createdAt equals changedAt.
attemptWhich 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.
summaryWhat 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.
statusThe top-line status after the change.
previousStatusThe top-line status immediately before it. Null on the occasional older change whose prior status was never recorded.
sourcedashboard, api or workflow. More sources may be added, so treat an unfamiliar value as a status change like any other.
changedByWho made the change. type is user (a member of your team; id is their user id), api_key (id is the key's id) or workflow (id is the workflow's id).
decisionWhat a person decided: APPROVED, DECLINED or RESUBMISSION. Null when the workflow made the change.
outcomeWhat the workflow decided: approve, decline or review. Null when a person made the change.
reviewedById, reviewedAtThe person or API key that decided, and when. Null when the workflow made the change. Kept for integrations built before changedBy.
runIdThe 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):

shell
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.

KeyDescription
selfieLiveness selfie still.
documentFrontFront of the ID document.
documentBackBack of the ID document, when captured.
livenessVideoRecording of the liveness challenge.
documentFrontVideoRecording captured while scanning the document front.
documentBackVideoRecording captured while scanning the document back.
documentPortraitThe portrait cut from the document, present when the selfie was matched to the photo printed on it.

Verifying the signature

Compute HMAC-SHA256(rawBody, endpointSecret) and compare it to the X-Myaza-Signature header using a constant-time comparison. Always use the raw request body, not a re-serialised JSON object, or the signature won't match.

Your endpoint secret is shown when you create the endpoint in the dashboard.

js
// Node.js / Express
const crypto = require('crypto');

app.post('/webhooks/myaza', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-myaza-signature'];
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.MYAZA_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  // process event.event / event.data …
  res.status(200).send('OK');
});
python
# Python / Flask
import hmac, hashlib
from flask import request, abort

@app.route('/webhooks/myaza', methods=['POST'])
def webhook():
    sig = request.headers.get('X-Myaza-Signature', '')
    expected = 'sha256=' + hmac.new(
        WEBHOOK_SECRET.encode(), request.get_data(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    event = request.get_json()
    return 'OK', 200

Retries

A delivery that doesn't receive a 2xx response (or times out) is retried with exponential backoff. After 5 failed attempts the delivery is marked FAILED and can be retried manually from the dashboard.

AttemptDelay after previous attempt
130 seconds
25 minutes
330 minutes
42 hours
524 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.