NeccessoryNeccessory
REST API

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

HTTPerror.codeMeaning
400validation_errorMalformed body or missing / invalid parameters.
401unauthorizedMissing or invalid HMAC signature, or unknown / disabled key.
403quota_exceededThe key's monthly quota of cloud seconds is exhausted.
403feature_not_allowedA requested cloud feature is outside the set allowed for this key.
404not_foundUnknown resource — e.g. a measurement id that doesn't exist.
409idempotency_conflictThe same idempotencyKey was already used for a different payload.
409already_completedThe cloud session already produced a measurement.
413payload_too_largeThe JSON body is over 256 KB — most often a raw block with a long PPG signal.
429rate_limitedToo 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.
500internal_errorUnexpected server error.
503cloud_unavailableThe 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 (check x-timestamp is 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.level not one of low | moderate | high, source not web | ios | playground, or a sdkVersion / modelsVersion outside ^[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);
  }
}