Kwikpik KYC
Self-hosted identity verification, liveness in the browser.
An embeddable identity-verification platform that brought liveness, face matching and address verification in-house — a third-party provider is only used for ID document analysis and business registry lookups.
- role
- Lead frontend · architecture
- year
- 2026
- platform
- Web · embeddable widget
- live
- private
- stack
- Next.js 16TypeScriptMediaPipeTensorFlow.jsVitest

The problem
Kwikpik moves money, so every user has to be verified — and every check sent to a vendor cost money, took seconds, and failed in ways we couldn't see. When a selfie failed, the user got "verification failed" and nothing else. Support tickets came in faster than verifications went through.
We decided to bring the hard parts in-house: liveness, face matching and proof of address. A third-party provider is still used for what it's genuinely best at — reading ID documents and looking companies up in the business registry.
What I built
An embeddable, self-hosted verification platform. A partner drops a link or an iframe into their app; the platform runs a server-driven step flow and reports a single verdict.
- A server-driven plan. The server decides the next step from an explicit state machine. The client never infers what comes next.
buildPlan()is the only branch: a business gets a longer plan (registry document → supporting documents → signature) that then reuses the same step objects as the personal flow, so the two can't drift. - In-browser liveness. A MediaPipe challenge (turn left, blink, turn right) runs on-device and coaches the user in real time — glare, exposure, blur, "move closer". The browser's run is submitted as an untrusted attestation; the server makes the call with anti-spoof scoring off the face crop.
- Server-side face matching with TensorFlow.js, against the portrait on the ID.
- Single-use signed capture tickets and optimistic concurrency, so a replayed or double-submitted step is rejected rather than counted.
- A hand-rolled UI themeable through CSS custom properties, so a partner can match it to their brand without forking anything.
The hard part: a client that survives vendors
Every external call goes through one shared, hardened HTTP client:
- pluggable auth — bearer, HMAC request signing, OAuth2 client credentials
- exponential backoff with jitter that honours
Retry-After - no retries on 4xx — a bad request is a bug or bad input, never a reason to hammer
- per-step idempotency keys, so a retry can't create a second verification
- request timeouts and a circuit breaker, so a provider outage fails fast instead of piling up
- typed, redacted error logs — no PII in the logs, ever
A production run failed liveness seven times at attempt 1. Two opposite situations — "the model didn't load" and "the model ran and couldn't find a face" — were collapsing into the same retryable error, which by design burned no attempt. The user could neither pass nor reach manual review. Splitting the error by reason gave the second case a real attempt and a real exit.
Outcome
- Liveness verdicts no longer depend on a vendor round-trip, and no selfie leaves our infrastructure for a liveness check.
- Users get coached before they fail, instead of being told why afterwards.
- 19 Vitest suites cover the state machine, the HTTP client's retry and breaker rules, and the step handlers.