Skip to content

Cross-device handoff

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.
  • The standalone exports drive a fully custom renderer. If you build your own IDV renderer on the capture core, 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.

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.

If you render the managed module with mount('idv', ...), cross-device is built in. Supply a crossDeviceCopy map (see 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:

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().

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:

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

Section titled “Open the overlay from a custom IDV renderer”

If you build your own IDV renderer on the capture core (see Advanced capture), open the overlay with openCrossDeviceOverlay:

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).

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.

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.

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

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.

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.