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

Writing entries

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

Create, update, publish and delete — including relation deltas and bulk saves that report per item.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Writes need a full token. With a read token every route here is a 403. Send Content-Type: application/json.

Creating

POST /api/v1/content/:typeName

Example language
curl -X POST 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{
  "values": { "title": "Hello", "slug": "hello", "author": "a71c…" },
  "locale": "en",
  "relations": { "tags": { "link": ["1f9a…"] } }
}'
const response = await fetch('https://cms.example.com/api/v1/content/article', {
  method: 'POST',
  headers: {
      Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
      'Content-Type': 'application/json'
  },
  body: JSON.stringify({
      values: { title: 'Hello', slug: 'hello', author: authorId },
      locale: 'en',
      relations: { tags: { link: [tagId] } }
  })
});
const draft = await response.json(); // 201, status: 'draft'
mutation Create {
  createArticle(
      input: { title: "Hello", slug: "hello", author: "a71c…" }
      relations: { tags: { link: ["1f9a…"] } }
      locale: "en"
  ) {
      id
      status
      localeGroupId
  }
}

201:

{
    "id": "9c4b…",
    "createdAt": "2026-09-30T10:00:00.000Z",
    "updatedAt": "2026-09-30T10:00:00.000Z",
    "values": { "title": "Hello", "slug": "hello", "excerpt": null },
    "status": "draft",
    "publishedAt": null,
    "locale": "en",
    "localeGroupId": "b2d1…"
}
Body keyEffect
valuesField values. Required (may be {}).
relationsLink changes per field. See relation deltas.
localeThe row’s language. Absent = default locale; unknown = 400. Create only.
localeGroupIdJoin an existing translation group — i.e. add a translation. Create only.
  • A create on a publishable type is always a draft. Publishing is its own call.
  • Drafts skip required and field rules until publish. Non-publishable types validate on every save.
  • status, publishedAt, locale and localeGroupId cannot be set through values.
  • A relation or media target outside the workspace is a 422.

Adding a translation

curl -X POST 'https://cms.example.com/api/v1/content/article' \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'Content-Type: application/json' \
  -d '{"values":{"title":"Hallo","slug":"hallo"},"locale":"de","localeGroupId":"b2d1…"}'
  • An unknown group is a 404.
  • A second row for the same (group, locale) is a 409.

Updating

PATCH /api/v1/content/:typeName/:id — or PATCH /api/v1/content/:typeName/group/:groupId?locale=de.

Example language
curl -X PATCH 'https://cms.example.com/api/v1/content/article/9c4b…' \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{"values":{"title":"Hello again","excerpt":null}}'
await fetch('https://cms.example.com/api/v1/content/article/' + id, {
  method: 'PATCH',
  headers: {
      Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
      'Content-Type': 'application/json'
  },
  // title changes, excerpt is cleared, everything else is untouched
  body: JSON.stringify({ values: { title: 'Hello again', excerpt: null } })
});
mutation {
  updateArticle(id: "9c4b…", input: { title: "Hello again", excerpt: null }) {
      id
      status
      publishedAt
  }
}
  • Merge semantics. Omitted fields are left alone. null clears.
  • locale on an update is ignored — a row never changes language.
  • Editing a live entry moves its working copy to draft and keeps publishedAt — the admin’s Modified state. Since 0.7.0, published reads keep serving the last published version until you publish again. Publish to ship the edit.

An unknown field name is dropped in silence

A key in values that is not a declared field is ignored: not stored, not rejected, 200. Read the response back when writing by hand.

Relation deltas

Relations change by delta, never by replacement.

{
    "values": {},
    "relations": {
        "tags": { "link": ["9c4b…"], "unlink": ["1f9a…"], "order": ["9c4b…", "7d2e…"] },
        "category": { "set": "c3d4…" }
    }
}
KeyEffect
linkAssign these ids.
unlinkUnassign these ids.
orderReorder. Owning side only.
setThe target of an owning single relation; null clears it.
byid (default) or localeGroup.
  • link, unlink, order: many and inverse relations. Ids you do not mention are untouched.
  • An owning single relation: set it in values by id, or with set here. Both at once, or set mixed with link arrays, is a 400.
  • Max 100 relation fields per save, 1000 ids per array.
  • Every id must be in the same workspace, or 422.

Linking across locales

When both sides are localized, links stay within one locale — linking another locale’s row is a 422. Pass translation-group ids instead and let the server pick the right row:

{ "values": {}, "relations": { "tags": { "link": ["b2d1…"], "by": "localeGroup" } } }

A group with no row in this entry’s locale is a 422 (“translate it first”). In GraphQL: by: LOCALE_GROUP.

