Some applicants start a verification on a desktop that has no usable camera. Cross-device handoff lets them continue on their phone: the SDK shows a QR code and a copy-link control, the applicant opens the link on their phone, and your host page reloads once the phone finishes. The SDK never writes `window.location`: navigation is always your host's, off the emitted events.

How you reach it depends on how you render capture:

- **The managed IDV module owns cross-device.** When you render the managed module with `mount('idv', ...)`, the returned handle exposes `openCrossDevice()`. It opens the overlay, runs the completion poll, and emits `checktiv.idv.submitted` when the phone finishes so your host reloads. On the managed working-token plane it can also mint the one-time link itself, so you supply a mint hook only to override that. See [From the managed IDV module](#from-the-managed-idv-module).
- **The standalone exports drive a fully custom renderer.** If you build your own IDV renderer on the [capture core](/developers/sdks/capture), the `./idv/cross-device` subpath (`openCrossDeviceOverlay`, `preloadCrossDeviceChunk`) manages the overlay lifecycle, and `./cross-device` (`mountCrossDevice`) is the bare panel for a screen you fully control. On these standalone surfaces you supply the mint hook yourself.

This is an advanced surface; the internal Checktiv verify journey uses it, and white-label hosts building a custom IDV renderer can use it too.

## When to use it

Offer cross-device handoff when a desktop applicant needs a phone camera: for example, after a `camera_denied` or `sdk_load_failed` error whose recovery is `cross_device`, or as a proactive "continue on your phone" affordance next to the capture frame. On a phone, the panel drops the QR (scanning your own screen is circular) and instead offers a try-again control, an open-in-browser hint, and a copy-link.

## From the managed IDV module

If you render the managed module with `mount('idv', ...)`, cross-device is built in. Supply a `crossDeviceCopy` map (see [Copy is injected](#copy-is-injected)) so the overlay has strings to render, render your own "continue on your phone" control, and call `openCrossDevice()` on the returned handle:

```ts
import { init } from '@checktiv/sdk-web';
import '@checktiv/sdk-web/idv';
import '@checktiv/sdk-web/idv/cross-device'; // enables the overlay (registers the opener)

const client = init({ publishableKey: 'ah_pk_us_test_...', getSessionToken });

const handle = client.mount('idv', {
  target: document.getElementById('idv-container'),
  crossDeviceCopy, // host-injected strings; required for the overlay to render
  onEvent: (event) => {
    if (event.type === 'checktiv.idv.submitted') {
      window.location.reload(); // the phone finished; land on the completed state
    }
  },
});

// Wire your own trigger control to open the handoff:
document.getElementById('go-mobile').onclick = () => handle.openCrossDevice?.();
```

You must import `@checktiv/sdk-web/idv/cross-device` (the CDN bundle already includes it) so the overlay is available. Without it, `openCrossDevice()` is a graceful no-op that logs an actionable warning rather than a dead trigger; it is also a no-op if the applicant's verification has not resolved yet or if you did not supply `crossDeviceCopy`.

On the managed working-token plane, the module mints the one-time link itself (it calls its own handoff endpoint authenticated by the applicant's short-lived working token), runs the completion poll for you, and caps the number of "refresh link" re-mints per overlay so a stuck flow cannot churn links. To point the mint at your own backend instead - for example when you already run a handoff endpoint, or when your integration mints links on your server - pass a mint hook as `onOpenCrossDevice` on the mount options (see below). `mountProvisioned` and the zero-lifecycle `Checktiv.mount` entry do not expose the overlay; use the explicit `mount('idv', ...)` call when you need `openCrossDevice()`.

## The mint hook

On the standalone exports (and as the managed-module override) your host supplies a mint hook that produces a one-time link URL. For a custom renderer this hook is required; for the managed module it overrides the built-in mint:

```ts
import type { CrossDeviceHandoffHook } from '@checktiv/sdk-web';

const onOpenCrossDevice: CrossDeviceHandoffHook = async () => {
  const res = await fetch('/api/cross-device/mint-otl', { method: 'POST' });
  if (!res.ok) return { kind: 'unavailable' };
  const { url } = await res.json();
  return { kind: 'ok', url }; // url MUST be https:
};
```

The URL must be `https:`. The SDK validates it and collapses any `javascript:`, `http:`, or `data:` scheme to the unavailable state, so a bad URL can never reach the QR image or the clipboard. The hook is re-callable: the panel offers a "refresh link" affordance, and the SDK caps the number of re-mints per open so a stuck flow cannot churn links.

A short-lived browser token (`bt_*`) cannot mint a handoff link, so a custom renderer on the browser-token plane must supply this hook, backed by a server-held credential. The managed module's built-in mint works only on the working-token plane, where it authenticates with the applicant's own working token; supply the hook to override it or to run on the browser-token plane.

## Open the overlay from a custom IDV renderer

If you build your own IDV renderer on the capture core (see [Advanced capture](/developers/sdks/capture)), open the overlay with `openCrossDeviceOverlay`:

```ts
import {
  openCrossDeviceOverlay,
  preloadCrossDeviceChunk,
} from '@checktiv/sdk-web/idv/cross-device';

// Warm the lazy chunk when the IDV step becomes active so the overlay opens instantly.
preloadCrossDeviceChunk();

const overlay = openCrossDeviceOverlay({
  target: containerElement, // the module's root container
  onOpenCrossDevice, // your mint hook from above
  copy: crossDeviceCopy, // host-injected strings; see below
  isMobile: false, // true suppresses the QR on a phone
  emit: (event) => onEvent(event), // forward to your event handler
  onClose: () => closeOverlay(), // the applicant pressed back
});
```

The returned handle has two methods:

- `setCompleting()` - mark the session as completing. The panel keeps the QR mounted with a waiting label instead of disappearing while your page reloads.
- `destroy()` - tear the overlay down. Idempotent.

`openCrossDeviceOverlay` also accepts an optional `poll` config so the overlay can detect completion for you (see [Completion](#completion)).

The overlay emits token-free events (none carries the URL, the link, or any code):

| Event type                              | Meaning                                                                                 |
| --------------------------------------- | --------------------------------------------------------------------------------------- |
| `checktiv.idv.cross_device_opened`      | The mint hook returned `ok` and the overlay is shown.                                   |
| `checktiv.idv.cross_device_unavailable` | The mint hook returned `unavailable`, or URL validation failed.                         |
| `checktiv.idv.cross_device_capped`      | The completion poll reached its total time cap without the phone finishing (see below). |

`checktiv.idv.cross_device_capped` fires only when a completion poll is running: the managed module emits it at the cap, and the standalone overlay emits it if you wire the poll's `onCapped` callback. At the cap the QR panel stays mounted so the applicant can still finish on their phone - it is not an error and not a verdict.

## Completion

The managed module runs the completion poll for you: when the phone advances past the desktop's step, it emits `checktiv.idv.submitted` so your host reloads.

On the standalone overlay, completion is the host's job unless you inject a `poll` config. Wire `poll.checkCompletion` (return `advanced` / `pending` / `terminal`) plus `onComplete` / `onCapped` / `onTerminal`, and the overlay runs a single-flight poll while it is open. If you omit `poll`, detect completion yourself: your backend advances the verification, your host polls your own status endpoint and calls `window.location.reload()` exactly once when it reports the step advanced. Either way, call `setCompleting()` on the handle just before you reload so the overlay shows a completing state rather than vanishing abruptly. The SDK never writes `window.location`.

## Mount the panel directly

If you only need the panel itself (for example, on a screen you fully control), mount it with `mountCrossDevice`:

```ts
import { mountCrossDevice } from '@checktiv/sdk-web/cross-device';

const handle = mountCrossDevice(containerElement, {
  url: 'https://verify.us.checktiv.com/handoff/...', // the https: link you minted
  copy: crossDeviceCopy,
  isMobile: false,
});

// Later, re-render with new props (for example a refreshed url), or tear down:
handle.update({ url: newUrl, copy: crossDeviceCopy, isMobile: false });
handle.destroy();
```

Always render the panel through `mountCrossDevice` (never render its internal component yourself): the chunk owns its own rendering so its interactive controls work correctly.

## Copy is injected

Every user-facing string comes from a `copy` map your host supplies, resolved to the applicant's locale. All keys are optional except `unavailableMessage`, which is required so the unavailable state is never blank. Omitting an optional key hides that affordance rather than showing a placeholder.

## Related pages

- [Modules overview](/developers/sdks/modules) - the full public surface
- [Advanced capture](/developers/sdks/capture) - build a custom IDV renderer that opens this overlay
- [Error reference](/developers/sdks/error-reference) - the errors whose recovery is `cross_device`
- [Quickstart](/developers/sdks/quickstart) - the managed integration path