API errors

Every failure this API can return, what causes it, and what to do about it. Branch on error.code — it is stable. Do not branch on the HTTP status, which is coarse, or on error.message, which is written for people and can change.

{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_body",
    "message": "Provide at least a name or an identifier.",
    "param": "name",
    "request_id": "req_2f1c9a7be4d84c0fa1b3",
    "doc_url": "https://next.husbandry.pro/developers/errors#invalid_body"
  }
}

Every response — success or failure — carries a Request-Id header, repeated as request_id in the body. Quote it to support and we can find the exact request. Validation failures also carry issues[]; 405s carry allowed_methods.

Authentication

The key itself. Nothing else about the request was looked at.

missing_api_keyHTTP 401authentication_error

No key was sent

The request carried no Authorization header, or one this API does not recognize.

What to do

Send Authorization: Bearer hpk_live_…. This API does not read keys from a query string or a cookie (the one exception is the legacy sensor ping endpoint).

invalid_api_keyHTTP 401authentication_error

The key was rejected

The key does not exist, has been revoked, or has expired. The three are deliberately indistinguishable — telling them apart would let anyone test whether a key id is real.

What to do

Check the key was copied whole, including the hpk_live_ prefix, and that it has not been revoked in the app under Profile → API.

Permission

The key is real; it is not allowed to do this.

insufficient_scopeHTTP 403permission_error

The key lacks the scope

The key is valid but was not granted a scope this endpoint requires. The response names the scopes required and the ones the key holds.

What to do

Issue a new key with the missing scope. Scopes cannot be added to an existing key — that would let a key silently gain reach after the user approved it.

developer_terms_requiredHTTP 403permission_errorretry safe

The Developer Terms have not been accepted

This account has not accepted the Developer Terms, which are a precondition for the endpoints that pair hardware.

What to do

Accept them at /developers/terms, then retry.

advanced_feature_requiredHTTP 403permission_error

The plan does not include this

The endpoint is an Advanced Feature — sensor pairing is the current case — and this account's plan does not include it.

What to do

Upgrade to the Breeder plan or above. Nothing about the request needs to change.

api_not_in_planHTTP 403permission_error

The plan does not include API access

The key and the request are both fine — this account's subscription level does not include the public API. The response names the plan the account is on and the cheapest one that does include it.

What to do

Upgrade to the plan named in minimum_plan. Retrying will not help: unlike api_disabled this is a plan rule, not an outage.

Request shape

The request never reached the endpoint's logic.

missing_bodyHTTP 400invalid_request_error

A body was required and none arrived

The endpoint takes a JSON body and the request had none — often a client that dropped the body while following a redirect, or a POST sent with no -d.

What to do

Send the body, with Content-Type: application/json. Note that requests to this API are never redirected, so a body lost to a redirect means the URL is wrong.

payload_too_largeHTTP 413invalid_request_error

The request body is too large

JSON request bodies are capped at 1 MiB so one request cannot exhaust an API worker's memory.

What to do

Split bulk work into smaller requests. Sensor reading arrays already have a lower per-request item limit documented on that endpoint.

unsupported_media_typeHTTP 415invalid_request_error

The request body is not marked as JSON

