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

FieldTypeRequiredNotes
descriptionstringnoFree text, up to 255 characters. Only there to help you recognise the token later.
permissionsarrayyesAt least one entry. Naming the same permission twice is refused.
durationstringyesAn 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 }
FieldTypeNotes
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.
maxSessionsinteger ≥ 1, or nullHow 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:

UnitWritten
Secondss, sec, secs, second, seconds
Minutesm, min, mins, minute, minutes
Hoursh, hr, hrs, hour, hours
Daysd, 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 12h is valid; 1d 1day is 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, not 2d. 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 a 500 rather than a 400 today.

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"
}
FieldWhat it is
idNames the token. Pass it back to revoke. Opens nothing.
valueThe credential. Begins with ivat-. Hand this to the widget.
description, permissionsAs you sent them; description is null when you sent none.
expiresAtResolved 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.

EnvironmentAPI base
Productionhttps://api.saphere.ai
Testhttps://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:

CheckHow 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 picks value 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.