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=, so what you pass in code wins over the customisation. Unknown top-level keys are rejected rather than ignored: load() throws Bad module options!.
Top level
| Key | Type | Default | Notes |
|---|---|---|---|
createMeasure | object | — | Required. How a measure is obtained. |
onEvent | function | — | Called on every event. |
allowLeave | boolean | false | Shows a control letting the user quit the widget. |
allowSkip | boolean | false | Shows a skip link on the first two screens. It jumps to the personal-information form when the journey has one, otherwise straight to the camera — past the consent screen. |
lang | string | first browser language the widget supports, else "en" | One of the ten supported languages. |
useShadow | boolean | true | Mounts inside a shadow root. |
isMobile | boolean | device detection | Forces the phone or the desktop layout: illustrations, front camera, camera switch. |
desiredVariables | string[] | — | Handed to createMeasure; narrows what is computed once your server passes it on to POST /measures. |
tags | string[] | — | Handed to createMeasure; stored with the measure once your server passes them on. |
colors | object | see Handler.defaultModuleOptions | The eleven colours. |
pages | object | see Handler.defaultModuleOptions | Which screens the journey includes, and the logo. |
texts | object | built in, ten languages | Every string, per language. |
colors, pages and texts describe the appearance. Their shape is the one Handler.defaultModuleOptions shows, and any part of them may be passed. They are best prepared in the console rather than 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 — each only when it has a value — and expects { "id": "<measureId>" } back. It sets Content-Type: application/json over your headers.
Your endpoint authenticates your user, calls POST /measures on the Saphere API with your key, and relays the identifier. The API’s answer carries the id field, so it can be relayed as it is.
handle — your code makes the call
fetch receives the same object the delegate body carries, { userData, desiredVariables, tags }, and resolves to the measure.
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 fails the creation, and the measurement stops with CREATE_MEASURE_ERROR. 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.
When the measure is created
The widget asks for a measure at each attempt, when the face is accepted — right after the start event — and in parallel with the countdown and the capture. The creation fails when your delegate endpoint answers a non-2xx status, when your fetch rejects, when the answer carries no string id, or when no answer arrives within 25 seconds. The widget then stops the measurement: onEvent receives aborted with CREATE_MEASURE_ERROR, and the user sees the error screen. Nothing is thrown to your code.
Return a new measure on every call. A measure serves a single measurement, and reusing an identifier does not produce a second one. When the measurement server refuses an identifier, the module keeps retrying the connection: the measurement does not complete, and no aborted event arrives until the user cancels. Opening a measurement is also bounded per account — see rate limiting; an opening refused for that reason is retried automatically.
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
type | Payload | When |
|---|---|---|
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 could not be opened |
start | — | The face is accepted and framing is frozen |
record | — | Capture begins |
end | — | All frames are buffered and the end marker is queued |
result | { id, userData?, variables } | The measurement completed |
aborted | { reasons } | The measurement stopped |
leave | — | The user asked to quit (requires allowLeave); the widget does nothing else — unmount it with Handler.destroy() |
transition | { from, to } | Declared, but never emitted |
video-stream-loading reason is one of "denied", "no-device", "not-supported", "already-used".
aborted reasons is a single code, despite its name. It is one of:
- the user cancelled:
CANCELED_WHILE_COUNTDOWN,CANCELED_WHILE_CAPTURING,CANCELED_WHILE_SENDING,CANCELED_WHILE_PROCESSING; - the capture was interrupted:
WIDGET_RESIZED,FOCUS_LOST,VIDEO_SOURCE_ENDED; - the checks the module runs during capture:
CONFORMITY_POOR_LIGHT,CONFORMITY_MUCH_VARIATIONS,CONFORMITY_LOW_FPS; - the exchange with the servers:
CREATE_MEASURE_ERROR,WS_CONNECTION_ERROR,UNEXPECTED_WS_RESULT.
The codes the server computes — CONFORMITY_FACE_PRESENCE, CREDIT_EXCEEDED, MISSING_USER_DATA_* and the others — never come through aborted. They arrive in the result event, as a variable’s error.
`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, error: null } on success, or { value: null, error } on failure; the result describes the shape of each value. userData is present only when an external identifier was given, and then carries { externalId } alone.
Appearance: screens and colours
The widget’s appearance is best not written 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. When you create one, set Widget version to v1: the default is v2, and the identifier of a v2 customisation applies nothing to this module.

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.

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

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. What you pass in colors, pages or texts still takes precedence over it.
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.