Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Errors and status codes

Content plugin @orthacms/content-server@orthacms/content-admin

What each status means, the error bodies to parse, and where two causes answer identically on purpose.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Status codes

StatusWhen
200A read, an update, a bulk call, any GraphQL request that ran.
201A non-bulk POST: create, upload, publish, unpublish.
204A delete.
304If-None-Match matched the response’s ETag.
400Bad input: a bad filter, an unknown fields name or query parameter, an unknown locale, a missing X-Workspace-Id on a multi-workspace token.
401No token, or an invalid one.
403Not 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.
404Not found, or not visible to this caller.
409A duplicate (translation group, locale), or a publish refused by publication protection.
413Body over MAX_REQUEST_BODY (1 MB default), or upload over MEDIA_MAX_UPLOAD_BYTES (50 MB default).
422Well-formed but failed validation.
429Rate 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: values before and after, links added and removed, and in unresolved the linked records left out because they have no translation in that row’s language. A linked record is flagged self when it is the record being written, and unsaved when it exists only inside this write; neither carries a status.
  • locales — the record’s own translations among them.
  • siblings — the published rows the write would leave failing validation, each with its locale, type, entryId and issues (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."
}
codeMeaning
protection.token_refusedThe rule does not let a token publish this type.
protection.insufficient_approvalsTokens 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"
}
codeMeaning
copilot.quota_exceededA daily token quota is spent. scope is user or workspace; resetsAt is the next midnight UTC.
copilot.rate_limitedToo many runs started by this user in the last minute.
copilot.too_many_runsThis 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.

AnswerCovers
401 Invalid API token.Unknown, revoked or expired token
404 on an entryMissing, in another workspace, soft-deleted, never published (and no status= widening), or restricted to segments the reader is not in
404 on a typeDoes not exist, or the workspace cannot read it
403 on a workspaceDoes not exist, or is not in the token’s bucket
422 on a relation or media targetDoes 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 values is 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.codestatus
BAD_REQUEST400
UNAUTHENTICATED401
FORBIDDEN403
NOT_FOUND404
CONFLICT409
VALIDATION_FAILED422
INTERNAL_SERVER_ERROR500
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.