
# 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_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](#when-the-applicant-submits). |
| `verification.completed` | Verification succeeded: identity confirmed. |
| `verification.failed` | Completed but validations did not pass (e.g. face mismatch, insufficient credit). |
| `verification.not_found` | The ID number was not found in the government database. |
| `verification.error` | A system error occurred during verification. |
| `verification.status_updated` | A verification's top-line status changed: a reviewer decided it in the dashboard, your backend decided it [through the API](https://trust.myaza.co/documentation/api-review-verification/markdown), or your workflow reached its verdict. See [Status changes](#status-changes). |
| `session.started` | An applicant opened a resumable verification session. |
| `session.resumed` | An applicant returned to an existing verification session. |
| `session.abandoned` | An opened verification session passed its completion window. |
| `session.expired` | An unopened verification session passed its completion window. |
| `verification.deleted` | You deleted a verification or an unfinished attempt (`kind` says which). See [Delete records](https://trust.myaza.co/documentation/api-manage-records/markdown). |
| `verification.restored` | Myaza restored a verification you had deleted, at your request. |
| `session.started` | An applicant opened a verification session for the first time. |
| `session.resumed` | An applicant returned to a verification session they had left. |
| `session.abandoned` | An applicant opened a verification session and left without submitting it. |
| `session.expired` | A verification session expired before the applicant ever opened it. |
| `api_key.created` | An API key was created (security audit). |
| `api_key.revoked` | An API key was revoked (security audit). |
| `credits.deducted` | Credit was deducted from the wallet for a verification. |
| `credits.low` | Wallet balance dropped below the configured threshold. |
| `credits.topped_up` | The wallet was topped up. |
| `workflow.run.completed` | A [workflow](https://trust.myaza.co/documentation/workflows/markdown) decision run finished; carries the `outcome` (`approve`/`decline`/`review`). See [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown#the-workflowruncompleted-webhook). |
| `workflow.action` | A `webhook` action node fired mid-graph. |
| `workflow.run.failed` | A decision run could not finish (engine fault, **not** a decline). |
| `entity.created` | A completed verification created a monitored [entity](https://trust.myaza.co/documentation/identity-hub/markdown). |
| `entity.imported` | An entity was created via the direct-create or bulk-import API. |
| `identity.resolved` | An entity was linked to a global identity. |
| `entity.updated` | You edited an entity; carries the changed field names, never values. |
| `entity.deleted` | You deleted an entity. Its screening pauses. |
| `entity.restored` | Myaza restored an entity you had deleted, at your request. |
| `event.flagged` | A monitored [event](https://trust.myaza.co/documentation/monitoring-events/markdown) scored to a non-`ALLOW` decision. |
| `screening.match` | A sanctions / PEP / adverse-media / crypto-wallet [screen](https://trust.myaza.co/documentation/screening/markdown) returned a non-clear result (the payload's `type` field says which; `WALLET` for wallet hits). |
| `alert.created` | Event monitoring opened an [alert](https://trust.myaza.co/documentation/investigations/markdown). |
| `alert.updated` | A repeat firing rolled up into an existing open alert. |
| `entity.reverification_due` | An entity's perpetual-KYC renewal is due. |
| `entity.address_presence_started` | Presence monitoring began for a customer. Fires once, when the watch is minted, and carries the policy it has to clear plus the deadline. The verdict follows later. See [Knowing a check is running](https://trust.myaza.co/documentation/address-intelligence/markdown#knowing-a-check-is-running). |
| `entity.address_verified` | A presence watch confirmed the person lives at their claimed address. See [Address Intelligence](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification). |
| `entity.address_presence_failed` | A presence watch resolved without confirming: `verdict` is `failed` (an integrity contradiction was caught) or `inconclusive` (the window lapsed, unbilled). |
| `case.overdue` | An investigation [case](https://trust.myaza.co/documentation/investigations/markdown) blew past its SLA. |
| `key_person.completed` | A KYB [key person](https://trust.myaza.co/documentation/key-people/markdown) resolved (their KYC finished, or a reviewer attested them). Carries their headline `role` and the full `roles` set. |

The `data` payloads for the compliance events (`entity.created`, `identity.resolved`, `event.flagged`, `screening.match`, `alert.*`) are documented with each feature. See [Identity Hub API](https://trust.myaza.co/documentation/identity-hub/markdown#webhooks) and [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown#the-eventflagged-webhook). The `workflow.*` events are documented under [Decisioning](https://trust.myaza.co/documentation/decisioning/markdown#the-workflowruncompleted-webhook).

## When the applicant submits

`verification.started` is the submission event. It fires the moment the applicant presses submit, before any check has run, and it is the only event that marks that moment. The name describes what happens next on our side (processing begins), but the trigger is the applicant finishing the flow.

This catches people out because the event family changes at exactly that point:

| Moment | Event |
|---|---|
| The applicant opens your link, or the SDK is mounted | `session.started` |
| They return to a flow they had left | `session.resumed` |
| **They press submit** | **`verification.started`** |
| The checks finish | one of `verification.completed`, `verification.failed`, `verification.not_found`, `verification.error` |

An attempt that never reaches submission ends in the session family instead: `session.abandoned` if they opened it and walked away, `session.expired` if they never opened it at all.

A session and the verification it becomes share one id, so `verificationId` is the same value on every event in that table. Correlate the whole attempt on it.

If what you want is the event that tells you somebody completed one of your [workflows](https://trust.myaza.co/documentation/workflows/markdown), this is the one. The payload carries `workflowId` and `workflowVersion`, so you know which flow and which published version they walked.

## Payload structure

Events share the same envelope, and the `data` object varies by event type. The one exception is [`verification.status_updated`](#status-changes), which is sent flat.

```json
{
  "id": "evt_01j9abc123",
  "event": "verification.completed",
  "createdAt": "2026-04-27T12:00:00.000Z",
  "data": {
    "verificationId": "ver_01j9xyz456",
    "requestId": "order_1001",
    "externalId": "prov_rec_01j9",
    "externalUserId": "user_42",
    "metadata": { "loanId": "loan_20191" },
    "workflowId": "wf_AbC123dEf456",
    "workflowVersion": 3,
    "attempt": 1,
    "submittedAt": "2026-04-27T12:00:00.000Z",
    "status": "approved",
    "checkStatus": "verified",
    "reason": null,
    "reasonCode": null,
    "idType": "bvn",
    "country": "NG",
    "idNumber": "12345678901",
    "userData": {
      "firstName": "JOHN",
      "lastName": "DOE",
      "dateOfBirth": "1990-01-01"
    },
    "facialMatch": { "match": true, "confidence": 85 },
    "facialMatchSource": "gov_record",
    "media": {
      "selfie": "https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/selfie",
      "livenessVideo": "https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/liveness-video"
    },
    "environment": "PRODUCTION",
    "createdAt": "2026-04-27T12:00:00.000Z"
  }
}
```

On a non-success event (`verification.failed`, `.not_found`, `.error`) the `data` carries a human-readable `reason` and a stable `reasonCode` you can branch on; both `null` above:

```json
{
  "status": "declined",
  "checkStatus": "failed",
  "reason": "The document expired on 2020-01-01. A current, non-expired document is required.",
  "reasonCode": "document_expired"
}
```

See the full [failure reason codes](https://trust.myaza.co/documentation/verifications/markdown#failure-reason-codes) catalogue.

`facialMatchSource` says which photo the selfie was compared with: `gov_record` (the government record), `chip` (the verified photo on the document's chip) or `document` (the photo printed on the document, used only when the workflow allows it and neither stronger photo exists). It is `null` when no facial comparison ran. A printed photo is weaker evidence, so you may want to treat a `document` match with more care, for example by sending it to review.

`facialMatchSkipped` says why no facial comparison ran when one was expected. It is `no_face_on_document` when the photo printed on the document was the only photo the selfie could be compared with and no face could be read on it, and `null` otherwise. Your workflow either declines that verification (`reasonCode: document_photo_no_face`) or keeps it for review.

`workflowId` is the [workflow](https://trust.myaza.co/documentation/workflows/markdown) that drove the verification (`null` for SDK mounts configured with plain props). When the workflow has a [decision graph](https://trust.myaza.co/documentation/decisioning/markdown), the pass/fail result above arrives first, and the approve / decline / review **decision** follows in a separate `workflow.run.completed` event.

`workflowVersion` is the **published version that actually ran**. Publishing a workflow overwrites its live config in place and bumps the version, so the id alone stops describing what happened as soon as you publish again; store the pair. Look up the exact configuration a version used in the dashboard under **Workflows → Version history**. It is `null` when the verification had no workflow, predates this field, or attributed to a workflow that was never published.

`attempt` says which attempt of the verification the event describes. A verification you [send back or verify again](https://trust.myaza.co/documentation/api-verify-again/markdown) keeps its id, so when the applicant resubmits you receive another `verification.completed` (or `verification.failed`) with the same `verificationId`, `attempt` counted up, and `submittedAt` set to when they resubmitted. Update the record you already hold rather than creating a new one.

An `api_key.*` event's `data` instead looks like:

```json
{
  "id": "evt_01j9abc789",
  "event": "api_key.created",
  "createdAt": "2026-04-27T12:00:00.000Z",
  "data": {
    "apiKeyId": "key_01j9abc000",
    "name": "Mobile app production key",
    "environment": "PRODUCTION",
    "createdBy": "user@example.com"
  }
}
```

## Your reference on every event

You never need a mapping table to know which of your users an event is about. Every event that originates from an entity, a session or a verification carries two fields beside Myaza's own ids:

| Field | Meaning |
|---|---|
| `externalUserId` | **Your** stable reference for the person or business: what you passed as `externalUserId` when you [created the session](https://trust.myaza.co/documentation/api-create-session/markdown), [submitted the verification](https://trust.myaza.co/documentation/api-create-verification/markdown) (or its legacy `userId` / `metadata.userId`) or [registered the entity](https://trust.myaza.co/documentation/identity-hub/markdown). |
| `metadata` | Your own correlation fields (a loan id, an account id), echoed exactly as you sent them. Bounded at 16 KiB, four levels and 100 keys; Myaza's request controls (`requestId`, `userId`, `device`, sandbox options) are never part of it. |

Myaza's ids (`verificationId`, `entityId`, `sessionId`, `alertId`, ...) stay exactly where they are; the two fields are added, never substituted. Both are resolved once, when the event is created, and persisted with it, so a retry or a manual redelivery replays the same values even if you later changed the entity. `externalId` on verification events is the verification source's own record id, kept for compatibility; correlate on `externalUserId`.

Events about your organisation rather than one of your users carry neither field: `api_key.*`, `credits.low`, `credits.topped_up`, `credits.granted`, `credits.promo_expired` and `screening.adjudication_overdue`. A KYB key person's own verification events resolve to the business's reference (the applicant you know), while events about that person's entity keep the `kp_` id you received in `keyPeopleInvites`.

## Status and checkStatus

Every verification payload carries two status fields, and the difference between them matters.

**`status`** is what happened. It is the single value to drive your own record off, and it is the same vocabulary the dashboard shows and `GET /api/kyc/status/:id` returns, so polling and listening can never disagree.

| `status` | Meaning |
|---|---|
| `not_started` | The link exists but nobody has opened it. |
| `in_progress` | The applicant is part-way through. |
| `processing` | Submitted. Checks are running, or a decision has not landed yet. |
| `in_review` | Your workflow asked a person to decide this one. |
| `awaiting_resubmission` | A reviewer sent it back for the applicant to redo some steps. |
| `approved` | Accepted, automatically or by a person. |
| `declined` | Rejected, automatically or by a person. |
| `abandoned` | Opened, then left unfinished past its deadline. |
| `expired` | Timed out without ever being opened. Send a new link. |
| `error` | A fault on our side. You were not charged. |

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

### Why both

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

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

That verification is now:

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

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

Branch on `status`. Store `checkStatus` alongside it.

### Status changes

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

| `source` | What happened |
|---|---|
| `dashboard` | A reviewer approved, declined or sent it back from your dashboard, or changed an earlier decision. |
| `api` | Your backend did the same through [`POST /verifications/:id/review`](https://trust.myaza.co/documentation/api-review-verification/markdown). |
| `workflow` | Your [workflow](https://trust.myaza.co/documentation/decisioning/markdown) reached its verdict (`approve`, `decline` or `review`), and that moved the status. |

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

A decision made by a person:

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

A workflow's verdict:

```json
{
  "event": "verification.status_updated",
  "createdAt": "2026-04-28T08:02:11.000Z",
  "verificationId": "ver_01j9xyz456",
  "attempt": 1,
  "status": "in_review",
  "previousStatus": "processing",
  "checkStatus": "failed",
  "summary": {
    "reason": "The selfie does not match the photo on the government record for this ID. Take it again in good light, looking straight at the camera.",
    "reasonCode": "selfie_mismatch",
    "assuranceLevel": null,
    "facialMatch": false,
    "dataMatch": true
  },
  "source": "workflow",
  "changedBy": { "type": "workflow", "id": "wf_AbC123dEf456" },
  "changedAt": "2026-04-28T08:02:11.000Z",
  "decision": null,
  "outcome": "review",
  "reviewedById": null,
  "reviewedAt": null,
  "runId": "wfr_01j9xyz789",
  "resubmission": null
}
```

| Field | Description |
|---|---|
| `event`, `createdAt` | The event name and when the change happened, carried in the body too. `createdAt` equals `changedAt`. |
| `attempt` | Which attempt of the verification the change is about. A verification you send back keeps its id, and the applicant's resubmission is the next attempt. |
| `summary` | What the checks concluded, so you can act on the change without a second request: `reason`, `reasonCode`, `assuranceLevel`, `facialMatch` and `dataMatch`. Conclusions only: no names, dates of birth or ID numbers, which stay on the [secret-key result](https://trust.myaza.co/documentation/api-verification-result/markdown). |
| `status` | The top-line status after the change. |
| `previousStatus` | The top-line status immediately before it. Null on the occasional older change whose prior status was never recorded. |
| `source` | `dashboard`, `api` or `workflow`. More sources may be added, so treat an unfamiliar value as a status change like any other. |
| `changedBy` | Who made the change. `type` is `user` (a member of your team; `id` is their user id), `api_key` (`id` is the key's id) or `workflow` (`id` is the workflow's id). |
| `decision` | What a person decided: `APPROVED`, `DECLINED` or `RESUBMISSION`. Null when the workflow made the change. |
| `outcome` | What the workflow decided: `approve`, `decline` or `review`. Null when a person made the change. |
| `reviewedById`, `reviewedAt` | The person or API key that decided, and when. Null when the workflow made the change. Kept for integrations built before `changedBy`. |
| `runId` | The [workflow run](https://trust.myaza.co/documentation/decisioning/markdown) that decided, when the workflow made the change. |

A workflow's verdict fires this event only when it moves the status. If a person already decided while the workflow was still waiting (on key people, for example), their decision stands, the verdict changes nothing, and you receive `workflow.run.completed` alone.

On a resubmission, `resubmission.url` is the link to send the applicant, unless the reviewer had us email it. They redo only the steps in `steps`; `full: true` means the reviewer asked for the whole flow. Their resubmission completes the **same** verification: the next `verification.completed` or `verification.failed` arrives with the same `verificationId` and `attempt` counted up. See [Verify again](https://trust.myaza.co/documentation/api-verify-again/markdown).

The reviewer's internal note is deliberately **not** included. It is free text somebody wrote about your applicant, so it stays in your dashboard: in the audit log and on the verification's timeline. The short message written *for* the applicant travels on the resubmission link instead.

Decisions can change. A reviewer or your backend may reverse an earlier decision, and each change arrives as its own `verification.status_updated`. Deliveries retry independently and can arrive out of order, so treat the latest `changedAt` as current. Every change also appears on the verification's **Timeline** in your dashboard, with who made it and why.

An endpoint only receives `verification.status_updated` when it is subscribed to it. Endpoints created before the event existed are not, so open the endpoint under **Developers → Webhooks** and choose **Edit events** to add it. The endpoint keeps its URL and signing secret.

## Captured media

Verification events carry a `media` object: a map of the images and videos captured during the flow. Each value is an **absolute URL** (not the bytes themselves). Fetch each one from your backend with a **secret (`sk_`) key** as a Bearer token (media is sensitive, so a publishable key returns `403 secret_key_required`):

```bash
curl -H "Authorization: Bearer sk_live_..." \
  https://trust.myaza.app/api/kyc/verifications/ver_01j9xyz456/media/selfie \
  --output selfie.jpg
```

The URLs are scoped to the secret key's **organisation and environment** (a sandbox key cannot read production media) and do not expire. The object only contains the kinds that were actually captured (a number-only-ID flow has just `selfie` + `livenessVideo`), and is `null` when no media is associated with the event.

| Key | Description |
|---|---|
| `selfie` | Liveness selfie still. |
| `documentFront` | Front of the ID document. |
| `documentBack` | Back of the ID document, when captured. |
| `livenessVideo` | Recording of the liveness challenge. |
| `documentFrontVideo` | Recording captured while scanning the document front. |
| `documentBackVideo` | Recording captured while scanning the document back. |
| `documentPortrait` | The portrait cut from the document, present when the selfie was matched to the photo printed on it. |

## Verifying the signature

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

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

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

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

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

  const event = JSON.parse(req.body);
  // process event.event / event.data …
  res.status(200).send('OK');
});
```

```python
# Python / Flask
import hmac, hashlib
from flask import request, abort

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

## Retries

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

| Attempt | Delay after previous attempt |
|---|---|
| 1 | 30 seconds |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 24 hours |

## Best practices

- **Verify the signature before processing** the payload.
- **Respond `2xx` immediately**, then do heavy work asynchronously to avoid timeouts and retries.
- **Be idempotent.** The same delivery may arrive more than once; deduplicate on `X-Myaza-Delivery` (or `verificationId`).
- **Store the secret** in an environment variable, never in source.
- **Test on sandbox** before enabling production endpoints.
