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": "measurement.scan", "maxSessions": 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
Two permissions exist, one per measurement chain.
{ "name": "measurement.scan", "maxSessions": 1 }
| Field | Type | Notes |
|---|---|---|
name | "measurement.scan" or "measurement.realtime" | What the token opens: a Saphere Scan session, whose result is computed at the end, or a real-time chain session, whose vitals come back as it runs. |
maxSessions | integer ≥ 1, or null | How many sessions the token may open. |
These are two distinct doors: a token minted for one is not accepted by the other. One token may carry both, by asking for both — naming the same one twice is still refused.
`maxSessions` 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 maxSessions: 1 and mint a fresh token for every measurement.
A session is counted when it is opened, not when a measurement completes — a reconnection to a session already open is not counted. 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 a length of time: a number, a unit, and optionally more of the same.
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 this rarely matters: anything expressed in weeks or months goes past the 48-hour limit and is refused anyway.
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 again when the token is stored.
- Write the ceiling in hours:
48h, not2d. Days are counted on the calendar when the token is stored, where a day does not always last 24 hours. A duration in days that comes out longer than the ceiling passes validation but is refused at that point — with a500rather than a400today.
Response
201 Created
{
"id": "8f1c2d34-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"value": "ivat-GPPctPJ3HURBcwEKJFq26Ckp4khJUeNp8qjhLK0RblozlBfP0",
"description": "Mobile application, iOS and Android",
"permissions": [{ "name": "measurement.scan", "maxSessions": 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. |
description, permissions | As you sent them; description is null when you sent none. |
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 length of time you asked for. The database works it out, so neither your clock nor ours can shift it.
Revoke a token
DELETE /access-tokens/{id}
Authorization: Bearer <apiKey>
200 OK on success, with an empty body. The {id} is the id from the creation response, not value.
One answer for three situations
An id that never existed, an already-revoked token, and a token belonging to another client all return the same 404, { "message": "Not Found", "statusCode": 404 }. 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 address.
| Environment | API base |
|---|---|
| Production | https://api.saphere.ai |
| Test | https://api.test.saphere.ai |
A token created 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": "measurement.scan", "maxSessions": 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 maxSessions: 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. |
| Opening too fast? | Beyond two openings a second or twenty a minute for your account, openings are refused the same way — without spending the token. See opening a measurement. |
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 mistake: the call succeeds, and every measurement is then refused.Rate limit
POST /access-tokens is limited to 20 calls per minute per API key, tighter than the general 300. Mint one token per measurement, at the moment the measurement starts.