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. This delay is fixed rather than configurable, on purpose. While a hook runs, the transition is held with nothing shown on screen. A sixty-second value would mean a widget frozen for a minute. 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 falseThe navigation does not happen. The user stays where they are.
Reported asscreen: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.

Do not refuse leaving the welcome animation

The welcome screen, /intro, hands over by itself when its animation ends: it has no button. Refusing a transition whose from is /intro leaves the user on it with nothing to press, for good. Let those through — a refusal is only safe where the screen the user stays on still offers a control.

Screens it is never asked about

Three, plus the first one and the test screens, 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, so neither “Retry” on the abnormal-end screen nor the way on from the result screen is asked about. This has a second effect worth knowing: the internal detour through the detector-loading screen and back to the measurement no longer counts as two questions for a single user gesture.

The test screens. Those that previewScreen() shows are reached at your own request: having you approve the navigation you just asked for would make no sense, and a hook that refuses everything would lose its own preview.

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 falseThe attempt ends.
Reported asmeasure: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. That makes it the right place for a credit check, or for one last confirmation.

A refusal here is terminal

It ends the attempt rather than suspending it. Once the start has been decided, the placement screen cannot go back. And in automatic mode, which is the default, a refusal that could be reversed would ask again 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 last measure:attempt reported before 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()