Access tokens
One session, one token, minted by your server and consumed by the handshake.
A session is opened by presenting an access token, in the URL of the handshake:
https://api-webtransport.<your environment host>/measures?accessTokenValue=<token>&visual=video-full&format=webm
It travels in the URL rather than in a header because a WebTransport handshake carries none — that is the interface, and it is the same for everyone. Two consequences follow, and they are why the token is what it is: a URL is written down in more places than a header, so mint one per session and keep its lifetime to the gap between the decision to measure and the handshake itself.
Your server mints it
Your server holds the API key and calls POST /access-tokens with the permission measurement.realtime:
{
"permissions": [{ "name": "measurement.realtime", "maxAttempts": 1 }],
"duration": "15min"
}
maxAttempts: 1 is worth writing explicitly: the field is optional, and an absent bound means no limit — a token that stays replayable for its whole duration, while travelling in a URL.
The answer carries value, shown once and never again: the API keeps only a digest of it. That is the string the handshake takes, and id — which the same answer carries — opens nothing; it is what revokes the token.
The API key stays on your server
It is permanent, unscoped, and opens every route of the API. A token is the opposite: one chain, one session, minutes of life. Nothing that reaches a client should be anything but a token.From the browser library
The library never holds a token: it asks you for one, through retrieveAccessToken, and it asks at every start() — a token is single-use, and the handshake spends it. Nothing is cached, which is why the option is a callback and not a value.
RealtimeMeasure.create({
video: "#camera",
retrieveAccessToken: () => fetch("/my-server/realtime-token").then(response => response.text())
})
Return the string, or a promise of it. Anything else — a rejection, a throw, no answer within ten seconds, or an answer that is not a non-empty string — fails the start, and token:error says which:
reason | What happened |
|---|---|
no-callback | No retrieveAccessToken was given. The library cannot measure without one. |
strategy-failed | Your callback threw, rejected, or stayed silent for ten seconds. |
unusable-response | It answered something that is not a non-empty string. |
The token’s value travels under no event, and is in no payload the library hands you. If your endpoint relays the API’s own answer, take value from it — id opens nothing, it is what revokes.
What a refusal looks like
The handshake consumes the token — resolving it is spending it — so a session that opens and is then abandoned has spent one all the same. A second handshake needs a second token, which is what makes an automatic retry a deliberate decision rather than a free one.
A token that is expired, already spent, malformed, revoked, or minted for the other chain is refused with a 401, and the session never opens: the client sees its ready promise reject, with nothing more precise. That is deliberate — a refusal that said which of those it was would answer questions nobody legitimate is asking — so it is your own logs, on the minting side, that tell them apart.
One permission per chain
measurement.realtime opens a realtime session and nothing else. A token minted with measurement.scan — the Saphere Scan chain — is refused here, and the reverse holds too.