Options

Everything Handler.load() accepts: the measure strategy, the event callback, the eleven-colour palette, the screens, and the texts.

New integrations should use Saphere Scan v2. What follows documents the v1 options object.

Your options are validated, then merged over the defaults — and over any customisation applied by ?customizationId=. Unknown top-level keys are rejected rather than ignored.

Top level

KeyTypeDefaultNotes
createMeasureobjectRequired. How a measure is obtained.
onEventfunctionCalled on every event.
allowLeavebooleanfalseShows a control letting the user quit the widget.
allowSkipbooleanfalseLets the user skip onboarding screens.
langstringbrowser language, else "en"One of the ten supported languages.
useShadowbooleantrueMounts inside a shadow root.
desiredVariablesstring[]Narrows what is computed.
tagsstring[]Stored with the measure.

Screens and colours are not in this list. They are prepared in the console, not written in your code — see appearance below.

`useShadow: false` lets your CSS reach inside

The default mounts the widget in a shadow root, which stops your stylesheet from affecting it and its own from affecting you. Turning that off is occasionally necessary in hybrid shells; when you do, expect your global CSS to reach the widget’s internals.

createMeasure

The widget needs a measure to attach the capture to. There are two strategies, and they differ in who makes the call.

delegate — the widget calls your endpoint

createMeasure: {
    strategy: "delegate",
    url: "/api/saphere-measure",
    headers: { "X-Session": sessionId }   // optional
}

The widget issues POST <url> with a JSON body carrying userData, desiredVariables and tags, and expects { "id": "<measureId>" } back.

Your endpoint authenticates your user, calls POST /measures on the Saphere API with your key, and relays the identifier.

handle — your code makes the call

createMeasure: {
    strategy: "handle",
    fetch: async ({ userData, desiredVariables }) => {
        const response = await fetch("/api/saphere-measure", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ userData, desiredVariables })
        })
        return await response.json()      // must be { id: "…" }
    }
}

`fetch` must resolve to an object with an `id`

Returning the identifier as a bare string throws Cannot get measureId!. The contract is { id: string } — an object.

This is worth checking first when a v1 integration fails at the very start: some published examples returned json["token"], which is a string, and does not satisfy it.

If the endpoint answers with a non-2xx status, the widget throws Cannot create measure!.

onEvent

onEvent: async event => {
    console.log(event.type, event)
}

Called for every event below. The return value is meaningful: returning literally false cancels the transition the event announces. Anything else allows it, including undefined, and including an exception thrown by your own code, which is logged and ignored.

The handler is awaited.

A slow handler slows the widget

Because the return value can veto a transition, v1 waits for your handler before proceeding. A handler that performs a network call on every event delays the user journey by that call.

This is the behaviour v2 deliberately dropped: there, onEvent is never awaited, and vetoing moved to a separate hooks API with a bounded timeout.

The event union

typePayloadWhen
video-stream-loading{ status: "start" }The camera is being requested
video-stream-loading{ status: "ready" }The stream is open
video-stream-loading{ status: "error", reason }The camera was refused
startThe face is accepted and framing is frozen
recordCapture begins
endAll frames are buffered and the end marker is queued
result{ id, userData?, variables }The measurement completed
aborted{ reasons }The measurement stopped
leaveThe user asked to quit (requires allowLeave)
transition{ from, to }Declared, but never emitted

video-stream-loading reason is one of "denied", "no-device", "not-supported", "already-used".

aborted reasons carries either a conformity code (CONFORMITY_POOR_LIGHT, CONFORMITY_MUCH_VARIATIONS, CONFORMITY_FACE_PRESENCE, CONFORMITY_LOW_FPS, CREDIT_EXCEEDED, MISSING_USER_DATA_*, …) or a failure reason (WIDGET_RESIZED, FOCUS_LOST, CANCELED_WHILE_CAPTURING, CREATE_MEASURE_ERROR, WS_CONNECTION_ERROR, VIDEO_SOURCE_ENDED, …).

`transition` never fires

TransitionEvent is part of the declared type, and a handler branching on it will compile. Nothing in the module constructs one. Do not build navigation tracking on it.

In a result event, each variable is { value, signals, error: null } on success, or { value: null, error } on failure.

Appearance: screens and colours

The widget’s appearance is not described in the options you pass in your code. It is prepared in the ops console, in the customisation workshop, and attached to the widget by its identifier.

Each customisation is listed under Scan tools → Scan customization, with the identifier you will use.

The customisation list in the ops console
The customisations of an account. The identifier in the first column is what you put in the widget address.

Opening one gives the workshop. A live widget sits beside the settings, so what you approve is what your users will see.

Colours — the palette the widget applies to itself.

The Colors tab of the customisation workshop, with a live widget preview
Each colour is a field, and the preview redraws as you change it.

Pages — which screens the journey includes, and which are left out, plus the logo shown at the top.

The Pages tab of the customisation workshop
Removing a guidance screen shortens the journey and lowers the share of measurements that produce a usable signal.

Once the customisation is saved, add its identifier to the address you load the module from:

<script src="https://cdn.saphere.ai/saphere-scan/v1/js/main.min.js?customizationId=8f1c2d34-5a6b-4c7d-8e9f-0a1b2c3d4e5f"></script>

The module arrives already carrying it. Nothing changes in your code, and editing the customisation later takes effect at the next load, with no deployment on your side.

Prepare it in the same environment you load from

A customisation saved on Test is not the same record as one saved on Production, so its identifier does not carry across. Prepare it where you will use it.