This is the consolidated table of error code values returned by the
HTTP API. The text of message is human-readable and may change without
notice; the code is stable and is what you should branch on.
For the error-envelope shape see the
API overview.
| HTTP | code | When |
|---|
| 401 | auth_required | No Authorization header on a protected route |
| 401 | token_invalid | Token is malformed or revoked |
| 401 | token_expired | Token has expired (session tokens only) |
| 403 | forbidden | Token correct, but scope does not cover route |
| 403 | device_not_in_token | Device token does not own the device in body |
| 403 | account_suspended | Account is past-due or admin-suspended |
| HTTP | code | When |
|---|
| 400 | bad_json | Body is not valid JSON |
| 400 | bad_query | Query string parameter has wrong type |
| 400 | missing_field | Required field omitted; message names the field |
| 400 | no_measurements | measurements array is empty on ingest |
| HTTP | code | When |
|---|
| 422 | unknown_kind | Measurement type not in supported set |
| 422 | out_of_range | Sanity-check bound exceeded (see POST /v1/ingest) |
| 422 | ts_in_future | ts more than 30 s in the future |
| 422 | ts_too_old | ts more than 30 days in the past |
| 422 | invalid_range | Rule ok_min > ok_max |
| 422 | grace_too_long | Rule grace > 6 h |
| 422 | period_empty | Report period contains no measurements |
| 422 | period_too_long | Report period > 13 months (beyond raw retention) |
| 422 | unknown_template | Report template not in supported set |
| HTTP | code | When |
|---|
| 404 | account_not_found | (Diagnostic only — never leaks existence) |
| 404 | site_not_found | |
| 404 | device_not_found | |
| 404 | sensor_not_found | |
| 404 | channel_not_found | |
| 404 | rule_not_found | |
| 404 | event_not_found | |
| 404 | report_not_found | |
| 409 | local_id_taken | Another device on the site has the same local_id |
| 409 | idempotency_conflict | Same key, different body, within 24 h |
| HTTP | code | When |
|---|
| 429 | rate_limited | Generic — see X-RateLimit-* headers |
| 429 | report_rate_limit | > 4 reports / min / account |
| 429 | ingest_rate_limit | > 60 req / min / device |
| 429 | export_rate_limit | > 1 export / 5 min / account |
| HTTP | code | When |
|---|
| 500 | internal | Catch-all — request_id in the body |
| 503 | db_unavailable | Postgres unreachable; retry with backoff |
| 503 | pdf_unavailable | PDF service container restarting |
| 504 | webhook_timeout | Customer-supplied webhook destination > 5 s |
For client code:
# Pseudocode for a defensive ingest client
try:
post_ingest(payload)
except HTTPError as e:
code = e.json()['error']['code']
if code in ('rate_limited', 'ingest_rate_limit'):
sleep(retry_after_seconds); retry()
elif code in ('bad_json', 'no_measurements', 'unknown_kind',
'out_of_range', 'ts_in_future', 'ts_too_old'):
log_locally(payload, e); drop() # do NOT retry, fix client
elif code in ('auth_required', 'token_invalid', 'token_expired',
'device_not_in_token', 'forbidden'):
alert_operator(e); halt() # human intervention
elif code in ('internal', 'db_unavailable'):
sleep(exponential_backoff); retry()
else:
log_locally(payload, e); drop() # unknown error — fail safe
For your own integration tests, asserting on code rather than HTTP
status preserves correctness across our internal refactors.