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

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

One permission exists today.

{ "name": "websocket.measurement", "maxAttempts": 1 }
FieldTypeNotes
name"websocket.measurement"Allows opening a real-time measurement session.
maxAttemptsinteger ≥ 1, or nullHow 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:

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 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 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 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"
}
FieldWhat it is
idNames the token. Pass it back to revoke. Opens nothing.
valueThe credential. Begins with ivat-. Hand this to the widget.
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 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:

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

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