Events
Forty 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 events, covering the whole journey: the instance, the screens, consent, the face detector, the camera, placement, tokens, the measurement, the transport, 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.
In TypeScript the union is exported, so a switch on event.type is exhaustive and each branch narrows:
import type { SaphereScanEvent } from "…"
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 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. |
widget:destroyed | — | destroy() was called. Nothing follows. |
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, result, error).
Track `name`, not `path`
The onboarding index depends on which screens you disabled. Withonboarding3: { ignore: true }, /onboarding/2 is the fourth screen for you and the third for someone else. name is stable across configurations.consent and form — what the user grants
| Event | Payload | When |
|---|---|---|
consent:changed | termsOfUse, privacy | One of the two checkboxes changed. |
consent:accepted | — | The screen was left with both boxes ticked. |
form:completed | — | The personal-information form was left, valid. |
consent:accepted fires when leaving the screen in either direction, not when the second box is ticked: while the screen is still there, the user can still untick.
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 — no failure reason, no button, nothing for the user to act on — so it is the one case where your own error handling has to take over.
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.
`camera:ready` reports what was granted, not what was asked
The widget requests 30 frames per second at 640×480 and silently falls back to whatever the camera will give. 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 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.
The event fires only on change. Detection runs some sixty times a second; reporting each frame would drown a handler for no benefit.
token — the access token
| Event | Payload | When |
|---|---|---|
token:requesting | strategy | A token is requested, at every attempt. |
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.
The token value is never carried by any event.
measure — the measurement
| Event | Payload | When |
|---|---|---|
measure:attempt | attempt | An attempt begins. Numbered from one. |
measure:start | — | Acquisition begins: the thirty seconds of capture are running. |
measure:progress | percent | Progress changed by a whole point. |
measure:captures-sent | totalCaptures, totalImages | Everything has been sent; the server is computing. |
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.
code is a failure reason. REFUSED_BY_INTEGRATOR is the one produced by a beforeMeasureStart veto, and it is classed as an abort — nothing is broken, someone said no.
measure:result fires once the payload has been received and validated. Between the result arriving on the wire and this event there is a validation step, and a payload that fails it produces measure:failed instead — so you never receive a result the widget itself would not show.
transport — carriage to the server
| Event | Payload | When |
|---|---|---|
transport:connected | — | The connection is established. |
transport:reconnecting | attempt | A reconnection is being attempted (up to thirty). |
transport:disconnected | reason | The connection is lost and is not coming back on its own. |
transport:degraded | — | Throughput is not keeping up; the measurement continues. |
transport:restored | — | Throughput recovered. |
transport:disconnected is only emitted once the client has stopped retrying. While reconnection is in progress you get transport:reconnecting instead — reporting an automatic recovery as a disconnection would make every brief hiccup look like a failure.
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 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. Mounting the application runs a synchronous render, so services that emit during mount — the face detector, for one — would otherwise report 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 failure emits its measurement event then the screen change — measure:failed, then screen:enter for /error.
What is deliberately not emitted
Three absences, each for a reason worth knowing before you go looking.
No event per re-sent message. A measurement puts some nine hundred captures in flight, each acknowledgement expiring after fifty seconds. An outage would burst nine hundred events onto a channel that, in a mobile integration, sits behind a WebView bridge. transport:degraded covers the case in two events instead.
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-send and the start of computation 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 |
leave | — v2 has no command to leave the widget |
onEvent returning false cancelled the transition | hooks.beforeScreenChange |
Camera reason mapping: denied → NOT_ALLOWED, no-device → NOT_FOUND, already-used → NOT_READABLE, not-supported → NOT_SUPPORTED, plus ABORT, which is new.
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 a transition 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 |