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 zero-lifecycle
mount()/<ChecktivJourney>path (recommended). The handle returned bymount(), or reached through a<ChecktivJourney>ref, exposesopenCrossDevice(). On this path the SDK mints the one-time link itself, opens the overlay, runs the completion poll, and fires youronCompletewhen the phone finishes. See From the zero-lifecycle mount. - The explicit
mount('idv', ...)override. When you drive the managed IDV module directly on aninit()client, its handle exposes the sameopenCrossDevice(). See From the explicit IDV mount. - The standalone exports drive a fully custom renderer. If you build your own IDV renderer on the capture core, the
./idv/cross-devicesubpath (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.
The managed paths (the first two) are the common ones; the standalone exports are an advanced surface for hosts building a custom IDV renderer.
When to use it
Section titled “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 zero-lifecycle mount
Section titled “From the zero-lifecycle mount”The zero-lifecycle mount() entry (and its <ChecktivJourney> React wrapper) is the recommended path. Its handle exposes openCrossDevice(), so you render your own “Continue on your phone” trigger and call it: the SDK owns the mint, the overlay, and the completion poll. Supply a crossDeviceCopy map (see Copy is injected) so the overlay has strings to render.
import { mount } from '@checktiv/sdk-web';import '@checktiv/sdk-web/idv';import '@checktiv/sdk-web/idv/cross-device'; // enables the overlay (registers the opener)
const handle = mount(document.getElementById('checktiv-container'), { publishableKey: 'ah_pk_us_test_...', fetchToken, crossDeviceCopy, // host-injected strings; required for the overlay to render onComplete: () => { window.location.reload(); // the phone finished; land on the completed state }, onEvent: (event) => { // Recover a handoff that dies mid-flow (see "Recover a dead handoff" below). if (event.type === 'checktiv.idv.error' && event.error.code === 'session_expired') { showRestartPrompt(); } if (event.type === 'checktiv.idv.cross_device_capped') { showStillWaitingHint(); } },});
// Gate the trigger to a desktop (fine-pointer) device, then wire it:if (window.matchMedia('(pointer: fine)').matches) { document.getElementById('go-mobile').onclick = () => handle.openCrossDevice?.();}In React, reach the same method through a <ChecktivJourney> ref:
import { ChecktivJourney, type ChecktivJourneyHandle } from '@checktiv/sdk-web/react';import { useRef } from 'react';
const ref = useRef<ChecktivJourneyHandle>(null);// ...render <ChecktivJourney ref={ref} crossDeviceCopy={crossDeviceCopy} ... />// Desktop only: <button onClick={() => ref.current?.openCrossDevice()}>Continue on your phone</button>On the completion of the phone, the SDK emits checktiv.idv.submitted, which this path bridges to your onComplete. Reload or navigate from onComplete. On the zero-lifecycle working-token plane the SDK mints the one-time link itself, runs the completion poll, and caps the number of “refresh link” re-mints per overlay, so you supply a mint hook only to override the default (see The mint hook).
The trigger is desktop only
Section titled “The trigger is desktop only”Immersive capture (on a phone) and cross-device handoff (on a desktop) are mutually exclusive: a device gets at most one. A “Continue on your phone” trigger fired from a phone is circular, so openCrossDevice() warn-no-ops on a coarse-pointer (touch) device. Gate your own trigger to a fine-pointer device as shown above so a phone applicant never sees it. openCrossDevice() is also a graceful no-op, with an actionable warning rather than a dead trigger, before the verification has resolved, when you did not supply crossDeviceCopy, or after the handle is destroyed.
Recover a dead handoff via onEvent
Section titled “Recover a dead handoff via onEvent”onComplete fires only on completion. A handoff can also dead-end mid-flow, and those outcomes arrive on onEvent, not onComplete. Wire onEvent for both so the desktop is never a silent dead-end:
checktiv.idv.errorwitherror.code === 'session_expired'- the session died while the applicant was on their phone (for example the re-mint cap was reached and the link expired). Prompt the applicant to restart the verification.checktiv.idv.cross_device_capped- the completion poll reached its total time cap without the phone finishing. This is not an error and not a verdict: the QR panel stays mounted so the applicant can still finish. Show a “still waiting” hint rather than treating it as failure.
A note on fetchImpl
Section titled “A note on fetchImpl”A custom fetchImpl you pass to mount() is used for the token-lifecycle calls, but it is not forwarded to the IDV and cross-device data-plane calls (they use the resolved apiBase origin with the global fetch). This is a pre-existing behavior and is moot when the apiBase origin is correct.
From the explicit IDV mount
Section titled “From the explicit IDV mount”If you drive the managed module directly on an init() client with mount('idv', ...), its handle exposes the same openCrossDevice(). Supply crossDeviceCopy, render your own desktop trigger, and call it:
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 } },});
// Desktop only, as above:if (window.matchMedia('(pointer: fine)').matches) { document.getElementById('go-mobile').onclick = () => handle.openCrossDevice?.();}The desktop-only rule and the onEvent recovery guidance above apply to this path too. On the browser-token (bt_*) plane the SDK cannot self-mint the link, so supply a mint hook as onOpenCrossDevice (see The mint hook).
Enabling the overlay
Section titled “Enabling the overlay”Both managed paths need the overlay opener registered. For an npm or bundler-based app, import @checktiv/sdk-web/idv/cross-device once anywhere in your app: that import registers the opener and makes the QR panel available. The CDN <script> bundle registers the opener for you, so a script-tag customer needs no import; the QR panel loads as a lazy chunk on first open. Without the opener registered, openCrossDevice() is a graceful no-op that logs an actionable warning telling you which subpath to import, rather than a dead trigger.
Content Security Policy
Section titled “Content Security Policy”The QR panel loads on first open as a separate script chunk fetched at runtime. If your page sets a strict nonce-only script-src policy, that dynamically loaded chunk does not carry your page nonce, so allow its origin explicitly: add the SDK script origin to script-src (a host allowlist entry) or enable 'strict-dynamic'. Without it, stricter policies block the chunk and cross-device handoff cannot open. Pages that already allowlist the SDK script host, rather than relying on a nonce alone, need no change.
The mint hook
Section titled “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:
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_closed |
The applicant dismissed the overlay (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.
checktiv.idv.cross_device_closed fires when the applicant dismisses the overlay: the desktop “back” control, the mobile “try again” control, or the double-fault fallback’s “try again” button. It fires only on that applicant-initiated dismissal, not on every teardown - if you call destroy() yourself for an unrelated reason (for example after the phone already completed and you are navigating away), this event does not fire.
Completion
Section titled “Completion”The managed module runs the completion poll for you. When the applicant finishes on their phone, it emits checktiv.idv.submitted so your host reloads. It detects completion from the verification’s status, so the desktop advances reliably even when the phone step moves straight to review and leaves no further step for the desktop to show.
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
Section titled “Mount the panel directly”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.
Copy is injected
Section titled “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
Section titled “Related pages”- Modules overview - the full public surface
- Advanced capture - build a custom IDV renderer that opens this overlay
- Error reference - the errors whose recovery is
cross_device - Quickstart - the managed integration path