Access tokens

How the widget obtains a short-lived credential at the start of every measurement, without ever holding your API key

The widget holds no secret. Before each measurement it asks for a short-lived access token, and options.proxy.retrieveAccessToken is how you answer.

This is the only mandatory option. Without it the widget still walks its screens — onboarding, consent, guidance are all available — but the moment a measurement would start it fails with UNDEFINED_ACCESS_TOKEN_PROXY.

Why a token, and why every time

Your API key is permanent, unscoped, and opens every route of the API. It has no business being in a page.

An access token is the opposite: scoped to one permission, valid for minutes, and consumed by the websocket handshake that opens the measurement. That single-use property is what makes it safe to put in a browser — and it is also why the widget asks again at every measurement start rather than caching one.

A user who abandons a measurement and retries will cause two token requests. That is expected: the first token was spent by the attempt that was abandoned.

The three strategies

StrategyWho calls the APIWhat you provideUse it when
delegateThe widget, against your endpointurl, optional headers and bodyYour server can expose an endpoint. The usual choice.
handleYour own codefetch, a function returning the tokenObtaining the token needs application state — a session, a queue, a token already in memory.
unsafe-api-keyThe widget, against the Saphere APIapiKeyDemos and trials, on Test only. Never Production.

delegate — the widget calls your endpoint

const instance = SaphereScan.create("#scan", {
    proxy: {
        retrieveAccessToken: {
            strategy: "delegate",
            url: "/api/saphere-token"
        }
    }
})

url is either an absolute http(s) URL or a path rooted at /, served by the page itself.

The widget sends a POST with no body by default — there is nothing about the measurement to send, since it does not exist yet. Your endpoint has nothing to read; it only has to decide whether this user may run a measurement, and answer with a token.

Cookies are not sent

The request goes out without credentials. A cross-origin endpoint must authenticate through headers and nothing else — a session cookie on your domain will not travel with it. A same-origin path is the simplest way to keep your existing session.

Two response shapes are accepted:

{ "value": "ivst-8Kx2mQ…", "id": "3f1a…", "expiresAt": "2026-08-24T16:00:00.000Z" }
"ivst-8Kx2mQ…"

The first is the API’s own response to POST /access-tokens, so you can relay it verbatim without unpacking it. The second is a bare string, for an endpoint that returns only what it must.

It is `value` that opens, never `id`

The database stores only a digest of the token, so the row’s id opens nothing at all. An endpoint that relays id alone will have every handshake refused — with a token that looks perfectly well-formed.

headers authenticates the call, and body exists for the case where the endpoint you are pointing at is an API that requires parameters rather than an integrator endpoint of your own:

retrieveAccessToken: {
    strategy: "delegate",
    url: "/api/saphere-token",
    headers: { "X-Session": sessionId }
}

handle — you fetch it yourself

Take this as soon as obtaining a token needs more than one request: an application session to consult, a token already held in memory, a queue to wait on, a test double to substitute.

const instance = SaphereScan.create("#scan", {
    proxy: {
        retrieveAccessToken: {
            strategy: "handle",
            fetch: async signal => {
                const response = await fetch("/api/saphere-token", {
                    method: "POST",
                    signal,
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ userId: currentUser.id })
                })
                const { value } = await response.json()
                return value
            }
        }
    }
})

The function receives the attempt’s AbortSignal. Forward it to fetch and an in-flight request is cancelled when the user leaves the screen, instead of resolving into a measurement nobody is waiting for.

It may return a string or a promise of a string, and it may be declared with no parameters at all — the widget assumes nothing beyond “this is a function”.

A function with no `return`

The most common integration mistake is a fetch that performs the request and forgets to return its result. The widget reports it as token:error with code UNUSABLE_RESPONSE, which is exactly the case to look for first.

unsafe-api-key — trials only

retrieveAccessToken: { strategy: "unsafe-api-key", apiKey: "2428fbbc-…" }

The widget calls the API itself, with your client key. The name says what it is.

The API key is permanent and unscoped. Putting it here puts it in the page — in the bundle, in the browser cache, in the developer tools of every user. A leak is not revoked by rotating a token: it is revoked by changing the key, which cuts every integration of that client at once.

It exists so a demo page works in thirty seconds. It has no place in a shipped product — and the key you put here must be a Test key. A Production key in a page is the one mistake on this page that cannot be undone quietly.

The endpoint you need to write

For delegate and handle, you need an endpoint that mints a token. It must call the API of the same environment the widget was loaded from — that half is yours to get right, the widget cannot infer it. The sample below targets Production; on Test, only the host changes.

EnvironmentAPI base
Productionhttps://api.saphere.ai
Testhttps://api.test.saphere.ai

Here it is with Express:

import express from "express"

const app = express()

const SAPHERE_API = "https://api.saphere.ai"
const API_KEY = process.env.SAPHERE_API_KEY   // never in the client bundle

app.post("/api/saphere-token", async (request, response) => {
    // Your own authorisation, whatever it is: a session, a quota, an entitlement.
    if (!request.session?.userId)
        return response.status(401).end()

    const created = await fetch(`${SAPHERE_API}/access-tokens`, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${API_KEY}`
        },
        body: JSON.stringify({
            permissions: [{ name: "websocket.measurement", maxAttempts: 1 }],
            duration: "15min"
        })
    })

    if (!created.ok)
        return response.status(502).end()

    const { value } = await created.json()
    // Relay only what opens. `id` and `expiresAt` may be logged; `value` may not.
    response.json({ value })
})

This endpoint is where your business rules live. It is the only place that can decide this user, right now, may run a measurement — the widget cannot, and the API only knows that the key is valid.

permissions

An array, at least one entry. Today one permission exists:

NameWhat it grants
websocket.measurementOpening one measurement session.

maxAttempts bounds how many times the token may open a session.

Omitting `maxAttempts` yields a replayable token

The field is nullable with no default, and null means no limit — such a token is bounded only by its duration. Nothing fills it in on your behalf, because a default written there would silently bound a token an integrator deliberately left unbounded. Write maxAttempts: 1 unless you have a reason not to.

duration

An interval, not a date: 15min, 2h, 48h. The server evaluates it at insert time, so no clock has to be agreed on between your machine and ours.

The ceiling is 48 hours, and a request above it is refused. A token lives for the measurement it opens; it does not need to outlive the session that requested it.

Beware one abbreviation, inherited from the interval grammar: m means minutes. Months are mon. Since the shortest week already exceeds the ceiling, any duration written with a unit above the day is refused outright.

Revoking a token

curl -X DELETE https://api.saphere.ai/access-tokens/3f1a2b4c-… \
     -H "Authorization: Bearer $SAPHERE_API_KEY"

Here it is the id you need, not the value — the id is what identifies the token, the value is what opens it.

Unknown, already-revoked and another client’s token all answer the same 404, so nobody can probe for tokens they do not own. An expired token is still deletable, which is what lets you clean up.

Diagnosing a failure

Every failure on this path is reported as token:error with a code that says whose problem it is:

codeWhere to look
NO_PROXYproxy.retrieveAccessToken was not declared at all.
STRATEGY_FAILEDYour function threw, or the network dropped.
HTTP_ERRORYour endpoint answered outside the 2xx range.
UNUSABLE_RESPONSEThe response carried no usable token — most often a function with no return.
TIMEOUTMore than five seconds to answer.

The token itself is never carried by any event, under any code.

One cause is worth ruling out before the others: a token minted on one environment and presented to the other. It is refused, and the session simply never opens — see Environments.