Errors
Error format
Every error from the REST API (v1) is an RFC 9457 problem details document with the content type application/problem+json:
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "This API key lacks the \"conversations:write\" scope",
"code": "missing_scope",
"required_scope": "conversations:write"
}
| Field | Always present | Meaning |
|---|---|---|
type | yes | Currently always about:blank. |
title | yes | The HTTP status in words, for example Forbidden. |
status | yes | The HTTP status code, repeated in the body. |
detail | yes | A human-readable explanation. |
code | no | A stable machine-readable code — branch on this, not on detail. |
required_scope | no | On missing_scope errors: the scope the key needs. |
retry_after | no | On 429: seconds to wait before retrying. |
errors | no | On validation failures: a list of { "path", "message" } items. |
Validation example:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed",
"code": "validation_failed",
"errors": [
{ "path": "text", "message": "Too small: expected string to have >=1 characters" }
]
}
Other Mulabbe endpoints outside /api/v1 (for example the dashboard's own API) use a simpler JSON shape for errors:
{
"statusCode": 403,
"error": "FORBIDDEN",
"message": "Plan limit reached for products (1000/1000). Upgrade the plan to continue.",
"details": { "code": "PLAN_LIMIT", "resource": "products", "limit": 1000, "used": 1000 }
}
The machine-readable code is the same in both shapes; in the second one it lives under details.code.
Common status codes and error codes
| HTTP | code | Meaning and what to do |
|---|---|---|
| 400 | validation_failed | The body or query string failed validation. Fix the fields listed in errors. Unknown fields are also rejected. |
| 400 | invalid_idempotency_key | The Idempotency-Key header is not 1–255 visible ASCII characters. |
| 401 | — | The API key is missing, malformed, unknown, revoked or expired. Check the Authorization: Bearer wk_... header. |
| 402 | CREDITS_EXHAUSTED | The workspace has no AI credits left for work that would be charged. Top up credits (or wait for the next period), then retry. |
| 403 | missing_scope | The key lacks the route's scope; required_scope names it. Create or update a key with that scope. |
| 403 | ip_not_allowed | The key has an IP allowlist and this request came from outside it. |
| 403 | test_key_live_channel | A wk_test_... key tried to write to a conversation on a live channel. Use a live key, or a sandbox channel. |
| 403 | PLAN_LIMIT | The workspace's plan limit for a resource is reached (the message names the resource, the limit and current use). Upgrade the plan or free capacity. |
| 404 | — | The resource does not exist in this workspace. Ids from another workspace also return 404. |
| 422 | idempotency_key_reused | The same Idempotency-Key was sent with a different request. Use a fresh key per operation. |
| 422 | (operation-specific) | The request is valid but cannot be applied — for example sending into a conversation whose messaging window is closed. The code names the exact reason. |
| 429 | rate_limited | The key's token bucket is empty. Wait retry_after seconds (also in the Retry-After header), then retry. |
| 500 | — | Something went wrong on Mulabbe's side. Safe to retry with backoff; the body never contains internals. |
Handling errors in code
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (!res.ok) {
const problem = await res.json();
switch (problem.code) {
case 'rate_limited':
await sleep(problem.retry_after * 1000);
return retry();
case 'missing_scope':
throw new Error(`Key needs the ${problem.required_scope} scope`);
case 'CREDITS_EXHAUSTED':
notifyBilling();
break;
default:
throw new Error(`${problem.status} ${problem.title}: ${problem.detail}`);
}
}
Two rules of thumb:
- Branch on
codeandstatus, never on the text ofdetail— wording can change, codes do not. - Retry only what is safe to retry:
429(afterretry_after) and5xx(with exponential backoff). A4xxother than429will fail again until you change the request.