Access tokens
Minting the short-lived, scoped credential the widget presents — and revoking it.
An access token is what you hand to a browser in place of your API key. It names what it may do, it expires, and you can make it single-use.
Create a token
POST /access-tokens
Authorization: Bearer <apiKey>
Content-Type: application/json
{
"description": "Mobile application, iOS and Android",
"permissions": [{ "name": "websocket.measurement", "maxAttempts": 1 }],
"duration": "15min"
}
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
description | string | no | Free text, up to 255 characters. Only there to help you recognise the token later. |
permissions | array | yes | At least one entry. Naming the same permission twice is refused. |
duration | string | yes | An interval. At most 48h, and more than zero. Up to 64 characters. |
Permissions
One permission exists today.
{ "name": "websocket.measurement", "maxAttempts": 1 }
| Field | Type | Notes |
|---|---|---|
name | "websocket.measurement" | Allows opening a real-time measurement session. |
maxAttempts | integer ≥ 1, or null | How many sessions the token may open. |
`maxAttempts` has no default
Omit it — or send null — and the token carries no limit on how many sessions it opens. Its duration becomes the only thing bounding it, and it can be replayed until it expires.
That is a legitimate choice, but it must be a deliberate one. For a widget integration, write maxAttempts: 1 and mint a fresh token for every measurement.
An attempt is spent by opening a session, not by completing a measurement. A user who opens the camera and then walks away has spent the token. This is why the widget requests a new one at the start of every attempt rather than caching.
Durations
duration is written as an interval — a number, a unit, optionally repeated:
30s 15min 2h 48h
2h 30min 1d 1d 12h
Accepted units:
| Unit | Written |
|---|---|
| Seconds | s, sec, secs, second, seconds |
| Minutes | m, min, mins, minute, minutes |
| Hours | h, hr, hrs, hour, hours |
| Days | d, day, days |
`m` means minutes
It does not mean months. Months are written mon. This follows the interval grammar the database uses, and it is the single most likely thing to get wrong here.
In practice it rarely bites, because anything expressed in weeks or months exceeds the 48-hour ceiling and is refused outright.
Two further rules:
- A unit may appear at most once, counting all its spellings as the same unit.
1d 12his valid;1d 1dayis not. - The ceiling is 48 hours. A token exists for the measurement it opens, not for the length of a contract. It is enforced both when validating your request and on the table itself.
Response
201 Created
{
"id": "8f1c2d34-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"value": "ivat-XCq7mR4vT8pLnW2yZbJ6sD9eA1gUiO5rN0kMxPq7Yt",
"description": "Mobile application, iOS and Android",
"permissions": [{ "name": "websocket.measurement", "maxAttempts": 1 }],
"expiresAt": "2026-08-24T15:12:44.000Z",
"createdAt": "2026-08-24T14:57:44.000Z"
}
| Field | What it is |
|---|---|
id | Names the token. Pass it back to revoke. Opens nothing. |
value | The credential. Begins with ivat-. Hand this to the widget. |
expiresAt | Resolved from the duration, by the database, at insert. |
`value` is returned exactly once
Only a hash of it is stored. No route can give it back. If you lose it, revoke the token and mint another.
It follows that value should not be written to your logs. id and expiresAt can be, safely — they open nothing.
expiresAt is exactly createdAt plus the duration you asked for, resolved by the database rather than by your clock or ours.
Revoke a token
DELETE /access-tokens/{id}
Authorization: Bearer <apiKey>
200 OK on success. The {id} is the id from the creation response — not value.
One answer for four situations
An unknown id, an already-revoked token, a token belonging to another client, and a well-formed id that never existed all return the same 404 — in plain text. Nobody can probe for tokens they do not own.
A malformed id returns 400 instead: it is refused by validation before any lookup happens.
An expired token is still deletable. Expiry hides a token from every read, but it does not remove the row, and cleaning up after yourself remains possible.
Worked example
The commands below run against Production. The same calls on Test differ only in the base:
| Environment | API base |
|---|---|
| Production | https://api.saphere.ai |
| Test | https://api.test.saphere.ai |
A token minted on one is never valid on the other — see Environments.
# Mint a single-use token valid for a quarter of an hour
curl -sX POST https://api.saphere.ai/access-tokens \
-H "Authorization: Bearer $SAPHERE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"description": "web widget",
"permissions": [{ "name": "websocket.measurement", "maxAttempts": 1 }],
"duration": "15min"
}'
# Revoke it early
curl -sX DELETE https://api.saphere.ai/access-tokens/8f1c2d34-5a6b-4c7d-8e9f-0a1b2c3d4e5f \
-H "Authorization: Bearer $SAPHERE_API_KEY"
When a token is refused
The measurement server refuses a handshake without saying why — deliberately, so that a rejected token cannot be probed for the reason. Work through the list:
| Check | How to tell |
|---|---|
| Expired? | Compare expiresAt with now. Tokens are minted per measurement for a reason. |
| Already spent? | With maxAttempts: 1, opening a session consumes it. A retry needs a new token. |
| Revoked? | DELETE is irreversible. |
| Client deactivated? | Every token of an inactive client stops working at once. |
Sent id instead of value? | The commonest cause. id is a UUID; value starts with ivat-. |
Relaying the whole response is fine
If your token endpoint relays the API’s answer verbatim, the widget picksvalue out of it by itself. Relaying only id is the classic integration bug — the call succeeds, and every measurement is refused.Rate limit
POST /access-tokens is limited to 10 calls per minute per API key, tighter than the general 120. Mint one token per measurement, at the moment the measurement starts.