Verification lifecycle & results

Every verification is one session that moves through a lifecycle and ends on a decision. This is the reference for the statuses a session can hold, how they map to a decision, and the result you read back, from the server SDK or a webhook.

Session statuses

A session’s status is its lifecycle state. Progress statuses advance as the user completes each step; the flow then settles on a terminal status (or waits in requires_review for a human). Compare with exact, lowercase strings; these are stable identifiers, not display copy.

statusTerminal?Meaning
pendingnoSession created; the client token is minted but the user hasn’t started.
startednoThe user opened the widget and began the flow.
document_submittednoDocument image(s) uploaded.
address_submittednoAddress step completed (only if the workflow includes it).
liveness_submittednoSelfie / liveness captured.
processingnoRunning OCR, liveness, face match, and the decision engine.
requires_reviewsoftAuto-decision was inconclusive; waiting on a human reviewer.
approvedyesIdentity verified; all checks passed (auto or after review).
rejectedyesVerification failed, or a reviewer rejected it.
expiredyesThe session’s TTL elapsed before the user finished.
cancelledyesCancelled via the API or the dashboard.

requires_review is soft-terminal: the session stops advancing on its own, but a reviewer moves it to approved or rejected. Treat it as “decision pending,” not “done.”

Decisions

Two decision fields sit alongside the status. auto_decision is the engine’s output (approved · requires_review · rejected); final_decision is the decision of record, equal to auto_decision unless a human review overrode it, and null while a session is still in review. Branch on `final_decision` (falling back to the terminal status); auto_decision is informational.

Decision reasons

decision_reason explains why a session reached its decision; it’s a stable enum you can branch on. The *_LOW_CONFIDENCE reasons route to requires_review (ambiguous signal) rather than an outright rejected.

Groupdecision_reason
ApprovedAUTO_APPROVED, MANUAL_APPROVAL
DocumentLOW_DOCUMENT_QUALITY, OCR_LOW_CONFIDENCE, DOCUMENT_EXPIRED
LivenessLIVENESS_FAILED, LIVENESS_LOW_CONFIDENCE
Face matchFACE_MATCH_FAILED, FACE_MATCH_LOW_CONFIDENCE, MULTIPLE_FACES_DETECTED
AddressADDRESS_VERIFICATION_FAILED, ADDRESS_LOW_CONFIDENCE
Manual / flowMANUAL_REJECTION, RETRY_REQUESTED

Reading a result

Retrieve a session at any time from your backend:

result.ts
const session = await arkyc.sessions.retrieve(sessionId)

if (session.final_decision === "approved") grantAccess()
else if (session.status === "requires_review") waitForReview()
else denyOrRetry(session.decision_reason)   // e.g. DOCUMENT_EXPIRED → offer a fresh session

Key fields: status, auto_decision, final_decision, decision_reason, risk_score (aggregate risk in [0, 1], higher is riskier), name (OCR-extracted, when available), user_reference, and completed_at. Per-check detail (document quality/OCR, liveness score, face-match similarity) rides on the webhook under checks.

The extracted identity and address data itself (name, date of birth, document number, address) is separate and more restricted: it is read only through the server SDK’s retrieve() (secret key), never sent to the widget or webhooks, and only when the project holds a granted PII entitlement.

Handling each outcome

OutcomeWhat to do
final_decision: approvedGrant access; persist the verification against your user.
final_decision: rejectedDeny; optionally offer a fresh session if the reason is correctable.
status: requires_reviewWait, and don’t grant access. A reviewer settles it; you’ll get a follow-up webhook.
status: expired / cancelledNothing verified. Create a new session and re-prompt.

The widget’s onComplete is a UX signal only. Treat the webhook (or a server-side retrieve) as your source of truth; the browser can close before the decision settles.

Asset URLs

The captured images are exposed as signed, time-limited links under assets on the session and on webhook deliveries, one key per image that exists: assets.document_front, assets.document_back, and assets.selfie. Each is a public, HMAC-signed URL (no API key), so it’s safe to hand to a third party such as your own KYC provider in a capture-only flow.

The default lifetime is 30 minutes (a platform admin can change it, bounded to 60 seconds to 24 hours). After it lapses the link returns HTTP 403; the image itself is retained. URLs are minted fresh on every read, so fetch a new link by retrieving the session again (or from the next webhook). Treat them as short-lived: download the bytes promptly or re-fetch, and don’t persist the URLs.

Session expiry

A session is valid for 15 minutes from creation, and its client token expires with it. If the user doesn’t finish in time, the session moves to expired and further submissions are rejected. The window is fixed; to retry an expired or abandoned user, create a new session and hand the fresh token to the widget.

Idempotent creation

sessions.create is not idempotent: each call opens a new session with its own token. To avoid stacking duplicates for one user (a double submit, a retried request, a re-render), dedupe on your side. Store the returned session.id keyed by your userReference and reuse a session that’s still in progress; only start a fresh one once the previous is terminal or expired.

Supported documents

The widget captures four document types (passport, id_card, drivers_license, residence_permit) and OCR reads a common set of identity fields when present: name, date of birth, document number, expiry date, and nationality. These land on the session (and name on the result); the raw images are available as signed URLs for capture-only flows.