Errors
Every failure uses the same envelope, so you can write one handler and trust it everywhere.
The envelope
json
{
"error": {
"code": "validation_failed",
"message": "postcode is not a valid UK postcode",
"details": [
{ "field": "buy_box.postcodes", "issue": "invalid_format" }
]
}
}code is stable and safe to branch on. message is human-readable and may change wording — never parse it. details is present on validation failures. The response also carries X-Request-Id; log it.
Status codes
| Field | Type | Description |
|---|---|---|
| 400 | bad_request | Malformed JSON or an unusable query parameter. |
| 401 | unauthenticated | Missing, expired or revoked session or key. Sign in again or rotate the key. |
| 403 | forbidden / insufficient_scope | Authenticated but the role or key scope does not allow this. Do not retry. |
| 404 | not_found | No such record in this workspace. Also returned for records in other workspaces. |
| 409 | conflict | The record changed under you, or a unique value is taken. Re-read and retry. |
| 422 | validation_failed | The body parsed but a field is unacceptable. See details. |
| 429 | rate_limited | Too many requests. Back off using Retry-After. |
| 5xx | internal_error | Our fault. Retry with backoff and send us the request ID if it persists. |
CSRF failures
A browser request that omits or reuses a stale X-CSRF-Token returns 403 csrf_invalid. Re-fetch /ping to get a fresh token, then repeat the request once.
Handling errors in one place
ts
if (!res.ok) {
const { error } = await res.json();
switch (error.code) {
case "unauthenticated": return signInAgain();
case "csrf_invalid": return refreshCsrfAndRetryOnce();
case "rate_limited": return backoff(res.headers.get("Retry-After"));
case "validation_failed": return showFieldErrors(error.details);
default: throw new ApiError(error, res.headers.get("X-Request-Id"));
}
}