Most integrations should use the managed IDV module through `mountProvisioned` or `mount('idv')` (see the [Quickstart](/developers/sdks/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)

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.

```ts
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 (each `CaptureState` kind, including capture phases, the cv-reject reason, `submit_error`, `token_expired`, and `mint_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 terminal `all-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

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)

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.

```ts
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

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?

| You want                              | Use                                                                                 |
| ------------------------------------- | ----------------------------------------------------------------------------------- |
| The standard managed experience       | `mount('idv')` / `mountProvisioned` (see [Quickstart](/developers/sdks/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](/developers/sdks/versioning).

## Related pages

- [Modules overview](/developers/sdks/modules) - the full public surface
- [Cross-device handoff](/developers/sdks/cross-device) - open the handoff overlay from a custom renderer
- [Quickstart](/developers/sdks/quickstart) - the managed integration path most teams should use
- [Error reference](/developers/sdks/error-reference) - every error code and its recovery step