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.

onEventhooks
PurposeObserveAct
Return valueIgnoredA literal false refuses
AwaitedNoYes, up to a 2-second guard
A throw or rejectionReported in console, journey continuesReported 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

EventPayloadWhen
widget:readyThe widget is mounted. Always the first event.
widget:errormessagebootstrap() failed. The promise it returns rejects with the same error.
widget:destroyeddestroy() 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

EventPayloadWhen
screen:enterpath, nameA screen is displayed, including the first.
screen:blockedfrom, toYour 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. With onboarding3: { ignore: true }, /onboarding/2 is the fourth screen for you and the third for someone else. name is stable across configurations.
EventPayloadWhen
consent:changedtermsOfUse, privacyOne of the two checkboxes changed.
consent:acceptedThe screen was left with both boxes ticked.
form:completedThe 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

EventWhen
detector:loadingLoading begins, at mount.
detector:loadedThe detector is ready.
detector:errorThe 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

EventPayloadWhen
camera:requestingPermission is being requested. Once per open attempt.
camera:readywidth, height, frameRateThe stream is open.
camera:errorcode, nameThe stream was refused.
camera:endedThe camera stopped on its own: unplugged, taken by another application.
camera:releasedThe widget gave the camera back.
camera:devices-changedA 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. A frameRate 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

EventPayloadWhen
placement:startedThe placement screen is active. May fire twice if the camera changes.
placement:guidanceguidance, stableThe displayed instruction changed.
placement:acceptedThe face is accepted and the framing frozen.

guidance is "up", "down", "back" or nullnull 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

EventPayloadWhen
token:requestingstrategyA token is requested, at every attempt.
token:retrievedA usable token was obtained.
token:errorcodeIt 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

EventPayloadWhen
measure:attemptattemptAn attempt begins. Numbered from one.
measure:startAcquisition begins: the thirty seconds of capture are running.
measure:progresspercentProgress changed by a whole point.
measure:captures-senttotalCaptures, totalImagesEverything has been sent; the server is computing.
measure:resultresultThe result arrived and validated.
measure:abortedcodeDeliberate stop: the user gave up, or your hook refused.
measure:failedcodeFault: 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

EventPayloadWhen
transport:connectedThe connection is established.
transport:reconnectingattemptA reconnection is being attempted (up to thirty).
transport:disconnectedreasonThe connection is lost and is not coming back on its own.
transport:degradedThroughput is not keeping up; the measurement continues.
transport:restoredThroughput 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

EventPayload
ui:menu-opened, ui:menu-closed
ui:document-opened, ui:document-closedkind: notice, termsOfUse or privacy
ui:language-changedlocale, 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

v1v2
transition (fromto)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", reasoncamera:error, code
startplacement:accepted
recordmeasure:start
endmeasure:captures-sent, with the totals
resultmeasure:result
abortedmeasure: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 transitionhooks.beforeScreenChange

Camera reason mapping: deniedNOT_ALLOWED, no-deviceNOT_FOUND, already-usedNOT_READABLE, not-supportedNOT_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:

BeforeNow
readywidget:ready
screenscreen:enter — also carries name
progressmeasure:progress
resultmeasure:result
abortmeasure:aborted
errormeasure:failed