Errors
The stable response envelope and the full list of error codes.
Every error arrives in one and the same envelope, regardless of endpoint or cause:
{
"error": {
"code": "validation_error",
"message": "capacity must be a number",
"requestId": "0f2b8c1e-6a4d-4a4b-9a30-1c9a2e5f8b77"
}
}
code— a stable machine-readable code. It does not change, and this is what your logic should branch on.message— a human-readable explanation. The wording can change at any time — do not parse it.requestId— a unique identifier for the request. Log it: support can find that exact call in the server logs by this value.
The HTTP status always agrees with the code, so you can check either — but code is more precise:
two different codes can share one status.
Code list
| Code | HTTP | When it happens | What to do |
|---|---|---|---|
unauthorized | 401 | no Authorization header, invalid or revoked key, or the company's plan no longer includes API access | check the key and the plan; retrying unchanged will not help |
forbidden | 403 | the key lacks the required scope; the request comes from an IP outside the allowlist; no rights over that listing | issue a key with the right scopes, or fix the IP allowlist |
not_found | 404 | the object does not exist or belongs to another company | check the id; the API deliberately does not distinguish the two |
conflict | 409 | another request with the same Idempotency-Key is still running | retry in a second or two |
validation_error | 400, 422 | body or parameters failed validation; an unexpected field was sent (e.g. companyId); the same Idempotency-Key was reused with a different body (422); a listing was bumped too soon or its bump limit is exhausted | fix the request; retrying unchanged gives the same result |
rate_limited | 429 | the per-minute request limit was exceeded | wait as instructed by Retry-After |
quota_exceeded | 429 | the daily write quota is exhausted | continue the next day; Retry-After says how long is left |
internal | 5xx | a failure on our side | retry with exponential backoff; if it persists, send the requestId to support |
Why there are only this many
Internal failure reasons are deliberately not promoted to their own codes. Bumping a listing, for
instance, can fail because it is "too soon" or because the bump limit is used up — both arrive as
validation_error, with the specific reason in message. That keeps the code set small and
stable: an integration written today will not break when a new server-side check appears.
The practical rule follows: branch on code, show message to the user, send requestId to
support.
Examples
Missing authorization header:
{ "error": { "code": "unauthorized", "message": "Missing API key", "requestId": "…" } }
Key without the required scope:
{ "error": { "code": "forbidden", "message": "Insufficient API key scope", "requestId": "…" } }
An unexpected field in the body (here companyId, which always comes from the key):
{ "error": { "code": "validation_error", "message": "property companyId should not exist", "requestId": "…" } }
Daily quota exhausted:
{ "error": { "code": "quota_exceeded", "message": "Daily write quota exceeded", "requestId": "…" } }
Client-side handling
async function call(path, init) {
const res = await fetch(`${API_BASE}/api/public/v1${path}`, init);
if (res.ok) return res.json();
const { error } = await res.json();
switch (error.code) {
case "rate_limited":
case "quota_exceeded":
throw new RetryableError(error, Number(res.headers.get("Retry-After") ?? 60));
case "internal":
throw new RetryableError(error, 5);
case "unauthorized":
case "forbidden":
throw new ConfigurationError(error); // retrying will not help — someone must intervene
default:
throw new RequestError(error); // fix the request
}
}