Errors
Every failure — including a 405 from an unsupported method and a 500 from a bug — comes back as JSON in the same envelope. You never have to parse an HTML error page or branch on content-type.
The envelope
json
{
"error": {
"code": "INVALID_INPUT",
"message": "Send at most 500 rows per request",
"path": ["rows"]
}
}| Field | Always present | Meaning |
|---|---|---|
code | Yes | A stable, machine-readable identifier. Branch on this. |
message | Yes | Human-readable, and may change without notice. Do not parse it. |
path | No | Present on validation failures; points at the offending field. |
path is an array of strings and numbers forming a path into your request body, with numbers being array indices. ["rows", 12, "phone"] means the phone field of the thirteenth row. Use it to point a human at the row they got wrong instead of making them bisect a 500-row payload.
The envelope is built by json_err in src/lib/utils/api/response.util.ts.
Codes
| Code | Status | When |
|---|---|---|
INVALID_INPUT | 400 | The body or query failed validation, or an idempotency-key was reused for a different body. Usually carries path. |
UNAUTHORIZED | 401 | No key, an unparseable key, or a key we do not recognise. |
PAYMENT_REQUIRED | 402 | Your organization's prepaid credit cannot cover the request — a QA review. Top up, then retry. |
FORBIDDEN | 403 | The key is valid but lacks a scope, or the org is the wrong kind. |
NOT_FOUND | 404 | No such route, or no such record in your organization. |
METHOD_NOT_ALLOWED | 405 | Real route, wrong verb. Carries an allow header. |
TIMEOUT | 408 | The request took too long to process. |
DUPLICATE | 409 | The record already exists. |
CONFLICT | 409 | The record is still in use, or a request with the same idempotency-key is still running. |
TOO_LARGE | 413 | The body exceeded the endpoint's limit. |
UNSUPPORTED_MEDIA_TYPE | 415 | Missing or wrong content-type. |
TOO_MANY_REQUESTS | 429 | A rate limit was hit. See Rate limits. |
INTERNAL_SERVER_ERROR | 500 | Our fault. Quote your x-request-id. |
Defined in src/lib/const/error.const.ts. Two codes share 409, so branch on code, not on status — DUPLICATE and CONFLICT are different situations with different fixes.
This list can grow within v1. Treat an unrecognised code as a failure of its status class rather than crashing on it.
The two you will hit first
Getting the content type wrong:
json
{
"error": {
"code": "UNSUPPORTED_MEDIA_TYPE",
"message": "Send a JSON body with `content-type: application/json`."
}
}A charset parameter is accepted — application/json; charset=utf-8 is fine. A missing header is not.
Sending something that is not JSON:
json
{
"error": {
"code": "INVALID_INPUT",
"message": "Request body is not valid JSON."
}
}TOO_LARGE is checked before authentication, from the content-length header, so an oversized body is refused without your key being verified or any rate-limit token being spent. A body sent without content-length (chunked) is measured as it is read instead, after your key is verified: that 413 spends the request's tokens, as any other refusal after the limiter does.
What to retry
| Code | Retry? |
|---|---|
TOO_MANY_REQUESTS | Yes — wait retry-after seconds, add jitter. |
CONFLICT on an idempotency-key | Yes — shortly, with the same key. The first request is still running. |
INTERNAL_SERVER_ERROR | Yes — exponential backoff, a few attempts, then alert. |
TIMEOUT | Yes — with backoff. |
| Everything else | No. Retrying will produce the same answer; fix the request. |
Before retrying anything that writes, make sure the retry carries the same idempotency-key as the first attempt, so it cannot apply twice — see Idempotency.
Getting help
Every response carries x-request-id, echoed from your request if you sent a usable one (see the overview), minted otherwise. Capture it and quote it. With a request id we can find the exact request; without one, a timestamp and a rough description is all anybody has to work with.