Most integrations should use the managed IDV module through mountProvisioned or mount('idv') (see the Quickstart). The managed module resolves the session, drives capture, handles recovery, and translates everything into the checktiv.idv.* event stream for you.
This page is for the rare case where you are building a fully custom capture renderer and need lower-level control than the managed module gives. It exposes two tiers:
- Tier-1
./capture- a headless capture core (createCaptureController). It owns the capture logic and exposes state and events; you render every pixel. - Tier-2
./capture-ui- a batteries-included renderer (mount()). It renders the default capture surface for you and keeps the injected transport seam.
If you are unsure which to use, use the managed ./idv module instead. Reach for these only when you need to own the rendering.
Tier-1: createCaptureController (headless)
Section titled “Tier-1: createCaptureController (headless)”The controller owns the capture frame handshake, state machine, and submit flow. It renders no UI. You provide the dependencies (how to mint a capture token, how to submit, how to resolve the frame source) and subscribe to state and events to render your own surface.
import { createCaptureController } from '@checktiv/sdk-web/capture';
const controller = createCaptureController({ // Lazy accessor for the iframe element you render. Read at handshake time, // not captured once (the frame mounts only after the mint resolves). getIframe: () => document.querySelector('iframe.my-capture-frame'),
// Mint a per-run capture token plus the validated capture origin and run index. mintToken: async () => { const res = await fetch('/api/capture/mint', { method: 'POST' }); return await res.json(); // { captureToken, embedOrigin, runIndex } },
// Submit the captured artifacts for a run. Returns a typed outcome. May // navigate away and never resolve (your reload/redirect can live here). submit: async (runIndex, r2Keys) => { const res = await fetch('/api/capture/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ runIndex, r2Keys }), }); if (res.ok) return { ok: true }; if (res.status === 401) return { ok: false, reason: 'token_expired' }; return { ok: false, reason: 'failed' }; },
// Resolve the iframe src for the validated capture origin. getEmbedSrc: (embedOrigin) => `${embedOrigin}/capture`,
// Typed init payload (theme, locale, config). The controller injects the // capture token itself; never put the token in this payload. initPayload: () => myInitPayload,});
// Subscribe to state, listen for one-shot events, then start.const unsubscribe = controller.subscribe((state) => renderMySurface(state));const offComplete = controller.on('all-complete', () => advanceJourney());controller.start();
// Teardown cancels any in-flight mint and removes the message listener.// unsubscribe(); offComplete(); controller.destroy();The controller exposes two complementary surfaces:
subscribe(state)carries every persistent or terminal condition (eachCaptureStatekind, including capture phases, the cv-reject reason,submit_error,token_expired, andmint_error). This is the only error surface, so you handle errors in one place.on(type, cb)carries one-shot impulses that are not state:selfie-cv-reject(with its attempt index) and the terminalall-complete.
start() is idempotent, and destroy() cancels an in-flight mint and removes the frame message listener, so the controller is safe under React StrictMode double-invocation.
Security: the origin handshake
Section titled “Security: the origin handshake”The controller communicates with the capture frame over postMessage. It pins every inbound message to the exact capture origin returned by your mintToken call and validates the message shape before acting on it. A message from any other origin is ignored. Do not relax this check: it is the trust boundary that stops a hostile frame or page from spoofing capture events. Your mintToken must return the real validated embedOrigin, and your getEmbedSrc must build the frame source from it.
Tier-2: mount() (batteries-included renderer)
Section titled “Tier-2: mount() (batteries-included renderer)”If you want a custom transport but not a custom renderer, use mount() from ./capture-ui. It creates a Tier-1 controller from the same injected dependencies (minus getIframe, which the renderer owns) and renders the default capture surface: the capture frame, a per-phase status line, per-error affordances, and the persistent cv-reject banner.
import { mount } from '@checktiv/sdk-web/capture-ui';
const handle = mount(document.getElementById('capture-container'), { mintToken, // same shape as Tier-1 submit, // same shape as Tier-1 getEmbedSrc, // same shape as Tier-1 initPayload, // same shape as Tier-1 copy: {}, // injected white-label copy; empty object = English fallbacks});
// handle.destroy() unmounts the surface and tears the controller down.mount() returns a { destroy } handle. It does not add a transport of its own: your mintToken, submit, getEmbedSrc, and initPayload flow straight through to the controller, so you keep full control of the network layer while getting the default UI for free.
Copy is injected
Section titled “Copy is injected”The Tier-2 renderer takes all user-facing text through the copy option, a map of functions and strings (status line, coaching guidance, inline error text, terminal-state text, the retry label, and the frame title). Every key is optional; an absent key falls back to a built-in English string. The renderer never bundles a translation runtime, so pass copy: {} for the English defaults or build the map from your own locale catalog.
Every copy value renders as plain text, never as HTML.
Which path should I use?
Section titled “Which path should I use?”| You want | Use |
|---|---|
| The standard managed experience | mount('idv') / mountProvisioned (see Quickstart) |
| A custom transport but the default UI | Tier-2 mount() from ./capture-ui |
| A fully custom renderer | Tier-1 createCaptureController from ./capture |
All three paths share the same session, token, and event contracts, so you can move between them without a breaking change. See Versioning.
Related pages
Section titled “Related pages”- Modules overview - the full public surface
- Cross-device handoff - open the handoff overlay from a custom renderer
- Quickstart - the managed integration path most teams should use
- Error reference - every error code and its recovery step