Errors
The problem+json body, the twelve codes, and why two of them tell you little.
Every refusal is application/problem+json (RFC 9457) with the same fields:
{
"type": "https://fabric.inc/docs/api/errors#insufficient_scope",
"title": "Credential lacks the required scope",
"status": 403,
"code": "insufficient_scope",
"detail": "This key does not carry `catalog:write`.",
"instance": "/api/v1/brands/brd_1/products/prd_9"
}Branch on code. It is a closed set, stable across releases, and it is the only field you
should compare. title and detail are written for a person reading a log — detail describes
this one occurrence and its wording will change.
A validation failure adds errors, one entry per field, so you can mark the input that was wrong
rather than printing a sentence:
{
"code": "invalid_request",
"status": 400,
"errors": [{ "path": "limit", "message": "Expected number, received string" }]
}Codes
| Code | Status |
|---|---|
invalid_request | 400 |
refused | 400 |
unauthorized | 401 |
forbidden | 403 |
insufficient_scope | 403 |
not_found | 404 |
conflict | 409 |
unprocessable | 422 |
too_large | 413 |
rate_limited | 429 |
server_error | 500 |
service_unavailable | 503 |
invalid_request
400. The body or query could not be understood. Read errors — it names each field and what
was wrong with it. Fix the request; retrying it unchanged returns the same thing.
refused
400. The request was understood and declined on its merits. detail names the reason, and it
is a reason about your data rather than your syntax — a mapping that is not active, a run that has
nothing to do. Retrying unchanged will not help.
unauthorized
401. No usable credential. See below — every cause returns this same body on purpose.
forbidden
403. The credential is valid and carries the scope, but you may not do this. A scope never
substitutes for a role. Three things answer it: changing organization configuration as a member,
writing a global config scope (a platform setting, refused to owners too), and an operation your
organization is not entitled to, such as Catalog Builder.
Publishing's role checks are the exception. They run inside the operation rather than at the
door, so approving a publish, cancelling one, and activating or rolling back a mapping version all
answer refused instead. detail names the role either way.
insufficient_scope
403. The credential is valid but does not carry the scope this endpoint requires. detail
names the missing scope. Scopes are fixed when the key is created, so this is a new key rather
than a retry.
not_found
404. The resource does not exist, or it belongs to another organization. Those two are deliberately indistinguishable — see below.
conflict
409. The request contradicts the current state: reusing an Idempotency-Key with a different
body, or starting a run on a brand that already has one going. detail says which.
unprocessable
422. Well-formed and permitted, but it cannot be applied to this resource in its current state — approving a value that no AI wrote, for instance. The request was right; the target was not.
too_large
413. The answer would be too large to return synchronously. A publishing export over the row
cap for your organization or brand — 5,000 products by default — is the usual cause. detail
carries both the count and the cap. Start a run instead: it streams, resumes and leaves a record.
rate_limited
429. You are over one of the four budgets. Retry-After says how many seconds to wait, and
RateLimit-Reset when the window turns over. See Rate limits.
server_error
500. Something broke on our side. Retry with backoff. The body carries no detail on purpose — see below.
service_unavailable
503. We are not configured to serve this yet — a missing credential key on our side, not
yours. detail names the configuration, because it describes our system rather than your data.
Tell us; retrying will not fix it.
Every auth failure looks the same
A missing key, a malformed key, a key that never existed, a revoked key, an expired key, and a key whose organization was archived all return the same 401 with the same message.
That is deliberate. Distinguishing them would tell a caller which of their guesses was once a real key, which is exactly what someone enumerating keys wants to learn.
Debugging your own integration, the useful checks are: is the header spelled right, is the key the
full string including the fab_sk_ prefix, and has it been revoked.
A resource that is not yours is not found
Sending a brandId from another organization returns 404, never 403.
Same reasoning: "that brand exists but is not yours" confirms the id. The API will not tell you whether an id you do not own is real. This applies to every scoped resource — products, views, connections, mappings, runs and webhook endpoints all answer the same way.
500s carry no detail
A server error returns the bare server_error body, because an internal message can name a brand,
a destination or a credential and the caller who triggered it should learn none of them. The detail
is in our logs, correlated by request.