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, mode?, testHint? } },
// Submit the captured artifacts for a run. Returns a typed outcome. May // navigate away and never resolve (your reload/redirect can live here). // `acquisition` is 'camera' or 'upload' - see "How the document was acquired". submit: async (runIndex, r2Keys, acquisition) => { const res = await fetch('/api/capture/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ runIndex, r2Keys, acquisition }), }); 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. This carries mount-static data only (theme, locale, // config). The controller injects the capture token itself, and it injects the // mint-sourced fields from your mintToken result, so this payload no longer // accepts `mode` or `testHint`. 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();If you run test-mode verifications, your mintToken must return mode: 'test' (and the testHint your mint endpoint returns, when it returns one). That value is what switches the capture frame to its simulated flow: without it the frame opens the real camera and captures real images on a session you expect to be synthetic.
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.
How the document was acquired
Section titled “How the document was acquired”Your submit receives a third argument, acquisition, alongside runIndex and r2Keys. It is optional in the type and always supplied by the controller. It is 'camera' when the applicant photographed the document during the verification and 'upload' when they supplied an existing photo of it, which the capture frame offers when the workflow template turns on Allow photo upload.
Forward it to your submit endpoint. It is what makes the verification record how its document arrived, so your reviewers see a Document source on the applicant detail page and on the exported report. Drop it and the run is recorded as a camera capture, because that is the value the platform assumes when nothing says otherwise, and nothing will tell you the label is wrong.
Two properties worth knowing before you build anything on it:
- It is declared, not proved. The value originates in the browser, so anyone who can tamper with the page can change it, exactly as with every other client-supplied field. It is honest reviewer context, not a control. Do not gate anything of your own on it.
- It does not change the verdict. An uploaded document runs the same checks as a photographed one and can pass automatically. The platform records the difference rather than scoring it.
An existing two-parameter submit keeps working, in both directions. Writing a two-parameter function is fine: a narrower closure is still assignable, so an integration written before this argument existed compiles and runs unchanged, recording every run as a camera capture, which is correct until you enable photo upload on a workflow template. Calling submit yourself is fine too: the argument is optional, so a wrapper of your own that forwards only runIndex and r2Keys still compiles. If you wrap submit to add retries or telemetry, forward all three arguments, or the label is lost for every uploaded document. See Document upload.
The initPayload config
Section titled “The initPayload config”initPayload().config is the identity-verification settings the capture frame renders from. minAge and allowDocumentUpload are optional; every other field is required. Two of them decide what the applicant is offered:
biometricMode('none' | 'selfie' | 'liveness') selects the face check that follows the document.allowDocumentUpload(boolean, optional) selects whether the upload option appears beside the camera. Leave it out and the frame offers the camera alone, so an integration written before the option existed keeps compiling and keeps behaving exactly as it did.
Build the object from what your backend read for the verification rather than hardcoding it, or the frame renders a flow the workflow template did not ask for. In particular, a hardcoded allowDocumentUpload: false does not track the workflow template: turning Allow photo upload on in the console will not reach the applicant until your page forwards the verification’s own value.
Localized text on the capture frame
Section titled “Localized text on the capture frame”Before any capture starts, the frame shows the applicant a short screen: a line of guidance, a button that starts the camera, and, when the workflow template turns on Allow photo upload, an upload panel that button screen can open. Pass initPayload().beginScanCopy to render that text in your applicant’s language:
initPayload: () => ({ ...myInitPayload, beginScanCopy: { prompt: 'Lorsque vous etes pret, touchez le bouton pour scanner votre document.', label: 'Commencer', },}),Every slot is optional, and one you leave out falls back to the frame’s built-in English. A partial translation therefore degrades a single line instead of blanking the screen, and passing nothing at all gives you English throughout.
beginScanPrompt and beginScanLabel are the older flat spelling of those first two slots. They are deprecated but fully supported: an integration that still sets them compiles and renders its own text exactly as it did. Move them inside beginScanCopy when it suits you. If you set both, the beginScanCopy slot wins, one slot at a time, so a half-finished migration never drops the string you have not moved yet.
When your page blocks the camera
Section titled “When your page blocks the camera”Both tiers run one extra check for you while the capture frame starts up: if your page’s Permissions-Policy declares a camera allowlist that does not reach the capture frame, whether it omits the capture origin or drops self, the frame can never open the camera, and the SDK writes a [Checktiv] line to the browser console naming the capture origin and the header to add. That needs no wiring and happens on every path.
The SDK does not act on that finding by itself. It remembers it, and if a capture failure then arrives, the failure is marked as caused by the page policy. On the controller’s state, a capture_error carries cameraPolicyBlocked: true:
controller.subscribe((state) => { if (state.kind === 'capture_error' && state.cameraPolicyBlocked === true) { // The camera was refused by your page's header, not by the applicant's // device. Another device will not help: it receives the same header. showContactSupportState(); return; } if (state.kind === 'capture_error') { showRetryOrCrossDevice(state.errorKind); // an ordinary device problem }});Treat it as terminal rather than retryable, and do not offer a second device. The managed paths surface the same conclusion as camera_policy_blocked on onEvent.
On Tier-2 you get this for free. The default renderer already shows the applicant copy that says the page is not allowing the camera, that it is not their device, and that another device will not help, and it suppresses its own retry button (a reload re-sends the same header). You only need the state field above if you build your own UI on Tier-1.
The check is deliberately conservative in both directions. Detection needs a non-standard browser API that Chrome and Edge implement and other browsers may not, so cameraPolicyBlocked never appears where the API is absent, and its absence is not proof your header is correct. And because the flag only ever appears alongside a real capture failure, a wrong detection on a correctly-configured page costs nothing: no failure arrives, so nothing is marked. See Security headers.
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.
One failure has its own copy key rather than going through copy.error: when the SDK attributes a capture failure to your page’s camera policy, it resolves copy.cameraPolicyBlocked instead.
copy: { error: (kind) => myMessageFor(kind), // Optional. Omit it and the SDK uses its own text for this state. cameraPolicyBlocked: () => myPagePolicyMessage(),}That state is separate on purpose. The renderer suppresses its retry button there, because a reload re-sends the same page header. If the explanation came through copy.error, which is keyed only on the error kind, an implementation written before this state existed would answer it with that kind’s ordinary “try again” text, and the applicant would read “try again” with no control to try again. A separate key cannot be answered by accident.
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