Events
Forty-four lifecycle events, from the camera being requested to the result arriving — everything the widget knows about itself
The widget reports what it is doing through options.onEvent. There are forty-four events, covering the whole journey: the instance, the screens, consent, the face detector, the camera, placement, tokens, the measurement, the transport, the real-time chain, and the panels layered over the journey.
const instance = SaphereScan.create("#scan", {
onEvent: event => console.log(event.type, event),
proxy: { retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" } }
})
Two channels, one difference
onEvent and hooks look alike and are not. The difference is the contract, not the vocabulary.
onEvent | hooks | |
|---|---|---|
| Purpose | Observe | Act |
| Return value | Ignored | A literal false refuses |
| Awaited | No | Yes, up to a 2-second guard |
| A throw or rejection | Reported in console, journey continues | Reported in console, treated as allow |
The widget does not wait for onEvent and does not read what it returns. That is what guarantees a slow or faulty handler cannot take a measurement down with it. If you need to refuse something rather than watch it, that is what hooks are for.
Reading an event
Names are domain:event. Each event carries what describes it flat — your handler reads event.percent, not event.data.percent.
The widget is distributed as JavaScript: no type declarations are served alongside the bundle. The union every event follows is named SaphereScanEvent in the widget; declared on your side, it makes a switch on event.type exhaustive, each branch narrowed to its payload:
import type { SaphereScanEvent } from "./saphere-scan-events" // your own declaration of the union
const onEvent = (event: SaphereScanEvent) => {
switch (event.type) {
case "measure:result": return save(event.result)
case "measure:failed": return report(event.code)
case "camera:error": return explain(event.code)
}
}
In JavaScript, use SAPHERE_SCAN_EVENTS, exported by the module beside the class, for the names as constants — a typo in a bare string literal is a branch that silently never runs.
widget — the instance
| Event | Payload | When |
|---|---|---|
widget:ready | — | The widget is mounted. Always the first event. |
widget:error | message | bootstrap() failed. The promise it returns rejects with the same error. Nothing else is reported for that mount. |
widget:destroyed | — | destroy() was called on a mounted widget. Nothing follows — unless the instance is bootstrapped again. |
widget:error exists because the rejected promise is easy to miss: an integration that calls bootstrap() without awaiting it would otherwise have no sign that the mount failed.
screen — the journey
| Event | Payload | When |
|---|---|---|
screen:enter | path, name | A screen is displayed, including the first. |
screen:blocked | from, to | Your beforeScreenChange hook refused the transition. |
path is the route (/onboarding/1, /measurement, /result); name is the screen (intro, onboarding, load-face-detectors, measurement, realtime, result, error).
Track `name`, not `path`
The onboarding index depends on which screens the journey keeps. Out of the box the third guidance screen — the personal-information form — is off, so/onboarding/2 is the fourth screen; an integration that turns the form on sees the third one there. name is stable across configurations.A test screen shown by previewScreen() reports the name of the screen it stands for, under a /preview/… path.
consent and form — what the user grants
| Event | Payload | When |
|---|---|---|
consent:changed | checks | A consent checkbox changed. |
consent:accepted | — | The screen was left with every shown box ticked. |
form:completed | — | The personal-information form was left, valid. |
checks carries one key per checkbox the screen shows: { check1: true, check2: false }. The screen offers four, of which the first two are shown out of the box; a checkbox your customisation removes asks for nothing, so it does not appear there. Every shown checkbox has to be ticked before the user can go further.
consent:accepted fires when leaving the screen in either direction, not when the last box is ticked: while the screen is still there, the user can still untick. A screen whose checkboxes have all been removed asks for nothing, and reports nothing.
form:completed carries nothing. Those are health data, and they already reach you in the result’s userData — they have no reason to travel twice.
detector — the face detector
| Event | When |
|---|---|
detector:loading | Loading begins, at mount. |
detector:loaded | The detector is ready. |
detector:error | The detector could not be loaded: no measurement will be possible. |
detector:error is worth wiring. It leaves the loading screen with no way forward: a single line saying the detectors could not be loaded — in English, whatever the language — and no button, nothing the user can act on. This is the one case where your own error handling has to take over.
These three concern the detector of the thirty-second measurement. The real-time screen loads its own, and reports a failure to do so through realtime:unavailable.
camera — the camera
| Event | Payload | When |
|---|---|---|
camera:requesting | — | Permission is being requested. Once per open attempt. |
camera:ready | width, height, frameRate | The stream is open. |
camera:error | code, name | The stream was refused. |
camera:ended | — | The camera stopped on its own: unplugged, taken by another application. |
camera:released | — | The widget gave the camera back. |
camera:devices-changed | — | A device was plugged or unplugged; the stream reloads. |
code is one of NOT_ALLOWED, NOT_FOUND, NOT_READABLE, ABORT, NOT_SUPPORTED or UNKNOWN. name is the raw exception name the browser produced, since browsers do not all use them the same way — keep it for diagnosis, branch on code. In camera:ready, each of the three settings is null when the browser does not report it.
`camera:ready` reports what was granted, not what was asked
The widget asks for 30 frames per second at 640×480 as a preference, and silently retries with no constraint at all if the camera refuses. AframeRate of 15 explains a lower-quality measurement, and this event is the only way to see it. Hardware identifiers are never forwarded.camera:ended and camera:released are different states. The first is not wanted — the source stopped by itself, and the measurement is compromised. The second is the widget cleaning up.
placement — framing the face
| Event | Payload | When |
|---|---|---|
placement:started | — | The placement screen is active. May fire twice if the camera changes. |
placement:guidance | guidance, stable | The displayed instruction, or the stillness, changed. |
placement:accepted | — | The face is accepted and the framing frozen. |
guidance is "up", "down", "back" or null — null meaning either that nothing needs correcting, or that no face is seen. stable is true while the face holds still — it moved by at most a pixel since the previous detection — and false when no face is seen; the start waits for the face to be both placed and still.
The event fires only on change. Detection runs on every camera frame; reporting each frame would drown a handler for no benefit.
token — the access token
| Event | Payload | When |
|---|---|---|
token:requesting | strategy, measurement | A token is requested, at every attempt. measurement is the measurement chain the token is asked for: scan for the thirty-second measurement, realtime for the real-time screen. |
token:retrieved | — | A usable token was obtained. |
token:error | code | It could not be. |
code separates your failure from ours — see access tokens for the table. This is the one point in the journey where the fault can be on your side, which is why a single generic code was not enough.
With no proxy.retrieveAccessToken declared, token:error (NO_PROXY) comes alone: there was nothing to request. On the real-time screen, a token that does not arrive within five seconds ends in realtime:unavailable without a token:error before it.
The token value is never carried by any event.
measure — the measurement
| Event | Payload | When |
|---|---|---|
measure:attempt | attempt | An attempt begins, as the measurement screen opens. Numbered from one. |
measure:start | — | Acquisition begins: the thirty seconds of capture are running. |
measure:progress | percent | Progress changed by a whole point, from 0 — reported as the attempt begins — to 100. |
measure:captures-sent | totalCaptures, totalImages | Capture is over: every capture is handed to the connection, followed by the end of the measurement, and the waiting screen shows. |
measure:result | result | The result arrived and validated. |
measure:aborted | code | Deliberate stop: the user gave up, or your hook refused. |
measure:failed | code | Fault: the measurement will not complete. |
The split between aborted and failed exists for your supervision. A user cancelling is a normal outcome and should not appear in your dashboards as an incident.
attempt counts the attempts made on one visit to the measurement screen. A second one follows the user giving up on the wait (CANCELED_WHILE_PROCESSING), which starts a new measurement in place. “Retry” on the abnormal-end screen, and a new measurement from the result screen, enter the measurement screen anew: the count starts again at one.
code is a failure reason, and there are fifteen of them. Three are aborts, the rest are faults.
| Code | Event | What happened |
|---|---|---|
CANCELED | measure:aborted | The user cancelled the measurement. |
CANCELED_WHILE_PROCESSING | measure:aborted | The user gave up waiting for the result. The only reason that does not end on an abnormal-end screen: the widget goes straight to a new measurement. |
REFUSED_BY_INTEGRATOR | measure:aborted | Your beforeMeasureStart hook refused the start. Nothing is broken, someone said no. |
UNDEFINED_ACCESS_TOKEN_PROXY | measure:failed | No proxy.retrieveAccessToken was declared. An integration cannot measure without one. |
ACCESS_TOKEN_ERROR | measure:failed | The token could not be obtained. token:error carries which part failed. |
ACCESS_TOKEN_TIMEOUT | measure:failed | The token took more than five seconds to arrive. |
CREATE_MEASURE_OPTIONS_ERROR | measure:failed | Your createMeasureOptions function threw, or returned a measurement the API would refuse. |
CREATE_MEASURE_OPTIONS_TIMEOUT | measure:failed | That function took more than five seconds to answer. |
VIDEO_SOURCE_ENDED | measure:failed | The camera stopped on its own mid-capture — unplugged, taken by another application, tab suspended. Never raised by a stop the widget asked for. |
INVALID_ENDED_MEASURE_DTO | measure:failed | The result did not pass validation, so it was never delivered to you. |
WS_CONNECTION_ERROR | measure:failed | The connection to the measurement server did not hold: a message was re-sent five times without being acknowledged, or the server closed the connection without a result or a reason. |
WS_MESSAGE_REJECTED | measure:failed | The server refused a capture, an image or the end of the measurement. |
WS_SERVER_ERROR | measure:failed | The server ended the measurement and gave its reason, which the widget logs; the event does not carry it. |
WS_RESULT_TIMEOUT | measure:failed | No result arrived within ten minutes of the end of the measurement being sent. |
UNKNOWN | measure:failed | Anything the widget could not name. Should not happen; report it. |
The four WS_ codes name the carriage to the measurement server. The three you can act on alone are UNDEFINED_ACCESS_TOKEN_PROXY, CREATE_MEASURE_OPTIONS_ERROR and CREATE_MEASURE_OPTIONS_TIMEOUT: all three come from your own integration, not from the user or the network.
A token the server refuses surfaces late
The handshake refusing a token — expired, already used, minted for the other chain — gives the widget no reason it could act on, so it keeps retrying the connection. The capture still runs its thirty seconds, the waiting screen follows, and the attempt ends onWS_RESULT_TIMEOUT ten minutes later. The sign to watch is transport:reconnecting repeating with no transport:connected ever before it: most often a token the server will not take, more rarely a server out of reach, and how your endpoint mints the token is the first thing to check.measure:result fires once the payload has been received and validated. Between the result arriving over the network and this event, the widget checks its shape. A result that fails the check produces measure:failed instead, so you never receive one the widget itself would refuse to show.
transport — carriage to the server
| Event | Payload | When |
|---|---|---|
transport:connected | — | The connection is established — again after each reconnection. |
transport:reconnecting | attempt | A connection attempt failed, the first one included, and is being retried. Attempts are not limited; attempt counts them. |
transport:disconnected | reason | The connection is closed and will not be retried. |
transport:degraded | — | Throughput is not keeping up; the measurement continues. |
transport:restored | — | Throughput recovered. |
transport:disconnected only comes once nothing will be retried: when the server closes the connection — which is how every measurement ends, once its result is delivered — or when the widget closes it on leaving the measurement. It is therefore not a failure in itself; measure:failed is what says one. While a connection is being re-established you get transport:reconnecting instead — reporting an automatic recovery as a disconnection would make every brief hiccup look like a failure.
These five describe the connection of the thirty-second measurement. The real-time chain has its own, whose loss leads to realtime:unavailable.
realtime — the real-time chain
| Event | Payload | When |
|---|---|---|
realtime:started | — | The session is measuring: detectors loaded, token spent, transport open, capture running. |
realtime:variables | variables | The vitals as the server computes them, once a second. |
realtime:presence | face | A face entered the frame, or left it. |
realtime:unavailable | — | Real-time measurement could not go through; the widget continues with the certified measurement. |
These four only apply when realtime is set.
realtime:started is the only event that says the waiting is over: the real-time screen does not change between loading and measuring, so screen:enter reports nothing of it. It is the counterpart of measure:start for the other chain.
variables carries what the server computes, as it stands — the payload is the one described under what the vitals carry. Nothing arrives while the window is still too short: an absence means “not yet”, not “nothing to measure”, and mature then confident say what may be shown as a measurement. The widget’s own screen shows none of them below the quality bar.
The domain is `realtime`, not `measure`
The two chains are not the same measurement. Monitoring that filtersmeasure:* follows the certified measurement — attempt, progress, result — and has no business seeing live values arrive there that have no attempt, no progress and no result.realtime:presence is emitted on change only. The detection box is recomputed at capture rate; what you are promised is entering and leaving the frame, not tracking it — the real-time screen is the widget’s own, detection overlay included, and there is nothing for you to draw over it. The absence of a face when the screen opens is not reported: that would announce a departure that never happened.
As for realtime:unavailable, the known case is Safari (26/27), whose WebTransport implementation speaks a draft of the protocol the server does not serve yet; the certified journey goes over a WebSocket those browsers do serve, so the widget switches to it rather than leaving the user on a screen that leads nowhere. A refused token, detectors that fail to load and a link dropping mid-measurement end up in the same place: the real-time screen offers no retry, the certified journey depending on none of those steps. The measurement your user came for is one screen away either way.
ui — the layers over the journey
| Event | Payload |
|---|---|
ui:menu-opened, ui:menu-closed | — |
ui:document-opened, ui:document-closed | kind: notice, termsOfUse or privacy |
ui:language-changed | locale, direction (ltr or rtl) |
ui:language-changed is reported when the user picks another language in the widget’s chooser — not when your own setOptions() changes lang. It carries the direction because that is what you may have to act on: a host layout framing the widget may need to mirror itself too.
Ordering guarantees
Two, and they are structural rather than incidental.
widget:ready is always the first event of a mount that succeeds. Starting the application draws its first screen immediately, so anything that reports during that step — the face detector, for instance — would otherwise speak before the widget announced itself. Those events are held and delivered behind widget:ready.
Nothing follows widget:destroyed. Tearing the application down runs every teardown hook, several of which emit. The channel latches closed before that happens, so the event genuinely is the last one.
Beyond those two, a measurement reports its outcome before the screen that shows it: measure:failed, then screen:enter for /error; transport:disconnected, measure:result, then screen:enter for /result.
What is deliberately not emitted
Four absences, each for a reason worth knowing before you go looking.
No event per re-sent message. A measurement sends some nine hundred captures, and whatever a dropped connection leaves unacknowledged is sent again once it is back. Reporting each re-send would burst events onto a channel that, in a mobile integration, sits behind a WebView bridge. transport:reconnecting and transport:degraded cover the case instead.
No real-time points from either wave. The pulse and the breathing are each produced per measured frame, some thirty values a second apiece on that same channel. Both waves are drawn by the screen, which reads them on the spot; realtime:variables carries what is derived from them once a second. If it is the signals themselves you want, Realtime Measure exposes them, with the video and the display in your hands.
No resize event. The container’s size is your layout, which you already know, and ResizeObserver fires per animation frame during a drag.
No separate “computing” event. The end of capture and the switch to the waiting screen happen in the same synchronous turn. measure:captures-sent says it once, and carries the totals.
Migrating
From the v1 widget
| v1 | v2 |
|---|---|
transition (from → to) | screen:enter — declared in v1 but never actually emitted |
video-stream-loading status: "start" | camera:requesting |
video-stream-loading status: "ready" | camera:ready, with the settings obtained |
video-stream-loading status: "error", reason | camera:error, code |
start | placement:accepted |
record | measure:start |
end | measure:captures-sent, with the totals |
result | measure:result |
aborted | measure:aborted or measure:failed, depending on whether the stop was deliberate; reasons becomes code |
leave | — v2 has no command to leave the widget |
onEvent returning false held back the step that followed it | hooks.beforeScreenChange for a screen, hooks.beforeMeasureStart for the start of capture |
Camera reason mapping: denied → NOT_ALLOWED; no-device → NOT_FOUND, or NOT_READABLE on Firefox, which v1 filed under it; already-used → NOT_READABLE or ABORT, which v2 tells apart; not-supported → NOT_SUPPORTED. UNKNOWN is new: v1 had no reason for an exception it did not expect.
The contract changed on one point: onEvent is no longer awaited and its return is no longer read. That is what guarantees a slow or faulty handler cannot take a measurement down. The ability to refuse did not disappear — it moved to hooks, where it is bounded by a guard delay.
From early v2 integrations
The first six names were replaced by the namespaced ones:
| Before | Now |
|---|---|
ready | widget:ready |
screen | screen:enter — also carries name |
progress | measure:progress |
result | measure:result |
abort | measure:aborted |
error | measure:failed |