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

Test is also where new developments are validated, so it is sometimes slightly ahead of Production. See Environments for what else differs — chiefly that API keys are not interchangeable.

Load the module from one of these two addresses, and from nowhere else. The widget locates its services from the address it was loaded from: a copy served by your own server, or relayed through a proxy of yours, cannot do so, and bootstrap() then fails on Saphere Scan must be served by a CDN it can recognise, and was loaded from "…".

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

Seven methods, and they are the whole public surface. Two of them — create and describeDefaults — are static, called on the class; the other five on an instance.

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.

Read what it throws with String(error) rather than error.message: a refused payload throws the validation report, which is not an Error and has no message, and whose string form names each faulty field and what it expects. The same holds for setOptions() and describeDefaults().

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

Replaces the options of an instance, mounted or not, and the screen follows — texts, colours, sizes, language, and even the composition of the journey. Nothing is remounted: the widget keeps its application, its workers and its camera.

// the user picks their language in your interface, not in the widget
instance.setOptions({ ...options, lang: "de" })

The payload replaces, it does not complete. It takes exactly the shape of the one create() takes and describes the state you want: whatever it leaves out returns to the widget’s default. That is what lets you remove a customisation, and it is also what requires you to restate, on every call, whatever you want to keep — your proxy, your hooks and your onEvent included. Leaving onEvent out does not fail: the widget simply stops reporting to you.

Options are validated as they are at create(). A rejected payload throws, and the instance stays on the options it was applying: nothing is half-applied.

Removing the screen on display sends the user to the first remaining screen; removing a screen that sits before it leaves them where they are, the screen being followed by its identity rather than by its rank. A measurement in progress is never interrupted: the measurement, result and error screens do not depend on the composition of the journey.

Returns the instance, so it chains.

instance.previewScreen(name)

Shows a test screen — the real screens, fed with fabricated inputs — or, called with no argument, returns the widget to its journey.

// what your users will see when a measurement completes, without running one
instance.previewScreen("result")

// back to the journey
instance.previewScreen()

It exists so that appearance can be judged without provoking the situation that produces the screen. The result screen follows a completed measurement, the two abnormal-end screens follow an interrupted one, and the five camera screens follow a real refusal; none of them can be reached by settings alone. The journey screens are simply walked: "onboarding3" goes to the third screen’s own route, with its numbering and its frame.

While a test screen is shown, the navigation controls do nothing. “Retry”, “Previous” and “Reload the camera” stay visible and clickable — their appearance is part of what you are judging — but they do not leave the screen, and the camera is never opened.

NameWhat it shows
journeyThe first screen carrying the toolbar: the frame, the menu button, the buttons
introThe welcome animation
onboarding1 … onboarding7Each guidance screen, at its rank in the current journey
load-face-detectorsThe plain frame, without a toolbar
menuThe menu open, over its backdrop
lang-choiceThe language panel
got-itThe tooltip, on the guidance screen your options give it
tooltipThe tooltip on its own, whatever your journey retains
sliderThe guidance progress dots, on their own
placementThe start of the measurement, in the mode you configured, and the back chevron
computingThe screen shown while the measurement is computed, and its cancel button
realtimeThe button that leaves the realtime screen for the certified journey
camera-not-allowed, camera-not-found, camera-not-readable, camera-abort, camera-not-supportedEach camera refusal
end-canceled, end-failureThe two abnormal ends
resultThe result screen, every value filled in
result-hr, result-br, result-hrv, result-strs, result-bp, result-bpClass, result-bmi, result-faceBmi, result-physicalAge, result-healthScoreThe same screen, reduced to a single value: the card whose colours you are setting, without the other nine

An unknown name throws, and so does calling it on an instance that is not bootstrapped — there is no router before that. A journey screen your options exclude falls back to a screen that exists.

Five of them — slider, tooltip, placement, computing and realtime — owe nothing to your journey: they are shown whatever your options retain, which is what lets you judge a setting whose usual screen you have removed. The placement and realtime screens carry no camera image — that is the one thing they cannot fabricate.

Returns the instance, so it chains.

instance.getOptions()

Returns the options the instance is applying right now — resolved and validated, which is to say the full tree, defaults included, and not the payload you handed over.

// what language is the widget actually showing?
instance.getOptions().lang

Useful after a setOptions() whose payload you built by merging, and to read what a default actually is without guessing.

