Skip to content

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

Headline "Status in, product state out" beside an illustration of three rising tiers, on a deep indigo gradient.

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

status

What is the outcome?

not_started, in_progress, processing, in_review, awaiting_resubmission, approved, declined, abandoned, expired, error

checkStatus

What did the checks find?

pending, verified, failed, not_found, error

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.

status

Settled?

Your account state (example)

What the applicant sees (example)

What your backend does

not_started

No

kyc_invited

"Finish verifying your identity"

Nothing yet. Send reminders if you use links.

in_progress

No

kyc_in_progress

"Pick up where you left off"

Nothing yet.

processing

No

kyc_checking

"We are checking your details"

Wait. Read waitingOn if it takes long.

in_review

No

kyc_manual_review

"A member of our team is reviewing this"

Wait. A person is involved.

awaiting_resubmission

No

kyc_action_needed

The reviewer's request, plus a way back into the flow

Wait for the new attempt.

approved

Yes

kyc_passed

Give access to the product

Store the result, continue onboarding.

declined

Yes

kyc_failed

Message chosen from reasonCode

Decide whether a new attempt is allowed.

abandoned

Yes

kyc_dropped

"Start again"

Offer a new verification.

expired

Yes

kyc_link_expired

"Your link expired"

Send a new link.

error

Yes

kyc_retry

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.

reasonCode (examples)

Suggested next step for the applicant

document_blurry, document_unreadable

Take the photo again

document_expired

Use a current document

selfie_mismatch

Retake the selfie

identity_not_found, gov_data_mismatch

Check the number and details entered

provider_error, system_error

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 status value, with in_review and processing kept apart.

  • checkStatus, reason and reasonCode stored next to status, always.

  • Latest status taken from verification.status_updated or polling when your workflow has decision rules.

  • Applicant copy chosen by reasonCode; reason shown only on failed and not_found.

  • Unknown status or reasonCode values 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

About the author

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.

Build your product.We'll handle the rest.

Identity and compliance, end to end, built to global standards, priced for founders.

Mapping status and checkStatus to your product states · Myaza Trust