Skip to content

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

Headline "Designing for async verification" beside an illustration of a path of connected steps, on a deep indigo gradient.

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:

status

What your app shows

What your app allows

processing

"We're checking your details"

Browsing, not the gated action

in_review

"We're reviewing your details; this can take longer"

Same as processing

awaiting_resubmission

"We need you to redo one step" with the link

Only the resubmission

approved

Verified

The gated action

declined

A next step, worded from reasonCode

Retry or contact support

error

"Something went wrong on our side, please try again"

Retry

abandoned, expired

"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.

  1. The client signal. The SDK's onSubmit, or a hosted page's submitted event, 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.

  2. Webhooks to your backend. These are the record. Update your database from them and push the change to the user's screen from there.

  3. The status endpoint. GET /api/kyc/status/:verificationId returns 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-Remaining and RateLimit-Reset headers 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

  1. Create a local record keyed on verificationId the moment onSubmit fires.

  2. Update it only from your backend, driven by webhooks.

  3. Act on status; store checkStatus, reason and reasonCode beside it.

  4. Keep the latest changedAt and ignore older changes.

  5. Treat unknown status and waitingOn values as "still checking".

  6. On a resubmission, update the same record and show the attempt.

  7. Word declines from reasonCode, and never show an error reason to the applicant.

  8. Reuse requestId on retries, and poll only to fill gaps.

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.

  • Headline "Webhook signatures and retries" beside an illustration of a code window with angle brackets, on a deep indigo gradient.

    Developers

    Verifying webhook signatures and handling retries correctly

    Verify the V2 signature over the raw request bytes, reject timestamps outside the replay window, record the event ID under a unique constraint before doing any work, and return 2xx only once the event is durably stored.

  • Headline "Where verification flows lose people" beside an illustration of a path of connected steps, on a soft lavender gradient.

    Identity Verification

    Where verification flows lose people, and how to fix each step

    People abandon verification at predictable moments: an unexplained start, the camera permission prompt, document capture, the desktop that has no camera, and being asked to redo everything after one failure. Measure abandonment per step, then fix the step, not the whole flow.

Build your product.We'll handle the rest.

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

Designing for asynchronous verification results · Myaza Trust