SaphereScan.describeDefaults(options)

Returns what the widget would apply for the given options, without mounting anything. Called with no argument it returns the defaults: the whole tree, as JSON.

// the whole default customisation tree, to build an editor on
const defaults = SaphereScan.describeDefaults()

// what a journey with the personal-information screen enabled would carry
const withForm = SaphereScan.describeDefaults({ components: { onboarding: { onboarding3: { ignore: false } } } })

It is static because there is nothing to mount and nothing to keep. The argument is not an ornament: several screens are ignored by default, and what they would carry does not appear in the argument-free payload — their ignored variant declares only its ignore. Resolving them means saying so.

Every call returns a fresh payload, which you can modify without a second call carrying the trace. A payload the widget refuses throws, exactly as at create().

instance.destroy()

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

It can be called at any time. Calling it on an instance that was never started, or calling it twice in a row, does nothing and raises nothing. You do not have to track the widget’s state in order to remove 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 whatever is inside the container. Anything you left in the div, such as a placeholder or your own loading indicator, 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.

What the browser has to provide

  • A secure context. The page must be served over https (or be localhost): the browser refuses the camera to anything else.
  • A Content-Security-Policy that lets the widget run, if your page sets one. Scripts and images from the CDN, and 'wasm-unsafe-eval' under script-src: the face detector is WebAssembly. blob: under worker-src, or the script-src it falls back to: the widget’s workers come from the CDN, a browser refuses a worker script from another origin whatever its CORS headers, so the widget starts each one through a one-line blob: script of your page’s own origin. And under connect-src, the addresses it talks to: the CDN and the measurement service of the same environment (api-measure. followed by the CDN’s domain), both also over secure WebSockets; your own endpoint under the delegate strategy, the API under unsafe-api-key; and in real-time mode, api-webtransport. followed by the same domain.

Without an import statement

Some integrations cannot write an import — a CMS that only lets you paste a script tag, a bundler configuration you do not control, a page whose script is generated for you. The bundle also exposes itself globally, so a tag alone is enough:

<script type="module" 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.

The tag has to be a module tag

The bundle is an ES module. Loaded by a classic <script src>, it does not run at all: the browser stops on SyntaxError: Cannot use import statement outside a module, the global is never set, and the event never fires. type="module" is what the page above needs, and it is supported everywhere the widget is.
Listen for the event rather than reading the global directly. A module tag is deferred, so it has not finished executing when your own inline script runs, and the event removes the race.

The bundle refuses to be evaluated twice. The same address imported twice is evaluated once, as for any module; but the bundle loaded a second time under another address — from the other environment, or with another customizationId — throws IVirtual.SaphereScan is already defined. To run several widgets, create several instances from the one class.

The options

Every option may be left out at create(), and the table gives what each one then is. proxy is the only one a measurement cannot start without.

OptionWhat it carriesDefault
proxyHow the widget obtains an access token — see Access tokensNone: the screens work, a measurement fails at its start
onEventA function receiving every eventNone
hooksbeforeScreenChange and beforeMeasureStart, the two points where you can refuse — see HooksNone
createMeasureOptionsThe measurement to create — see belowNone
desiredVariablesThe variables to compute, at least oneEvery variable your account is entitled to
tagsTags for every measurement of the instance, at least oneNone
realtimeOpens the widget on its real-time screen — see belowfalse
langar, de, en, es, fr, it, pl, pt, pt_BR or trThe first of the browser’s preferred languages the widget speaks, otherwise en
componentsThe texts, colours and composition of the journey — see CustomisationThe widget’s own

pt_BR is written with an underscore: pt-BR is refused.

The measure to create

Everything the widget has to say about the measurement travels in one option, createMeasureOptions. Its fields are the ones POST /measures accepts, and they describe the measurement the widget is about to open.

FieldWhat it carries
userDataSex, age or date of birth, weight, height, smoking status, and an identifier of your own, externalId — see the API for the bounds. Several variables cannot be computed without it.
tagsWhat you will find the measurement under in the console.
desiredVariablesThe variables you want computed. Absent, every variable your account is entitled to is.
unwantedVariablesThe variables you do not want computed.
computeOptionsOptions that steer the computation.

It takes two forms, and the second one exists for a precise reason.

An object describes a measurement that does not change from one person to the next.

