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
| Strategy | Who calls the API | What you provide | Use it when |
|---|---|---|---|
delegate | The widget, against your endpoint | url, optional headers and body | Your server can expose an endpoint. The usual choice. |
handle | Your own code | fetch, a function returning the token | Obtaining the token needs application state — a session, a queue, a token already in memory. |
unsafe-api-key | The widget, against the Saphere API | apiKey | Demos 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 throughheaders 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’sid 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 afetch 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.
| Environment | API base |
|---|---|
| Production | https://api.saphere.ai |
| Test | https://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:
| Name | What it grants |
|---|---|
websocket.measurement | Opening 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, andnull 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:
code | Where to look |
|---|---|
NO_PROXY | proxy.retrieveAccessToken was not declared at all. |
STRATEGY_FAILED | Your function threw, or the network dropped. |
HTTP_ERROR | Your endpoint answered outside the 2xx range. |
UNUSABLE_RESPONSE | The response carried no usable token — most often a function with no return. |
TIMEOUT | More 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.