Errors
The error envelope and the status codes the API returns.
Error envelope
Every failed request returns:
{
"ok": false,
"error": {
"code": "validation_error",
"message": "human readable explanation"
}
}Always branch on ok first, then read error.code for programmatic handling and
error.message for logs or display.
Every response — successful or not — also carries an X-Request-Id header,
exposed to browser clients through CORS. It is not in the body; read it off the
response and keep it. It is the id under which we recorded the call, so it is
what makes a failure findable afterwards in the
usage log and in the console's ⌘K search.
const res = await fetch(url, { method: 'POST', headers, body });
const requestId = res.headers.get('x-request-id');Status codes
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | validation_error | Malformed body or missing / invalid parameters. |
| 401 | unauthorized | Missing or invalid HMAC signature, or unknown / disabled key. |
| 403 | quota_exceeded | The key's monthly quota of cloud seconds is exhausted. |
| 403 | feature_not_allowed | A requested cloud feature is outside the set allowed for this key. |
| 404 | not_found | Unknown resource — e.g. a measurement id that doesn't exist. |
| 409 | idempotency_conflict | The same idempotencyKey was already used for a different payload. |
| 409 | already_completed | The cloud session already produced a measurement. |
| 413 | payload_too_large | The JSON body is over 256 KB — most often a raw block with a long PPG signal. |
| 429 | rate_limited | Too many requests; back off and retry. Fires on the per-session streaming limits, on the log polling limit, and on any signed path once the key owner enables a per-key limit — see Cloud → Limits & errors. |
| 500 | internal_error | Unexpected server error. |
| 503 | cloud_unavailable | The cloud tier is not configured or its object storage is unreachable. |
The cloud endpoints add their own codes on top of this envelope —
consent_required, sha256_mismatch and the rest — plus
the WebSocket error events, which are not HTTP at all. They are listed in
Cloud → Limits & errors.
Common causes
unauthorized— clock skew beyond the ±300 s window (checkx-timestampis Unix seconds), a signature over the wrong string (sign the timestamp and Base64-encode), or a rotated / expired / disabled key.validation_error— a field type mismatch, e.g.stress.levelnot one oflow | moderate | high,sourcenotweb | ios | playground, or asdkVersion/modelsVersionoutside^[A-Za-z0-9][A-Za-z0-9._+-]*$.rate_limited— you exceeded a rate limit: a per-session streaming limit, or a per-key limit the key owner enabled. Apply exponential backoff.quota_exceeded— the key ran out of its monthly cloud seconds; the quota is set (or removed) in the console.
When unauthorized is not specific enough
401 unauthorized is one code for nine different credential problems, on purpose:
telling an unauthenticated caller which part of its credentials was wrong is
telling an attacker the same thing. The distinction is not lost, only moved — the
key's own usage log records each refusal with a machine
errorCode: invalid_signature, timestamp_out_of_window, unknown_key,
key_expired, nonce_reused and the rest. Reading that log needs a valid
signature, so only the key's owner sees it.
Handling in code
const res = await fetch(url, { method: 'POST', headers, body });
const json = await res.json();
if (!json.ok) {
switch (json.error.code) {
case 'unauthorized': return refreshSignatureAndRetry();
case 'rate_limited': return backoffAndRetry();
case 'validation_error': return surfaceValidationIssue(json.error.message);
default: throw new Error(json.error.message);
}
}