Hooks
Where your code can refuse what the widget is about to do, bounded so that it can never freeze the journey
Events let you watch. Hooks let you act.
A hook is awaited, and a literal false refuses what it announces. There are two of them, and the reason there are only two is a safety argument worth reading before you use either.
const instance = SaphereScan.create("#scan", {
hooks: {
beforeScreenChange: ({ from, to }) => to !== "/measurement" || quotaRemaining()
},
proxy: { retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" } }
})
The contract
Four rules, and they hold for every hook.
Only a literal false refuses. Everything else lets through — undefined, null, true, 0, an empty string, an object. A function that performs a check and forgets to return therefore stops nothing, which is the safe direction for that mistake.
A two-second guard delay, and it is not configurable. Past it the widget proceeds as if the hook had not answered. The delay is a constant rather than an option on purpose: the hook holds a transition open for its whole duration with no spinner on screen, so a sixty-second value would be a sixty-second frozen widget. A hook that times out is reported in the console — it is the one case no event carries, because it resolves to allow and there is nothing to observe.
A throw or a rejection lets through, and is reported in the console. Your code failing is never a reason to stop a user’s measurement, and it is never reported as a widget error.
No hook runs during acquisition. Both hooks sit at moments when the user is idle. Nothing that happens during the thirty seconds of capture can be held up by integrator code — by construction, not by convention.
Why not just read `onEvent`'s return value?
The v1 widget did exactly that, and it meant a slow handler could stall a measurement. Keeping observation fire-and-forget and putting the power to refuse in a separate, time-bounded surface is what removes that risk while keeping the capability.beforeScreenChange
Called before each screen transition. Receives the two screens as the integrator sees them.
hooks: {
beforeScreenChange: async ({ from, to }) => {
if (to !== "/measurement")
return true
// Ask your own backend whether this user still has a measurement left
const { allowed } = await fetch("/api/quota").then(response => response.json())
return allowed
}
}
| Context | { from, to } — the same paths screen:enter reports |
Effect of false | The navigation does not happen. The user stays where they are. |
| Reported as | screen:blocked with from and to |
A refusal leaves the user on a screen whose controls still work. That is the whole reason this hook is defensible: they can press the button again, or go back.
Screens it is never asked about
Three, plus the first one, and the exclusions are not conservatism.
The very first screen. It is reached by a redirect, with no previous screen. Refusing it would leave the widget blank, with nothing for the user to act on.
/result and /error. Refusing the result screen would strand the user on the waiting screen forever, after measure:result has already been delivered to you. Refusing the error screen would leave them without the message that explains what happened.
/load-face-detectors. Refusing it makes every measurement impossible, with no error and no button — a silent dead end.
Those three are excluded on both sides of a transition, which has a second effect worth knowing: the internal bounce through the detector-loading screen and back to the measurement no longer counts as two questions asked for a single user gesture.
beforeMeasureStart
Called at the moment the face is accepted, just before the framing is frozen and acquisition begins.
hooks: {
beforeMeasureStart: async () => {
const { allowed } = await fetch("/api/quota", { method: "POST" })
.then(response => response.json())
return allowed
}
}
| Context | {} |
Effect of false | The attempt ends. |
| Reported as | measure:aborted with code REFUSED_BY_INTEGRATOR |
This is the last moment at which a measurement can be declined without wasting the thirty seconds of capture, which makes it the right place for a quota check or a final consent gate.
A refusal here is terminal
It ends the attempt rather than suspending it. The placement screen cannot step back once the start has been decided, and in automatic mode — the default — a reversible refusal would re-ask every time the user holds the pose. The attempt therefore takes the existing failure path:measure:aborted, then the calm end-of-measurement screen.REFUSED_BY_INTEGRATOR is classed as an abort, not a failure. Nothing is broken; someone said no. That classification is also what selects the calmer screen for the user, rather than one titled with an error.
The context is empty rather than carrying an attempt number, because the numbering lives one level up. Correlate with the measure:attempt event that immediately preceded it.
What hooks are not for
A hook is a gate, not a data source. Two things it deliberately cannot do:
It cannot supply the access token. That is proxy.retrieveAccessToken, which has its own three strategies and its own timeout. A hook around token retrieval would hold up a measurement whose captures are already accumulating.
It cannot hold the result. By the time a result exists, the measurement is computed and paid for. measure:result reports it; nothing gates it.
Putting both together
A typical production integration uses one hook and reads several events:
const instance = SaphereScan.create("#scan", {
lang: "en",
proxy: {
retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" }
},
hooks: {
// One decision, at the last free moment before thirty seconds of capture.
beforeMeasureStart: () => quota.consume()
},
onEvent: event => {
switch (event.type) {
case "screen:blocked":
analytics.track("scan_blocked", { to: event.to })
break
case "camera:error":
support.log("camera refused", event.code, event.name)
break
case "measure:result":
save(event.result)
break
case "measure:aborted":
analytics.track("scan_abandoned", { code: event.code })
break
case "measure:failed":
support.log("scan failed", event.code)
break
}
}
})
await instance.bootstrap()