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.
| Environment | Module URL |
|---|---|
| Production | https://cdn.saphere.ai/saphere-scan/v2/main.js |
| Test | https://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.
| Name | What it shows |
|---|---|
journey | The first screen carrying the toolbar: the frame, the menu button, the buttons |
intro | The welcome animation |
onboarding1 … onboarding7 | Each guidance screen, at its rank in the current journey |
load-face-detectors | The plain frame, without a toolbar |
menu | The menu open, over its backdrop |
lang-choice | The language panel |
got-it | The tooltip, on the guidance screen your options give it |
tooltip | The tooltip on its own, whatever your journey retains |
slider | The guidance progress dots, on their own |
placement | The start of the measurement, in the mode you configured, and the back chevron |
computing | The screen shown while the measurement is computed, and its cancel button |
realtime | The button that leaves the realtime screen for the certified journey |
camera-not-allowed, camera-not-found, camera-not-readable, camera-abort, camera-not-supported | Each camera refusal |
end-canceled, end-failure | The two abnormal ends |
result | The 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-healthScore | The 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 callingdestroy() 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 belocalhost): 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'underscript-src: the face detector is WebAssembly.blob:underworker-src, or thescript-srcit 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-lineblob:script of your page’s own origin. And underconnect-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 thedelegatestrategy, the API underunsafe-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.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.
| Option | What it carries | Default |
|---|---|---|
proxy | How the widget obtains an access token — see Access tokens | None: the screens work, a measurement fails at its start |
onEvent | A function receiving every event | None |
hooks | beforeScreenChange and beforeMeasureStart, the two points where you can refuse — see Hooks | None |
createMeasureOptions | The measurement to create — see below | None |
desiredVariables | The variables to compute, at least one | Every variable your account is entitled to |
tags | Tags for every measurement of the instance, at least one | None |
realtime | Opens the widget on its real-time screen — see below | false |
lang | ar, de, en, es, fr, it, pl, pt, pt_BR or tr | The first of the browser’s preferred languages the widget speaks, otherwise en |
components | The texts, colours and composition of the journey — see Customisation | The 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.
| Field | What it carries |
|---|---|
userData | Sex, 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. |
tags | What you will find the measurement under in the console. |
desiredVariables | The variables you want computed. Absent, every variable your account is entitled to is. |
unwantedVariables | The variables you do not want computed. |
computeOptions | Options 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 atcreate(), 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.
| Export | What it is |
|---|---|
default | The SaphereScan class. |
SaphereScan | The same class, as a named export. |
SAPHERE_SCAN_EVENTS | The 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 everycreate() 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.