Mapping verification statuses to your product's states
Drive your product from status, the outcome, and store checkStatus beside it as the explanation. Map each of the ten status values to one state in your own system, and take the latest status from verification.status_updated or polling.

Charles Archibong, Co-founder
· 7 min read

Key takeaways
- Act on status, which is the outcome; keep checkStatus as the record of what the automated checks found.
- approved with checkStatus failed is a recorded exception, not a contradiction, and worth storing.
- With workflow decision rules, verification.completed is not final; the verdict arrives as verification.status_updated.
- Show reason to the applicant on failed and not_found outcomes, never on error, where it is addressed to you.
Your product should change state on one field: status. It is the outcome of a verification, and it takes one of ten values, from not_started to approved. Store checkStatus next to it, because it records what the automated checks found and is the only explanation you will have when the two disagree. They disagree more often than engineers expect, and that is by design.
In practice that means three things: map each of the ten status values to exactly one state in your own system, take the latest status from the verification.status_updated webhook or from polling rather than from the first event you receive, and use reasonCode (never the raw status alone) to decide what to tell the applicant. The rest of this article turns that into a table you can adopt and code you can adapt.
Why are there two fields instead of one?
Because two different things decide a verification. The automated checks read the document, query the government record, match the face and read the chip. Then your workflow's decision rules, or a person on your team, decide what to do about the person. The verification lifecycle documentation gives each question its own field.
Field | Question it answers | Values |
|---|---|---|
| What is the outcome? |
|
| What did the checks find? |
|
status is worked out in a fixed order, and the first rule that applies wins: a reviewer's decision, then your workflow's decision, then the checks alone. checkStatus never changes because of a rule or a reviewer. So an applicant whose face did not match, but whom a reviewer approved after a video call, ends with status: "approved" and checkStatus: "failed". Both are true, and storing only the first would leave you unable to explain later why the exception was allowed.
How should each status map to your product?
Here is a worked mapping for an illustrative lending app. The account states and messages are examples; the right-hand columns are the part to copy.
| Settled? | Your account state (example) | What the applicant sees (example) | What your backend does |
|---|---|---|---|---|
| No |
| "Finish verifying your identity" | Nothing yet. Send reminders if you use links. |
| No |
| "Pick up where you left off" | Nothing yet. |
| No |
| "We are checking your details" | Wait. Read |
| No |
| "A member of our team is reviewing this" | Wait. A person is involved. |
| No |
| The reviewer's request, plus a way back into the flow | Wait for the new attempt. |
| Yes |
| Give access to the product | Store the result, continue onboarding. |
| Yes |
| Message chosen from | Decide whether a new attempt is allowed. |
| Yes |
| "Start again" | Offer a new verification. |
| Yes |
| "Your link expired" | Send a new link. |
| Yes |
| A generic "something went wrong" | Retry or contact support. Nothing was charged. |
Two design choices in that table are worth copying even if you rename every state.
First, one state per value, even where two values look alike. It is tempting to fold in_review into processing because both mean "wait". Keep them apart: in_review means a person on your team owes a decision, which is an internal queue metric, while processing is the platform working.
Second, declined is one state, with the reason carried alongside it. An ID the government database does not hold is a declined with reasonCode: "identity_not_found", not a separate status. Branch on the code, not on a proliferation of states.
What does processing actually wait on?
Usually, only the checks. But when your workflow holds its decision on something slower, processing can last longer, and the status endpoint includes waitingOn to say what. Values include "screening" (a sanctions, PEP or adverse-media screen) and "key_people" (the directors and owners on a business application finishing their own verifications); null means the checks are still running. The status endpoint reference lists them.
That field is what lets your applicant screen say something more useful than a spinner, and lets your operations team see why a business application has been processing for two days.
Why is verification.completed not always the final answer?
Because it describes the checks, not the decision. The events verification.completed, .failed, .not_found and .error are sent the moment the checks finish. If your workflow has decision rules, that is before the workflow has decided, and their status reflects the checks alone.
The verdict arrives afterwards as verification.status_updated, with source: "workflow". The same event also fires when a reviewer decides in the dashboard (source: "dashboard") or your backend decides through the review API (source: "api"). It carries previousStatus and the new status, and unlike most events it is delivered flat rather than wrapped in a data object, as the webhooks documentation explains.
A worked example makes the trap concrete. A workflow declines anyone with a confirmed watchlist match. An applicant passes every check: verification.completed arrives with status: "approved". Seconds later the screening wait resolves and the workflow declines, so verification.status_updated arrives with status: "declined" and source: "workflow". If your handler onboarded the customer on the first event, you have onboarded someone your own policy rejects.
The rule: if your workflow has decision rules, treat verification.status_updated (or a fresh poll) as the source of status. Without decision rules, the checks are the decision and verification.completed is final unless a reviewer later changes it.
What should you tell the applicant?
Use reasonCode to choose the message, and use reason carefully.
On a failed or not_found check, reason is written for the applicant: plain language naming what did not match and what to do next, with no score, no threshold and nothing read back from the government record. You can show it as it stands.
On an error outcome, reason is written for you. It might say "top up your balance" or "contact Myaza", which means nothing to an applicant. Show them a generic message and route the reason to your team.
| Suggested next step for the applicant |
|---|---|
| Take the photo again |
| Use a current document |
| Retake the selfie |
| Check the number and details entered |
| Generic retry message |
New codes may be added over time, so map an unrecognised code to a generic failure message rather than to an error in your code.
What does this look like in code?
A handler that applies the rules above, in TypeScript. The accounts calls are placeholders for your own persistence.
type Status =
| 'not_started' | 'in_progress' | 'processing' | 'in_review'
| 'awaiting_resubmission' | 'approved' | 'declined'
| 'abandoned' | 'expired' | 'error';
const ACCOUNT_STATE: Record<Status, string> = {
not_started: 'kyc_invited',
in_progress: 'kyc_in_progress',
processing: 'kyc_checking',
in_review: 'kyc_manual_review',
awaiting_resubmission: 'kyc_action_needed',
approved: 'kyc_passed',
declined: 'kyc_failed',
abandoned: 'kyc_dropped',
expired: 'kyc_link_expired',
error: 'kyc_retry',
};
export async function applyVerificationStatus(event: {
verificationId: string;
status: Status;
checkStatus?: string;
reasonCode?: string | null;
reason?: string | null;
}) {
const state = ACCOUNT_STATE[event.status] ?? 'kyc_checking'; // unknown value: keep waiting
await accounts.updateByVerificationId(event.verificationId, {
kycState: state,
kycStatus: event.status,
kycCheckStatus: event.checkStatus ?? null, // the explanation, kept for audit
kycReasonCode: event.reasonCode ?? null,
kycReason: event.reason ?? null,
});
}Call it from both the verification.status_updated handler and your polling path, so the two cannot drift. Deduplicate webhook deliveries on the X-Myaza-Delivery header before calling it.
What happens when a verification is sent back?
It keeps its ID. When a reviewer sends a verification back and the applicant resubmits, the same verification starts a new attempt: attempt goes up by one, checkStatus returns to pending, and the checks run again. Your mapping handles this without special cases: the status moves from awaiting_resubmission to processing and then settles again. Keying your records on verificationId, not on a "latest verification for this user" query, is what makes that work.
For the full picture, including every earlier attempt and every decision, read GET /api/kyc/verifications/:id from your backend with a secret key. It returns review and statusHistory, newest first, as described in the verification result reference.
Checklist
One product state per
statusvalue, within_reviewandprocessingkept apart.checkStatus,reasonandreasonCodestored next tostatus, always.Latest
statustaken fromverification.status_updatedor polling when your workflow has decision rules.Applicant copy chosen by
reasonCode;reasonshown only onfailedandnot_found.Unknown
statusorreasonCodevalues handled as "keep waiting" or "generic failure", never as a crash.Records keyed on
verificationId, so resubmissions update the same row.
The developer hub links the webhook and status references used here.
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.


