Status codes
| Status | When |
|---|---|
200 | A read, an update, a bulk call, any GraphQL request that ran. |
201 | A non-bulk POST: create, upload, publish, unpublish. |
204 | A delete. |
304 | If-None-Match matched the response’s ETag. |
400 | Bad input: a bad filter, an unknown fields name or query parameter, an unknown locale, a missing X-Workspace-Id on a multi-workspace token. |
401 | No token, or an invalid one. |
403 | Not permitted: a read token writing or asking for drafts, a workspace outside the bucket, a write to a type reachable only through a shared workspace. |
404 | Not found, or not visible to this caller. |
409 | A duplicate (translation group, locale), or a publish refused by publication protection. |
413 | Body over MAX_REQUEST_BODY (1 MB default), or upload over MEDIA_MAX_UPLOAD_BYTES (50 MB default). |
422 | Well-formed but failed validation. |
429 | Rate limited — the public auth routes, and a copilot run refused by a usage limit. Never the content API. |
Error bodies
Most errors use the framework’s shape:
{ "message": "Invalid API token.", "error": "Unauthorized", "statusCode": 401 }A 400 from request validation (an undeclared query parameter or body key, a wrong type) lists one string per problem:
{
"statusCode": 400,
"message": ["property localeFallback should not exist"],
"error": "Bad Request"
}Validation failures (422)
{
"message": "Entry validation failed",
"issues": [
{ "field": "title", "message": "must be at most 200 characters" },
{ "field": "author", "message": "must reference an existing entry" }
]
}Same shape from a save, a publish that fails the gate, and a failed bulk item
(inside that item’s error, with "status": 422).
A save of a translated record can be refused for another language: changing
a shared field or a synced relation rewrites every sibling, and the save is
refused when that would leave a published one failing validation. Its issues
are not about the row you sent, so it says so with a code and names every
sibling it would invalidate:
{
"message": "Shared fields would invalidate the published \"de\" translation",
"code": "content.sibling_invalidated",
"siblings": [
{ "locale": "de", "issues": [{ "field": "author", "message": "is required" }] }
],
"issues": [{ "field": "author", "message": "is required" }]
}issues repeats the siblings’ issues flattened, for a client that reads only
that — check code before showing them against your own fields.
Fill the gap in those languages first. The admin asks a person instead and can save anyway; the API always refuses.
A client that wants to ask before a write reaches any other row can send
"askBeforeSiblingSync": true with it. Since 0.11.0 the admin routes read it on
a create (POST /api/content/:type), an update
(PATCH /api/content/:type/:id), a version restore and a publish-a-version
(POST /api/content/:type/:id/revisions/:number/restore, /publish, in an
optional body). The write is run, described and rolled back — nothing is
written — whenever it would change another live row: a translation of the same
record (a shared field, a synced link), another record’s translations (a link
edited from its far side), or a record whose synced link was waiting for the
translation being created.
{
"message": "This save would also change 1 other row (\"de\")",
"code": "content.sibling_sync_unconfirmed",
"locales": ["de"],
"siblings": [],
"changes": [
{
"type": "product", "typeLabel": "Product", "entryId": "…",
"locale": "de", "title": "Wanderschuh",
"sibling": true, "published": true, "optional": false,
"fields": [{ "field": "price", "label": "Price", "before": 35, "after": 40 }],
"links": [{
"field": "tags", "label": "Tags", "target": "tag",
"added": [{ "id": "…", "title": "Neu", "status": "published" }],
"removed": [],
"unresolved": [{ "id": "…", "title": "Sale", "status": "published" }]
}]
}
]
}changes— every other row and what changes in it: valuesbeforeandafter, linksaddedandremoved, and inunresolvedthe linked records left out because they have no translation in that row’s language. A linked record is flaggedselfwhen it is the record being written, andunsavedwhen it exists only inside this write; neither carries astatus.locales— the record’s own translations among them.siblings— the published rows the write would leave failing validation, each with itslocale,type,entryIdandissues(often none).
Resend without the flag once a person agrees — plus allowInvalidSiblings when
siblings is not empty. On a create, skipReferrers (an array of entryIds
from the optional changes) leaves those records out: their link stays empty
until their own next save. Only such a filled-in gap is optional; every other
change keeps one record’s languages consistent and cannot be declined row by
row. A write that reaches no other row is never refused for it.
Publish refusals
A publish refused by a guard carries a stable code:
{
"statusCode": 409,
"code": "protection.token_refused",
"message": "Publishing \"article\" requires review, and an API token cannot be reviewed. Publish as a person, or allow token publishing on the rule."
}code | Meaning |
|---|---|
protection.token_refused | The rule does not let a token publish this type. |
protection.insufficient_approvals | Tokens may publish, but this version lacks approvals. Adds required, given, stale, bypassable. |
In a bulk publish, the same refusal is a skipped item with reason
guard-refused.
Copilot usage limits
When an operator sets usage limits,
POST /api/copilot/runs can be refused before its stream opens — a real 429,
not a frame inside a 200, and nothing is stored:
{
"statusCode": 429,
"code": "copilot.quota_exceeded",
"scope": "user",
"message": "Your daily copilot allowance in this workspace is used up.",
"resetsAt": "2026-10-22T00:00:00.000Z"
}code | Meaning |
|---|---|
copilot.quota_exceeded | A daily token quota is spent. scope is user or workspace; resetsAt is the next midnight UTC. |
copilot.rate_limited | Too many runs started by this user in the last minute. |
copilot.too_many_runs | This user already has as many runs in flight as allowed. |
Retry-After is set (in seconds) when the wait is knowable — a quota or the
per-minute limit. A concurrency refusal sends none: it lifts when one of the
user’s runs ends. Branch on code; message is English for whoever reads a raw
response.
Handling errors in code
async function orthacms(path, init = {}) {
const response = await fetch(process.env.ORTHACMS_URL + '/api/v1' + path, {
...init,
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'Content-Type': 'application/json',
...init.headers
}
});
if (response.status === 204) return null;
const body = await response.json();
if (response.ok) return body;
const error = new Error(
Array.isArray(body.message) ? body.message.join('; ') : body.message
);
error.status = response.status;
error.code = body.code; // e.g. 'protection.token_refused'
error.issues = body.issues; // on a 422
throw error;
}Where two causes answer identically
Deliberate, so the API never confirms that an id you cannot see exists.
| Answer | Covers |
|---|---|
401 Invalid API token. | Unknown, revoked or expired token |
404 on an entry | Missing, in another workspace, soft-deleted, never published (and no status= widening), or restricted to segments the reader is not in |
404 on a type | Does not exist, or the workspace cannot read it |
403 on a workspace | Does not exist, or is not in the token’s bucket |
422 on a relation or media target | Does not exist, or is in another workspace |
An entry edited since it was published is not in this list. Since 0.7.0 it keeps serving its published version.
Silent successes
- An unknown field in
valuesis dropped. Not stored, not rejected, 200. Read back what you wrote. - A bulk save is always 200. Check each item’s
ok; the HTTP status tells you nothing. See bulk.
GraphQL errors
Once an operation runs, the answer is HTTP 200 with an errors array. The
REST status is in extensions.status:
{
"data": null,
"errors": [
{
"message": "Entry validation failed",
"path": ["createArticle"],
"extensions": {
"code": "VALIDATION_FAILED",
"status": 422,
"issues": [{ "field": "title", "message": "must be at least 3 characters" }]
}
}
]
}extensions.code | status |
|---|---|
BAD_REQUEST | 400 |
UNAUTHENTICATED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
CONFLICT | 409 |
VALIDATION_FAILED | 422 |
INTERNAL_SERVER_ERROR | 500 |
GRAPHQL_LIMIT_EXCEEDED | — over a cost limit, refused before running |
GRAPHQL_PARSE_FAILED | — the document did not parse |
const { data, errors } = await response.json();
if (errors?.length) {
const notFound = errors.find((e) => e.extensions?.status === 404);
// branch on extensions.status, not response.status
}- Before the operation runs, HTTP statuses still apply: a bad token is a 401, a workspace problem a 400/403, a malformed body a 400.
- Naming a type the workspace cannot read is a schema validation error, not a 404 — the field does not exist in your schema.
- A single-entry query for a missing entry returns
null, not an error.
Rate limiting
The public auth routes — sign-in, invite and reset redemption, SSO and, since
0.13.0, password recovery — are rate limited per client, and so — when an
operator turns it on — is
starting a copilot run (COPILOT_RUNS_PER_MINUTE, per user). Content reads and
writes (REST, GraphQL, MCP) are bounded by page size and GraphQL cost limits,
not a request counter.
Need one? Put it in your reverse proxy, and set
TRUST_PROXY so client IPs are right.