Authentication
Two kinds of credential, what each one opens, and why only one of them may reach a browser.
Every call to the API carries a credential, in the Authorization header.
Authorization: Bearer <credential>
There are two kinds of credential. Confusing them is the most expensive mistake you can make with this API.
First, choose the address. The API exists once per environment, and a credential issued for one is refused by the other. The examples on this page use Production.
| Environment | API base |
|---|---|
| Production | https://api.saphere.ai |
| Test | https://api.test.saphere.ai |
The two environments are completely separate: separate accounts, separate keys, separate data. See Environments.
The API key
Your API key identifies your client account. It is a plain UUID.
POST /access-tokens HTTP/1.1
Host: api.saphere.ai
Authorization: Bearer 2428fbbc-d322-49cb-90a7-4e93401e67f8
Content-Type: application/json
Three things about it are worth stating plainly.
- It has no limits. It opens every route of the API: creating measurements, reading them back, deleting them, issuing tokens.
- It has no expiry. Nothing about the key itself limits how long it is valid.
- The only thing that stops it is your account being deactivated. Revoking a key means deactivating the account, which cuts off every integration using it at the same moment.
The key must never reach a browser or a mobile bundle
A key shipped inside client code is a key anyone can read: by opening the browser’s developer tools, by unpacking a mobile app, or by fetching your files from a cache. And since revoking it means deactivating the account, a leak is not a small incident.
The widget is built so that it never needs the key. It asks your server for a short-lived token instead. Use that route.
Access tokens
An access token is the credential you can hand to a browser. Your server creates it from your API key, with POST /access-tokens. It is deliberately weak.
| API key | Access token | |
|---|---|---|
| Scope | Everything | Only the permissions it names |
| Expiry | None | Mandatory, at most 48 hours |
| Replayable | Always | Only if you allow it |
| Revocation | Deactivate the account | DELETE /access-tokens/{id} |
| Safe in a browser | No | Yes, by design |
Two permissions exist: measurement.scan, the Saphere Scan measurement, and measurement.realtime, the real-time chain. They allow opening a measurement session, and nothing else. A token that carries one cannot read your measurements, cannot delete anything, and cannot create other tokens.
Give it maxSessions: 1 and it opens exactly one session. That is what a widget integration should use: a token spent by the measurement it was created for, and worth nothing afterwards.
The token value and the token id are different strings
POST /access-tokens returns both. value is the credential. It begins with ivat-, it is returned only once, and it is what the widget presents. id names the token: it is what you pass to revoke it, and using it as a credential opens nothing.
Only a fingerprint of value is stored, so a copy of the database hands out no usable token.
Rate limiting
Requests are counted per credential and per route, over a rolling minute. Each route holds a count of its own, so a burst on one of them never closes another.
| Route | Limit |
|---|---|
POST /access-tokens | 20 per minute |
| Everything else | 300 per minute |
Creating tokens is deliberately tighter than the rest. Each call writes to the database, and a token is meant to be requested once, at the start of a measurement. Never in a loop.
These bounds are sized for peaks rather than for steady traffic: they are there so that a burst does not turn into an outage. What governs your volume is your quota, not this table.
Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, the last one in seconds. Exceeding a limit returns 429, with a Retry-After header in seconds: the route stays closed to that credential for up to a minute.
Opening a measurement
The handshake that opens a Saphere Scan measurement — whether it presents an access token or a measure id — is counted separately, per client rather than per route:
| What is counted | Limit |
|---|---|
| measurements opened | 2 per second |
| measurements opened | 20 per minute |
The two windows go together: the second spreads out what the minute allows, a measurement holding memory for as long as it runs. An opening refused by this bound does not consume the access token it presented: the token stays usable, and a fresh attempt a moment later goes through.
These values leave a wide margin on what a user journey produces — a person does not start two measurements within the same second. You meet them when automating: a test harness that chains measurements has to space them out.
A useful diagnostic
If a response carriesX-RateLimit-* headers, the request reached a route and was refused by a check. If a 404 carries none — and its body is the plain text Not Found — no route matched at all: the path is wrong, rather than the resource.Error responses
Errors are JSON, with one exception worth knowing about.
{
"statusCode": 400,
"message": ["duration must be an interval amounting to more than zero, such as 2h 30min, 45min or 90s, each unit appearing at most once, and to at most 48h"],
"error": "Bad Request"
}
A path that matches no route answers in plain text: a 404 whose body is Not Found, with no JSON. Every other error, a 404 for a resource that does not exist included, is JSON — { "message": "Not Found", "statusCode": 404 }. If your code parses error bodies as JSON, check the Content-Type first, or a mistyped path will fail on the parsing instead of being handled by its status.
| Status | Meaning |
|---|---|
400 | The request body or query failed validation; message lists what. |
401 | Authorization header missing or malformed, unknown API key, or deactivated account. |
404 | No such resource, or not yours. Plain text only when no route matched the path. |
406 | Your Accept header rules out JSON on a route that answers JSON. Send application/json or */*. |
415 | A route taking a JSON body received another Content-Type. |
429 | Rate limit exceeded; Retry-After says when to come back. |
500 | The request could not be completed on our side. |
503 | The instance is shedding load; Retry-After says when to come back. |
Not found and not yours are the same answer
A resource belonging to another account returns exactly the same404 as one that never existed. This is deliberate: nobody can use the API to find out whether an identifier is real.