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"
}
FieldAlways presentMeaning
typeyesCurrently always about:blank.
titleyesThe HTTP status in words, for example Forbidden.
statusyesThe HTTP status code, repeated in the body.
detailyesA human-readable explanation.
codenoA stable machine-readable code — branch on this, not on detail.
required_scopenoOn missing_scope errors: the scope the key needs.
retry_afternoOn 429: seconds to wait before retrying.
errorsnoOn 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

HTTPcodeMeaning and what to do
400validation_failedThe body or query string failed validation. Fix the fields listed in errors. Unknown fields are also rejected.
400invalid_idempotency_keyThe 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.
402CREDITS_EXHAUSTEDThe workspace has no AI credits left for work that would be charged. Top up credits (or wait for the next period), then retry.
403missing_scopeThe key lacks the route's scope; required_scope names it. Create or update a key with that scope.
403ip_not_allowedThe key has an IP allowlist and this request came from outside it.
403test_key_live_channelA wk_test_... key tried to write to a conversation on a live channel. Use a live key, or a sandbox channel.
403PLAN_LIMITThe 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.
422idempotency_key_reusedThe 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.
429rate_limitedThe 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 code and status, never on the text of detail — wording can change, codes do not.
  • Retry only what is safe to retry: 429 (after retry_after) and 5xx (with exponential backoff). A 4xx other than 429 will fail again until you change the request.