Designing your product around asynchronous verification results
Treat submission as the start of a state machine, not the end of a request. Show a pending state keyed on the verification ID, update it from webhooks, and let the status change more than once.

Charles Archibong, Co-founder
· 7 min read

Key takeaways
- onSubmit means the verification was created, never that it passed.
- Act on status and store checkStatus beside it; they can legitimately disagree.
- A verification's status can change after it settles, so take the latest change, not the first event.
- A resubmission keeps the same verification ID; update the record rather than creating a new one.
Between submission and the final result, your app should be in a pending state that is keyed on the verification ID, driven by events from your backend, and prepared for the status to change more than once. The moment the SDK calls onSubmit tells you the verification was created. It tells you nothing about whether it passed.
That sounds obvious until a verification sits in review for a day, is sent back for a new selfie, comes back, and is approved. A product built around a single "success or failure" callback has nowhere to put any of that. A product built around a small state machine handles it without special cases.
Why the result cannot arrive with the submission
Identity checks take different amounts of time for good reasons. A document has to be read, a government record looked up, a face compared. Then a decision graph may wait on a watchlist screen, a business may wait on its directors verifying themselves, and a person on your team may need to look. Some of that takes seconds and some of it takes days.
So Myaza Trust returns as soon as the verification is created, with a status of processing, and tells your backend how it settles through webhooks or a status poll. The design question for your product is what to show, and what to allow, during that gap.
Two fields, two questions
Every submitted verification carries two status fields, and you need both.
status is the outcome. It is the field you act on, and it uses ten values across the whole lifecycle: not_started, in_progress, processing, in_review, awaiting_resubmission, approved, declined, abandoned, expired and error.
checkStatus is what the automated checks found: pending, verified, failed, not_found or error. Decision rules and reviewers never change it.
They disagree whenever something other than the checks decided. A verification can be approved with checkStatus: "failed" because a reviewer accepted a selfie that scored just below the pass mark in poor light. Store both. The day someone asks why that customer was allowed through, checkStatus and reasonCode are your answer.
Status is decided in a fixed order: a reviewer's decision wins, then your workflow's decision, then the checks alone.
Mapping status to what the user sees
Most products need only a handful of user-facing states. A mapping from the documented values, as a starting point:
| What your app shows | What your app allows |
|---|---|---|
| "We're checking your details" | Browsing, not the gated action |
| "We're reviewing your details; this can take longer" | Same as processing |
| "We need you to redo one step" with the link | Only the resubmission |
| Verified | The gated action |
| A next step, worded from | Retry or contact support |
| "Something went wrong on our side, please try again" | Retry |
| "Start again" | A fresh link or a new attempt |
The same rule in code, as a single function your screens can share:
type UiState = "checking" | "action_needed" | "verified" | "not_verified" | "start_again";
function uiStateFor(status: string): UiState {
switch (status) {
case "approved": return "verified";
case "declined": return "not_verified";
case "awaiting_resubmission": return "action_needed";
case "abandoned":
case "expired":
case "error": return "start_again";
default: return "checking"; // processing, in_review and anything unrecognised
}
}The default branch is deliberate. It covers not_started, in_progress, processing and in_review, and if a value you do not recognise ever reaches it, "still checking" is a safer answer than a pass.
Wording a decline
On a failed or not_found outcome, reason is a sentence written for the applicant, with no scores or thresholds in it, and you can show it as it stands. reasonCode is the stable token to branch on: document_expired might trigger "Upload a current document", selfie_mismatch a prompt to retake the selfie in better light. On an error outcome the reason is addressed to you, for example about your credit balance, so show the person a generic message and route the reason to your team.
Telling "a few seconds" from "a few days"
processing covers both a document being read and a decision held on a watchlist screen. The status endpoint's waitingOn field says which: null when the checks are still running, "screening", "key_people" for a business waiting on its directors and owners, or "address_presence" for an address check that runs over days where enabled. Use it to set expectations ("this usually finishes in a minute" versus "we'll email you"), and treat an unfamiliar value as a wait you have not learned about yet.
Which signal to trust, and when
Three signals reach you, and they are not equally authoritative.
The client signal. The SDK's
onSubmit, or a hosted page'ssubmittedevent, fires when the applicant finishes the flow. Use it to move the user to the pending screen at once. Do not use it to grant anything.Webhooks to your backend. These are the record. Update your database from them and push the change to the user's screen from there.
The status endpoint.
GET /api/kyc/status/:verificationIdreturns the current status with a publishable key and carries no personal data, scores or result data. Use it to fill a gap, for example when the app reopens before your backend has pushed anything.
The first webhook is not always the last
If your workflow has decision rules, verification.completed is sent when the checks finish, before the workflow has decided. Its status reflects the checks alone. The workflow's verdict arrives afterwards as verification.status_updated with source: "workflow", and that is the status to act on. Later, a reviewer may decide or change an earlier decision, and each change arrives as another verification.status_updated.
Deliveries retry independently and can arrive out of order, so keep the changedAt of the change you hold and ignore any older one. Without decision rules the checks are the decision, and the status on verification.completed stands unless a reviewer later changes it.
Resubmissions keep the same ID
When a reviewer sends a verification back, the applicant redoes only the requested steps, and their resubmission completes the same verification. You receive another result event with the same verificationId, attempt counted up, and checkStatus back to pending while the checks run again. Update the record you hold. Creating a new row per event would leave you with two customers where there is one.
Polling, if you must
Webhooks should carry production. Polling is for gaps: a mobile screen that reopens, or an environment where you cannot receive webhooks yet. If you poll:
Back off, starting at a few seconds and widening, and stop once the status is settled.
Remember the documented default limit of 100 requests per 15 minutes per client, and read the
RateLimit-RemainingandRateLimit-Resetheaders rather than guessing.Poll from as few places as possible. A dozen open screens each polling the same verification is a dozen times the traffic for one answer.
Retries on the way in
The gap also exists on the submission side. A mobile network drops the response to the create call, and the app does not know whether the verification exists. Send a stable metadata.requestId for each logical verification and reuse it on any retry. The API returns the existing verification instead of creating a second one, and never charges twice.
A walk-through
Consider a hypothetical savings app with a workflow that screens every verified user. An applicant submits on their phone at 09:00. The app receives onSubmit and shows "We're checking your details". Seconds later the backend receives verification.completed with checkStatus: "verified". Its status reads approved, because that event reflects the checks alone, so the backend grants nothing yet: the workflow is still waiting on the screen, and a status poll shows processing with waitingOn: "screening". The screen returns a possible match, the workflow routes it to review, and verification.status_updated arrives with in_review. The app changes its message to say the review can take longer. At 14:30 a compliance officer clears the false positive and approves; a second verification.status_updated arrives with approved, and the backend enables deposits and sends a push notification.
Nothing in that sequence needed a special case. It is one record, keyed on one ID, updated by each event in turn.
A checklist for your pending state
Create a local record keyed on
verificationIdthe momentonSubmitfires.Update it only from your backend, driven by webhooks.
Act on
status; storecheckStatus,reasonandreasonCodebeside it.Keep the latest
changedAtand ignore older changes.Treat unknown status and
waitingOnvalues as "still checking".On a resubmission, update the same record and show the attempt.
Word declines from
reasonCode, and never show anerrorreason to the applicant.Reuse
requestIdon retries, and poll only to fill gaps.
Sources

Charles Archibong
Co-founder
Charles Archibong co-founded Myaza Trust. He writes about identity verification, financial technology, and the practical work of building trusted digital services.


