Part 2 · 2 chapters · ~25 min

KYC Journeys

Verification as a state machine with more states than pending and verified, resumable across days and devices with nothing asked twice, honest review states with estimates and references, rejections that map to a specific action and step, tiers as a path, and document capture with on-device checks, a large review before upload, liveness explained gently, and resumable uploads.

6

The journey as a state machine

code
// the journey as a server-held state machine the client resumes; a draft for the current step lives locally
type Step = 'details' | 'document' | 'selfie' | 'address' | 'funds'
type Journey = { id: string; tier: 1 | 2 | 3; completed: Step[]; current: Step | null; review: ReviewState; required: Step[] }   // required comes from country + target tier config
type ReviewState = { kind: 'none' } | { kind: 'submitted' } | { kind: 'auto' } | { kind: 'manual'; eta: string; ref: string }
  | { kind: 'verified'; tier: 1 | 2 | 3 } | { kind: 'resubmit'; step: Step; reason: ReasonCode; message: string }
  | { kind: 'rejected'; category: 'hard' | 'soft'; message: string } | { kind: 'hold'; message: string; contact: string }

const journey = useQuery({ queryKey: ['kyc'], queryFn: api.kyc.get, refetchInterval: j => j?.review.kind === 'auto' ? 3000 : j?.review.kind === 'manual' ? 60_000 : false })
// resume: the first required step not in completed; on any device, after any gap
const next = journey.data?.required.find(s => !journey.data.completed.includes(s)) ?? null

// the draft for the current step: survives reload, tab sleep, and the bus tunnel; cleared when the step is saved server-side
const [draft, setDraft] = usePersistedState(`kyc-draft-${journey.data?.id}-${next}`, {})
const save = useMutation({ mutationFn: (d: StepData) => api.kyc.saveStep(next!, d, { idempotencyKey: draftKey }), onSuccess: () => { clearDraft(); queryClient.invalidateQueries({ queryKey: ['kyc'] }) } })

// uploads: resumable, direct to storage; the step is "saved" only when the upload completes and the server has the file key
// the review screen renders by review.kind: each kind has its own copy, estimate, reference and next action; 'resubmit' deep-links to that step with the previous attempt shown
// REASON_ACTIONS: { DOC_BLUR: { step: 'document', text: 'The photo was too dark or blurry. Retake it in good light with all four corners visible.' }, NAME_MISMATCH: { step: 'details', field: 'name', text: '…' }, … }
steps, states, resumability
  1. The steps: details (name as on the document, date of birth, address, ID number), a document (types the country allows; front and back), a liveness selfie, and for higher tiers proof of address and source of funds. The required set comes from country and target tier configuration the client reads, never hardcodes. Each step is saved server-side as it completes.
  2. The states after submission: submitted; automatic review (OCR, face match, liveness score, sanctions and PEP screening: seconds to minutes); manual review (a person, with an estimate and a reference: hours to days); verified at a tier; resubmission requested for one step; rejected by category; on hold (told in limited terms, with a human contact). "Pending" hides six states the user needs to tell apart.
  3. Resumability is the biggest driver of completion: every completed step and upload is server-side; the journey resumes at the first incomplete step on any device after any gap; the current step's draft lives in the client and syncs; an interrupted upload resumes (the FSD course M3). Nothing is asked twice.
honesty, rejection, tiers
  1. Honest review states: "Checking your documents, usually a few minutes" then "A member of our team is reviewing. Usually within 2 hours; up to 2 business days. We will notify you." with a reference and timestamp. Never a spinner for a day; never pending with no estimate. A push on state change (the FSD course M7) closes the loop; the state shows on the home screen on return.
  2. Rejection maps to an action and a step: "The photo was too dark: retake in good light with all four corners visible" goes to the document step with the previous attempt visible; "Your name does not match: edit it here" highlights the field. A hard rejection says what it may, offers a person, and does not loop the user through the steps. "Rejected" alone is abandonment.
  3. Tiers: verification is incremental; the UI shows the user's tier, what it allows, what the next unlocks, and the exact steps there. "Upgrade to send more" is a path, not a wall. The CBA module's limits are the tier; the UI is where the user sees them.
THE KYC JOURNEY AS A STATE MACHINE
steps the user completes, states the review assigns, and the paths back from every rejection
swipe the figure sideways, or tap expand for full screen
1/6
the steps
The steps: personal details (name as on the document, date of birth, address, nationality, ID number); a document (type chosen from what the country allows: national ID, passport, driving licence; front and back); a selfie with liveness (a short video or guided movements, so a photo of a photo fails); sometimes proof of address (a utility bill) and source of funds (for higher tiers). Each step is saved server-side as it completes.
7

Document capture and liveness

the capture experience
  1. The camera: getUserMedia with the rear camera for documents and the front for the selfie at the highest resolution; a video with the guide overlay; a full-resolution capture (ImageCapture.takePhoto, or a canvas draw). Permission denied is a designed state: explain why, then offer file upload. The user with no camera has a path.
  2. On-device checks before capture: the document inside the outline (edge detection or the vendor's detector); focus (a sharpness score from a Laplacian on a downscaled frame); exposure (a histogram); glare (a bright blob); for the selfie, one centred face of sufficient size. One instruction at a time ("move closer", "too dark", "hold still"); auto-capture when the checks pass. A retake before upload beats a rejection after review.
  3. Review before upload: the capture shown large with "Is everything readable and in frame?" and retake or continue. A thumbnail hides the blur the reviewer will reject; the user is the best reviewer of their own capture if shown it properly.
  4. Liveness: active (follow a dot, turn the head, blink: explicit and replay-resistant) or passive (a short clip analysed for texture, depth and micro-motion: faster, weaker). The vendor SDK decides and scores; the client explains before the camera opens, keeps the instruction on screen, limits retries, and names the failure gently ("we could not confirm it was a live person: try again in better light, without glasses"), because "they think I am not real" is frightening.
  5. What is sent: images by resumable upload direct to storage with a signed URL and progress; metadata (device, timestamps, on-device scores) alongside; the SDK's liveness payload. No images kept past the session; a crash resumes the upload rather than recapturing.
  6. Edge cases that are populations: unsteady hands (a longer window, a tripod prompt); visual impairment (voice guidance for the overlay); non-Latin names (OCR and matching that handle the script); IDs without a machine-readable zone; no camera. The journey must not end for any of them.
the exercise
Run your own KYC flow on a phone in a dim room with a cracked ID, lose the connection halfway, and come back the next day on a laptop. Count what you were asked twice and every state that was a spinner. Each is a user who gave up.
DOCUMENT CAPTURE AND LIVENESS
the camera, the guide, the checks that run on the device, and what is sent
swipe the figure sideways, or tap expand for full screen
1/6
the camera
The camera: getUserMedia with the rear camera for documents (facingMode: environment) and the front for the selfie; the highest resolution the device offers; a video element with the guide drawn over it; a capture that grabs a full-resolution frame (ImageCapture.takePhoto where available; a canvas draw of the video frame otherwise). Permission denied is a designed state: explain why the camera is needed and offer file upload as the fallback.