Installation

Load one ES module from the CDN, mount it into a container you own, and the widget takes over from there

Saphere Scan ships as a single ES module. There is nothing to install, nothing to bundle, and no peer dependency to reconcile — the bundle, the face-detection model and the assets are all served from the CDN.

Which CDN

There are two, one per environment, and the one you import from decides which environment the widget works against — it derives the measurement API host from that same origin. Every sample on this page uses Production; swap the host for Test and nothing else changes.

EnvironmentModule URL
Productionhttps://cdn.saphere.ai/saphere-scan/v2/main.js
Testhttps://cdn.test.saphere.ai/saphere-scan/v2/main.js

Both serve the same release, so what you validate on Test is what runs in Production. See Environments for what else differs — chiefly that API keys are not interchangeable.

The shortest working page

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
        html, body { margin: 0; height: 100%; }
        #scan { height: 100dvh; }
    </style>
</head>
<body>
    <div id="scan"></div>

    <script type="module">
        import SaphereScan from "https://cdn.saphere.ai/saphere-scan/v2/main.js"

        const instance = SaphereScan.create("#scan", {
            lang: "en",
            proxy: {
                retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" }
            }
        })

        await instance.bootstrap()
    </script>
</body>
</html>

That is a complete integration. The widget walks the user through consent, guidance and capture, and reports back through events.

The lifecycle

Three methods, and they are the whole public surface.

SaphereScan.create(root, options)

Validates the options and returns an instance. It mounts nothing — no Angular application is built, no camera is touched, no network request is made.

root is a CSS selector or an HTMLDivElement. Anything else throws Root element must be an HTMLDivElement.

Options are validated here, synchronously, against the full option tree. A misspelled key is dropped silently; a value of the wrong shape throws. This is deliberate: you find out at create() time, on your own machine, rather than three screens into a user’s journey.

await instance.bootstrap()

Builds the application and mounts it. Returns the instance, so it chains.

Calling it twice throws SaphereScan is already bootstrapped. If the mount fails, the promise rejects with the underlying error, the partially-built application is torn down, and a widget:error event carries the message — so a supervision handler learns about it even if nothing awaits the promise.

instance.destroy()

Tears the widget down and releases the camera, the websocket and the workers.

It is idempotent. Calling it on an instance that was never bootstrapped, or twice in a row, does nothing and throws nothing — you do not have to track the widget’s state to be allowed to unmount it.

const instance = SaphereScan.create("#scan", options)
await instance.bootstrap()

// later — leaving the page, closing a modal, unmounting a component
instance.destroy()

Always destroy on unmount

The widget holds a camera stream, a websocket and two web workers. A single-page application that removes the container from the DOM without calling destroy() leaves the camera light on.

The container contract

The widget owns its container. Three consequences worth knowing before you style it.

It replaces the container’s children. Whatever you left in the div — a placeholder, your own loading spinner — is removed when bootstrap() runs. Put your placeholder inside the container and it disappears at exactly the right moment, with no coordination on your side.

It mounts into a shadow root. Your page’s CSS cannot reach inside the widget, and the widget’s CSS cannot leak into your page. This is what makes the widget safe to drop into an existing design system, and it is also why you cannot restyle it with a stylesheet — customisation goes through options instead.

It needs a container with a height. The widget fills 100% of its container in both dimensions. A div with no height collapses to nothing and the widget will not be visible.

/* Full screen — the usual choice on mobile and in a WebView */
#scan { height: 100dvh; }

/* Or a fixed frame inside a wider page */
#scan { width: min(420px, 100%); aspect-ratio: 9 / 16; margin-inline: auto; }

The journey is designed portrait. A tall, narrow frame is what it is built for.

Without ES modules

Some integrations cannot use <script type="module"> — an older WebView shell, a bundler configuration you do not control, a CMS that only accepts a plain script tag. The same bundle also exposes itself globally.

<script src="https://cdn.saphere.ai/saphere-scan/v2/main.js"></script>
<script>
    window.addEventListener("saphere-scan:ready", async ({ detail: { SaphereScan } }) => {
        const instance = SaphereScan.create("#scan", { lang: "en", proxy: { /* … */ } })
        await instance.bootstrap()
    })
</script>

The saphere-scan:ready CustomEvent fires on window once the bundle has finished evaluating, carrying the class in detail.SaphereScan. The same class is also set on window.IVirtual.SaphereScan.

Listen for the event rather than reading the global directly. A plain <script> tag is not guaranteed to have finished executing when your own inline script runs, and the event removes the race.

The bundle refuses to be loaded twice: a second evaluation throws IVirtual.SaphereScan is already defined. To run several widgets, create several instances from the one class — do not include the script twice.

TypeScript

The module exports its types, so an integration written in TypeScript gets autocompletion over the option tree and an exhaustive switch over events.

import SaphereScan from "https://cdn.saphere.ai/saphere-scan/v2/main.js"
import type { SaphereScanOptions, SaphereScanEvent, SaphereScanEventType } from "…"

const options: SaphereScanOptions = {
    lang: "en",
    proxy: { retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" } },
    onEvent: (event: SaphereScanEvent) => {
        switch (event.type) {
            case "measure:result":
                // `event.result` is typed here, and only here
                save(event.result)
                break
            case "measure:failed":
                report(event.code)
                break
        }
    }
}
ExportWhat it is
SaphereScanOptionsThe whole option tree, every branch optional. What create() accepts.
SaphereScanRootstring | HTMLDivElement — what create() accepts as its first argument.
SaphereScanEventThe discriminated union of the forty events, keyed on type.
SaphereScanEventTypeJust the names, for when you need the discriminant alone.
SAPHERE_SCAN_EVENTSThe names as frozen constants, for JavaScript integrations.

SAPHERE_SCAN_EVENTS exists because JavaScript gives a switch on bare string literals no safety net: a typo becomes a branch that never runs and never complains.

import SaphereScan, { SAPHERE_SCAN_EVENTS } from "https://cdn.saphere.ai/saphere-scan/v2/main.js"

const onEvent = event => {
    if (event.type === SAPHERE_SCAN_EVENTS.MEASURE_RESULT)
        save(event.result)
}

Runtime validation stays

Types are a convenience, not the guarantee. The option tree is validated at runtime on every create() call, because the primary integration mode is JavaScript and types protect nothing there.

What to do next

Set up token retrieval — the widget will not start a measurement without it — then wire the events.