Endpoints with a JSON body accept application/json and registered application/*+json media types.

What to do

Set Content-Type: application/json. The sensor ping compatibility endpoint is the documented exception.

invalid_jsonHTTP 400invalid_request_error

The body is not JSON

The body could not be parsed. Usually a trailing comma, a single-quoted string, or shell interpolation that mangled the payload.

What to do

Validate the payload as JSON before sending it.

invalid_bodyHTTP 400invalid_request_error

The body failed validation

The JSON parsed but does not match the endpoint's schema. param names the first offending field and issues[] lists up to 20 problems with their paths.

What to do

Fix the fields named in issues[]. The schema for every endpoint is in the OpenAPI document.

invalid_queryHTTP 400invalid_request_error

A query parameter failed validation

One or more query parameters are the wrong type or outside the allowed set. param names the first one.

What to do

Check the parameter against the endpoint's documented values.

method_not_allowedHTTP 405invalid_request_error

That method is not supported here

The path exists but not for this method. The response carries an Allow header and the same list as allowed_methods.

What to do

Use one of the allowed methods. Note this API has no PUT: updates are partial and go through PATCH.

resource_not_foundHTTP 404not_found_error

No such object — or not yours

Either the object does not exist, or it exists in another account. These are deliberately the same answer: distinguishing them would turn any id into a way to test what other accounts hold.

What to do

Check the id. If the path itself is wrong, compare it against the OpenAPI document — a 404 whose message mentions no route matching is a path problem, not a data problem.

Parameters and paging

A specific parameter is wrong. param names it.

invalid_limitHTTP 400invalid_request_error

limit is not a positive integer

limit was zero, negative, fractional, or not a number.

What to do

Send a whole number of 1 or more.

limit_too_largeHTTP 400invalid_request_error

limit is above the maximum

limit exceeded the endpoint's cap — 100 for most collections, 1000 for sensor readings.

What to do

Lower limit and page with next_cursor instead of asking for everything at once.

invalid_cursorHTTP 400invalid_request_error

cursor is not a cursor

The cursor was not one this API issued — hand-edited, truncated, or carried over from a different collection.

What to do

Pass back next_cursor from the previous response verbatim. Cursors are opaque; do not construct or modify them.

invalid_timestampHTTP 400invalid_request_error

A timestamp is not ISO-8601

A date/time parameter could not be parsed. param names it.

What to do

Use ISO-8601 in UTC, e.g. 2026-08-05T12:00:00Z.

invalid_booleanHTTP 400invalid_request_error

A boolean parameter is not a boolean

A true/false parameter held something else. param names it.

What to do

Send true/false (or 1/0).

invalid_rangeHTTP 400invalid_request_error

The time range is backwards

from is later than to, so the range selects nothing.

What to do

Swap the two parameters. Both are ISO-8601 timestamps in UTC.

missing_parametersHTTP 400invalid_request_error

A required parameter is absent

The endpoint needs parameters that were not supplied. The message names them.

What to do

Add the named parameters.

missing_target_typeHTTP 400invalid_request_error

target_id without target_type

An id alone is ambiguous: the same number can identify an environment, a rack, or a cage.

What to do

Send target_type alongside target_id.

Values this platform rejects

The shape was fine; the value does not refer to anything real.

invalid_speciesHTTP 400invalid_request_error

species_id is neither id nor slug

species_id matched no entry in this account's species list and no slug in the shared catalog.

What to do

List this account's species with GET /animal-types and use an id from there, or send a slug from GET /species — a catalog slug is added to the account's list automatically.

invalid_type_idHTTP 400invalid_request_error

type_id is not an integer

Activity type ids are integers from GET /activity-types.

What to do

Fetch the types once at setup and use the id values from there. Do not hard-code them — they differ between accounts.

invalid_device_uidHTTP 400invalid_request_error

device_uid is unusable

The device uid was empty or longer than 128 characters.

What to do

Use something stable and unique to the hardware — a MAC address or chip id is ideal.

unsafe_webhook_urlHTTP 400invalid_request_error

That webhook URL cannot be used

The URL is not a public HTTPS endpoint. Private, loopback, and link-local addresses are refused so this platform cannot be used to probe a network from the inside.

What to do

Use a public https:// URL. Test locally with a tunnel rather than pointing a webhook at localhost.

no_readingsHTTP 400invalid_request_error

The ping carried no readings

The legacy sensor ping endpoint was called with no recognizable metric parameters.

What to do

Include at least one metric in the query string, or move to POST /sensors/readings.

invalid_claim_codeHTTP 400invalid_request_error

The pairing code is malformed

Pairing codes are six characters.

What to do

Re-read the code from the device and send it exactly.

Idempotency

Retries that would not have been safe.

idempotency_key_too_longHTTP 400invalid_request_error

Idempotency-Key is too long

The key exceeded the maximum length.

What to do

Use something short and unique — a UUID is ideal.

idempotency_key_invalidHTTP 400invalid_request_error

Idempotency-Key has unusable characters

The key contained characters outside printable ASCII.

What to do

Use a UUID or another printable-ASCII token.

idempotency_key_reusedHTTP 409conflict_error

That key was used for a different body

The key has already been seen on a request whose body differed. Replaying it would silently return an answer to a question you did not ask.

What to do

Use a fresh key for a new request. Reuse one only to retry the identical request.

idempotency_key_in_flightHTTP 409conflict_errorretry safe

That request is still running

An earlier request with this key has not finished yet.

What to do

Wait a moment and retry with the same key — you will get the original result.

Device pairing

Codes are short-lived and single-use on purpose.

claim_code_expiredHTTP 409conflict_error

The pairing code has expired

Pairing codes are short-lived by design.

What to do

Restart pairing on the device to mint a new one.

claim_code_usedHTTP 409conflict_error

The pairing code was already used

A code pairs exactly one device, once.

What to do

Restart pairing on the device to get a new code.

claim_code_unavailableHTTP 409conflict_errorretry safe

No pairing code could be allocated

A transient collision while allocating an unused code.

What to do

Retry in a moment.

Plan limits

The API enforces the same allowances the app does.

animal_limit_reachedHTTP 402quota_error

The plan's animal allowance is full

The account is at its animal limit. Every animal that is not archived counts, so a sold animal still holds its slot until it is set to "Sold & Archived". The API enforces the same allowance the app does — it is not a way around it.

What to do

Archive an animal or upgrade the plan.

device_limit_reachedHTTP 402quota_error

The plan's device allowance is full

The account is at its sensor-device limit.

What to do

Remove a device or upgrade the plan.

endpoint_limit_reachedHTTP 402quota_error

Too many webhook endpoints

The account is at the maximum number of webhook endpoints.

What to do

Delete one before adding another. One endpoint can subscribe to many events.

Throttling and availability

Back off and retry — the request is not wrong.

rate_limit_exceededHTTP 429rate_limit_errorretry safe

Too many requests

The account exceeded a per-minute or per-day budget. Sensor ingest draws on its own budget, so a busy fleet cannot starve the rest of an integration.

What to do

Back off for the number of seconds in Retry-After. X-RateLimit-Remaining and X-RateLimit-Reset on every response let you pace ahead of this.

api_disabledHTTP 503api_errorretry safe

The API is not enabled for this account

The key is valid and the request is fine — API access is not switched on for this account yet. GET /status reports limited while the rollout is gated.

What to do

Contact support if you expect access. Treat it as an outage and back off; do not discard the key as rejected.

internal_errorHTTP 500api_errorretry safe

Something broke on our end

An unhandled failure. Nothing about the request needs to be different.

What to do

Retry with backoff. If it persists, quote the request_id to support — it identifies the exact request in our logs.

Rejected readings

Not HTTP errors. POST /sensors/readings answers 202 and reports each reading it could not store, so one bad channel never costs you the batch.

device_unassigned202 · rejected readingreading_rejected

The device is not attached to anything yet

The device exists but none of its channels are attached to a location, so there is nowhere to file the readings. The whole batch is rejected and device_unassigned is true on the response.

What to do

In the app, open Environment → Sensors, find the device, and attach its channels. Readings sent before that are not stored. next_push_after tells the firmware when to try again.

channel_unassigned202 · rejected readingreading_rejected

That channel is not attached

The device has attached channels, but not the one this reading came from.

What to do

Attach the channel in Environment → Sensors, or stop sending that channel.

metric_not_found202 · rejected readingreading_rejected

Unknown metric

The metric is not one this platform records.

What to do

Use an id from GET /environment/metrics.

invalid_value202 · rejected readingreading_rejected

The value is not a usable number

The reading's value was missing, non-numeric, or not finite.

What to do

Send a finite JSON number. A sensor that failed to read should omit the reading rather than send a sentinel like -999.

unknown_unit202 · rejected readingreading_rejected

Unrecognized unit

The unit is not one this platform knows for that metric.

What to do

Use the metric's documented unit, or omit unit to accept the metric's default.

unit_ambiguous202 · rejected readingreading_rejected

The unit is ambiguous

The unit could mean more than one thing for this metric — the classic case being a bare degree symbol that could be C or F.

What to do

State the unit explicitly, e.g. F or C.

value_out_of_range202 · rejected readingreading_rejected

The value is impossible for that metric

The value is outside the physically plausible range — almost always a unit mix-up or a disconnected probe.

What to do

Check the unit, then the wiring. A stored impossible reading corrupts every average computed after it, which is why these are dropped rather than clamped.

timestamp_clamped202 · rejected readingreading_rejected

The timestamp was out of range

The reading's timestamp was too far in the past or the future — usually a device whose clock never got set. The reading is still stored, timestamped on arrival.

What to do

Set the device clock from the time in GET /status, or omit recorded_at and let the server timestamp it.