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 through its screens: introduction, consent and guidance all work. But the moment a measurement would start, it fails: token:error with code NO_PROXY, then measure:failed with code 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 the permission it is minted for, valid for minutes, and consumed by the handshake that opens the measurement session. 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 reconnection to a session already open consumes nothing: a network drop during a measurement does not need a second token.
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 JSON POST whose body carries the measurement chain being asked for, and nothing else by default:
{ "measurement": "scan" }
There is nothing about the measurement to send, since it does not exist yet. What describes the measurement — patient data, tags, the variables you want — is a separate option, createMeasureOptions: the token authorises, it does not describe. Your endpoint only has to read which chain is being asked for, decide whether this user may run a measurement, and answer with a token.
Cookies only reach your page's own origin
The request goes out with the browser’s default credentials mode: your session cookie travels to a path of your page’s own origin, and to nothing else. A cross-origin endpoint receives no cookie and must authenticate throughheaders — and, the request carrying Content-Type: application/json, it must also answer the browser’s CORS preflight. A same-origin path is the simplest way to keep your existing session.Two response shapes are accepted:
{ "id": "3f1a…", "value": "ivat-8Kx2mQ…", "description": null, "permissions": [{ "name": "measurement.scan", "maxSessions": 1 }], "expiresAt": "2026-08-24T16:00:00.000Z", "createdAt": "2026-08-24T15:45:00.000Z" }
"ivat-8Kx2mQ…"
The first is the API’s own response to POST /access-tokens, so you can relay it verbatim without unpacking it: the widget reads its value. The second is the token alone, for an endpoint that returns only what it must — as plain text, or serialised as a JSON string, quotes included.
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 the id as if it were the token will have every handshake refused — with a token that looks perfectly well-formed. One that relays an object carrying id but no value gets UNUSABLE_RESPONSE.headers authenticates the call, and body is added to the body for the case where the endpoint you aim at is an API demanding parameters rather than an endpoint of your own. The widget’s measurement is merged on top of yours: the chain announced is the one being measured, never a copied value that has aged.
retrieveAccessToken: {
strategy: "delegate",
url: "/api/saphere-token",
headers: { "X-Session": sessionId }
}
handle — you fetch it yourself
Choose this as soon as obtaining a token takes more than one request: a session of your own to consult, a token already held in memory, a queue to wait on, or a stub to substitute during tests.
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 two arguments: the attempt’s AbortSignal, then a { measurement } context saying which measurement chain is being asked for. Forward the signal 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.
fetch: async (signal, { measurement }) => {
// `measurement` is "scan" or "realtime": ask for the matching permission
}
It may return the token as a string, or the API’s own response — an object carrying value —, or a promise of either, 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, and asks for a token carrying the one permission the chain needs, limited to one session and fifteen minutes. 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.
Which measurement chain
A token carries a permission, and there are two: measurement.scan, the collection whose result is computed at the end, and measurement.realtime, the chain whose vitals come back during the measurement. A token minted for one is refused by the other — so you have to know which one to ask for.
It is not yours to declare: the widget tells you. The chain is not an option, it is a fact of what the widget measures, and it reaches you through each of the three strategies:
| Strategy | How the chain reaches you |
|---|---|
delegate | A measurement field of the POST’s JSON body, always present. Your body is merged underneath, so the widget keeps the last word on this one point. |
handle | The second argument of fetch, in a context object: fetch: async (signal, { measurement }) => …. A function written before this field still finds its AbortSignal where it left it. |
unsafe-api-key | Nothing to do: the widget asks POST /access-tokens for the matching permission itself. |
It is "scan" for the certified journey, and "realtime" when the widget opens its real-time screen — the realtime option. An integration that enables that option therefore has to answer both values: an endpoint that always mints a measurement.scan token sees every real-time handshake refused, with nothing having changed on its side.
token:requesting carries the same value, so a supervision sees what was asked for.
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. It reads the chain the widget announces, and asks for the matching permission:
import express from "express"
const app = express()
app.use(express.json())
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()
// "scan" for the certified journey, "realtime" for the real-time screen
const permission = request.body?.measurement === "realtime" ? "measurement.realtime" : "measurement.scan"
const created = await fetch(`${SAPHERE_API}/access-tokens`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`
},
body: JSON.stringify({
permissions: [{ name: permission, maxSessions: 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.
POST /access-tokens accepts twenty calls a minute per API key, and answers 429 beyond that — one token per measurement, minted when the measurement starts, stays well within it. The request also takes an optional description, and the API reference details both bodies.
permissions
An array, at least one entry. Two permissions exist, one per chain:
| Name | What it grants |
|---|---|
measurement.scan | Opening one Saphere Scan measurement session, computed at the end. |
measurement.realtime | Opening one real-time measurement session. |
The permission you ask for must follow the measurement the widget announces: a token minted for one chain is refused by the other.
maxSessions bounds how many times the token may open a session: an integer, at least 1. Each session opened counts, whether or not the measurement that follows succeeds.
Omitting `maxSessions` 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 maxSessions: 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. A unit may appear only once — 1d 2h is accepted, 1h 1h is not — and the duration must be more than zero.
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 request could not be made — a network drop, a CORS refusal. |
HTTP_ERROR | Your endpoint answered outside the 2xx range — or the API itself, under unsafe-api-key. |
UNUSABLE_RESPONSE | The response carried no usable token — most often a function with no return, or a relayed id without its value. |
TIMEOUT | More than five seconds to answer. |
The token itself is never carried by any event, under any code. During the certified journey the attempt then ends with measure:failed, code ACCESS_TOKEN_ERROR, ACCESS_TOKEN_TIMEOUT or UNDEFINED_ACCESS_TOKEN_PROXY. On the real-time screen the widget moves on to the certified measurement with realtime:unavailable — and a token that takes more than five seconds there is reported by that event alone, without token:error.
A token that was obtained but is refused by the server is not a token:error: token:retrieved arrives, then the handshake is refused. The token was minted on the other environment, for the other chain, has expired, or has already opened all its sessions. On the real-time screen this ends in realtime:unavailable. During the certified journey the widget keeps retrying the connection: transport:reconnecting repeats with a growing attempt, transport:connected never comes, the capture still runs its thirty seconds, and the attempt ends on measure:failed, code WS_RESULT_TIMEOUT, ten minutes after it. That sequence is the signature to look for — the environment first, see Environments.