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.
status | Terminal? | Meaning |
|---|---|---|
pending | no | Session created; the client token is minted but the user hasn’t started. |
started | no | The user opened the widget and began the flow. |
document_submitted | no | Document image(s) uploaded. |
address_submitted | no | Address step completed (only if the workflow includes it). |
liveness_submitted | no | Selfie / liveness captured. |
processing | no | Running OCR, liveness, face match, and the decision engine. |
requires_review | soft | Auto-decision was inconclusive; waiting on a human reviewer. |
approved | yes | Identity verified; all checks passed (auto or after review). |
rejected | yes | Verification failed, or a reviewer rejected it. |
expired | yes | The session’s TTL elapsed before the user finished. |
cancelled | yes | Cancelled 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.
| Group | decision_reason |
|---|---|
| Approved | AUTO_APPROVED, MANUAL_APPROVAL |
| Document | LOW_DOCUMENT_QUALITY, OCR_LOW_CONFIDENCE, DOCUMENT_EXPIRED |
| Liveness | LIVENESS_FAILED, LIVENESS_LOW_CONFIDENCE |
| Face match | FACE_MATCH_FAILED, FACE_MATCH_LOW_CONFIDENCE, MULTIPLE_FACES_DETECTED |
| Address | ADDRESS_VERIFICATION_FAILED, ADDRESS_LOW_CONFIDENCE |
| Manual / flow | MANUAL_REJECTION, RETRY_REQUESTED |
Reading a result
Retrieve a session at any time from your backend:
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 sessionKey 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
| Outcome | What to do |
|---|---|
final_decision: approved | Grant access; persist the verification against your user. |
final_decision: rejected | Deny; optionally offer a fresh session if the reason is correctable. |
status: requires_review | Wait, and don’t grant access. A reviewer settles it; you’ll get a follow-up webhook. |
status: expired / cancelled | Nothing 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.
