Webhooks
Webhooks let you receive verification outcomes (and other events) in real time instead of polling. Toden 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:
Complete public catalogue
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:
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.
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:
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:
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 Toden’s own ids:
Toden’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.
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:
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:
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:
A workflow’s verdict:
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):
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.
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.
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).
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.
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.
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.