Publishing and deleting

Example language
# publish (201, the entry)
curl -X POST 'https://cms.example.com/api/v1/content/article/9c4b…/publish' \
-H 'Authorization: Bearer orthacms_…'

# unpublish (201)
curl -X POST 'https://cms.example.com/api/v1/content/article/9c4b…/unpublish' \
-H 'Authorization: Bearer orthacms_…'

# delete (204)
curl -X DELETE 'https://cms.example.com/api/v1/content/article/9c4b…' \
-H 'Authorization: Bearer orthacms_…'
const base = 'https://cms.example.com/api/v1/content/article/';
const auth = { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN };

const res = await fetch(base + id + '/publish', { method: 'POST', headers: auth });
if (res.status === 422) console.error((await res.json()).issues);
if (res.status === 409) console.error((await res.json()).code); // protection

await fetch(base + id, { method: 'DELETE', headers: auth }); // 204
mutation {
  publishArticle(id: "9c4b…") { id status publishedAt }
}

# also: unpublishArticle(id:), deleteArticle(id:) → true
# localized: publishArticle(localeGroupId: "b2d1…", locale: "de")
  • Each has a group twin: POST …/group/:groupId/publish?locale=de, and so on.
  • On a localized type a delete removes one row; other languages stay live.
  • A paranoid type soft-deletes.
  • Publish on a non-publishable type is a 400.
  • Publish re-validates: a failure is a 422 naming the fields. See the publish gate.
  • A type under publication protection refuses a token with 409 protection.token_refused unless its rule allows token publishing; then it still needs approvals (protection.insufficient_approvals). A token never bypasses. See errors.

Bulk

POST /api/v1/content/:typeName/bulk — up to 50 entries of one type.

curl -X POST 'https://cms.example.com/api/v1/content/article/bulk' \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'Content-Type: application/json' \
  -d '{"items":[
    {"values":{"title":"New one"}},
    {"id":"9c4b…","values":{"title":"Edited"}},
    {"op":"create","localeGroupId":"b2d1…","locale":"fr","values":{"title":"Bonjour"}}
  ]}'
{
    "items": [
        { "index": 0, "op": "create", "ok": true, "entry": { "id": "…", "status": "draft" } },
        { "index": 1, "op": "update", "ok": true, "entry": { "id": "9c4b…" } },
        {
            "index": 2,
            "op": "create",
            "ok": false,
            "error": { "status": 409, "message": "…" }
        }
    ],
    "created": 1,
    "updated": 1,
    "failed": 1
}

(Entries trimmed.)

  • With id: update. Without: create. With only localeGroupId: ambiguous, so op (create / update) is required.
  • Each item behaves exactly like the single POST/PATCH.
  • Needs content:create and content:update — a full token has both.

A bulk save is always 200

Each item is its own transaction; one bad row does not reject the rest. Check ok per item — a 422 keeps its issues. Retry what you can fix.

The other bulk routes take { "ids": [ … ] } (entry ids, max 100) and answer 200:

RouteReturns
POST …/bulk/publish{ "published": [ids], "skipped": [{ "id", "reason" }] } — reason already-published, blocked, not-found or guard-refused
POST …/bulk/unpublish{ "count": n } — rows that changed
POST …/bulk/delete{ "count": n }
curl -X POST 'https://cms.example.com/api/v1/content/article/bulk/publish' \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'Content-Type: application/json' \
  -d '{"ids":["9c4b…","5e0a…"]}'

Setting who may read an entry

With the segments plugin:

RouteEffect
GET /api/v1/content/:type/:id/access{ entryId, restricted, allow, deny } — segment ids. Any token.
PUT /api/v1/content/:type/:id/accessReplace both lists. full token.
curl -X PUT 'https://cms.example.com/api/v1/content/article/9c4b…/access' \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'Content-Type: application/json' \
  -d '{"allow":["<premium segment id>"],"deny":[]}'
{ "entryId": "9c4b…", "restricted": true, "allow": ["<premium segment id>"], "deny": [] }
  • allow = Can see, deny = Cannot see. Empty allow = everyone. A deny always wins.
  • PUT replaces both lists, and writes every language of the record.
  • Readers are then matched with X-Reader-Tags.

Shared types are read-only

A type the workspace reads only from a shared workspace cannot be written:

{
    "message": "This workspace can only use \"Articles\" records from shared workspaces; it cannot create its own.",
    "error": "Forbidden",
    "statusCode": 403
}

Reading your writes back

A read token cannot see the draft you just created. A full token can, with ?status=draft or ?status=any. See API tokens.