createMeasureOptions: {
    userData: { sex: "F", age: 35, weight: 58, height: 171, smokingStatus: "non-smoker" },
    tags: ["kiosk-3"]
}

A function is asked again at every attempt, and receives what the widget’s own form collected — null when that screen is disabled, or was left incomplete — then the attempt’s AbortSignal. That is the only way to combine the two sources: you enrich what the person just typed with what you already hold, instead of having to choose between them. It may return a promise, and has five seconds to settle.

createMeasureOptions: collected => ({
    userData: { ...collected, sex: patient.sex, age: patient.age },
    tags: ["consultation", consultation.id]
})

What it declares wins, field by field. What it leaves out falls back: to the top-level desiredVariables and tags options, which apply to every measurement of the instance, and for userData to whatever the widget’s own form collected.

It is checked before the measurement starts

An object is refused at create(), and a function’s return the moment it answers — the attempt then ends with measure:failed, code CREATE_MEASURE_OPTIONS_ERROR, which is also the code of a function that throws; one that takes longer than five seconds ends it with CREATE_MEASURE_OPTIONS_TIMEOUT. Both forms are checked exactly as the API checks them, so a value out of bounds is a clear refusal rather than a connection dropped a few seconds into the acquisition.

Real-time mode

realtime: true opens the widget on a real-time measurement screen once the intro has played: the camera, the breathing and pulse waves as they are measured, and the live vitals. At the bottom of that screen, one button starts a certified measurement — the usual journey, guidance and consent included — and the result screen’s button then reads Continue and returns to the real-time screen. The two loop for as long as the widget is mounted.

const instance = SaphereScan.create("#scan", {
    realtime: true,
    proxy: { retrieveAccessToken: { strategy: "delegate", url: "/saphere/token" } }
})

What the screen shows is the heart rate, the breathing rate and a signal quality in stars, over the camera image. A value appears only once the measurement is confident of it — below that threshold the reading stays on dashes rather than showing a number the chain would not stand behind, so the first seconds of a session are normally blank.

Each real-time session consumes a token of its own, asked through the same proxy.retrieveAccessToken — with measurement set to "realtime" where the certified journey asks for "scan". Once this option is on, your endpoint therefore has to answer both chains.

The screen carries no toolbar: the camera fills the frame, and the button to the certified measurement is its only control — so neither the back arrow nor the menu (language chooser, manufacturer documents) is reachable there. They are back as soon as the certified journey starts.

Should the real-time chain fail — a browser whose WebTransport does not interoperate with the server, a refused token, detectors that do not load, a link dropping mid-session — the widget goes straight to the certified measurement and says so through realtime:unavailable. There is no retry screen.

The screen reports itself as realtime in screen:enter, and the token events are the same as for the certified journey. The option is off by default: an existing integration changes in nothing.

This is the widget measuring in real time from the camera it asks for. If you already hold the video — a teleconsultation, a media server, a file to replay — the chain is reachable directly, and that is what Realtime Measure documents.

What the module exports

Three names, and they are all a page can import.

ExportWhat it is
defaultThe SaphereScan class.
SaphereScanThe same class, as a named export.
SAPHERE_SCAN_EVENTSThe forty-four event 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)
}

No type declarations are published, on the CDN or anywhere else. An integration written in TypeScript declares the module itself, and the shortest declaration under which the samples of this page type-check is this one — the option tree and the events stay untyped, the events page describing their shapes:

// saphere-scan.d.ts
declare module "https://cdn.saphere.ai/saphere-scan/v2/main.js" {
    export interface SaphereScanInstance {
        bootstrap(): Promise<SaphereScanInstance>
        setOptions(options: object): SaphereScanInstance
        previewScreen(name?: string): SaphereScanInstance
        getOptions(): Record<string, unknown>
        destroy(): void
    }
    export const SaphereScan: {
        create(root: string | HTMLDivElement, options?: object): SaphereScanInstance
        describeDefaults(options?: object): Record<string, unknown>
    }
    export const SAPHERE_SCAN_EVENTS: Readonly<Record<string, string>>
    export default SaphereScan
}

Runtime validation is the guarantee

The option tree is validated at runtime on every create() and setOptions() call, because the primary integration mode is JavaScript and types protect nothing there. A declaration of your own only checks your code against what you wrote in it.

What to do next

Set up how the widget obtains a token, because it will not start a measurement without one. Then connect the events.