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
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 key | Effect |
|---|---|
values | Field values. Required (may be {}). |
relations | Link changes per field. See relation deltas. |
locale | The row’s language. Absent = default locale; unknown = 400. Create only. |
localeGroupId | Join 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
requiredand field rules until publish. Non-publishable types validate on every save. status,publishedAt,localeandlocaleGroupIdcannot be set throughvalues.- 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.
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.
nullclears. localeon an update is ignored — a row never changes language.- Editing a live entry moves its working copy to
draftand keepspublishedAt— 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…" }
}
}| Key | Effect |
|---|---|
link | Assign these ids. |
unlink | Unassign these ids. |
order | Reorder. Owning side only. |
set | The target of an owning single relation; null clears it. |
by | id (default) or localeGroup. |
link,unlink,order: many and inverse relations. Ids you do not mention are untouched.- An owning single relation: set it in
valuesby id, or withsethere. Both at once, orsetmixed 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
# 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
paranoidtype 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_refusedunless 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 onlylocaleGroupId: ambiguous, soop(create/update) is required. - Each item behaves exactly like the single
POST/PATCH. - Needs
content:createandcontent:update— afulltoken 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:
| Route | Returns |
|---|---|
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:
| Route | Effect |
|---|---|
GET /api/v1/content/:type/:id/access | { entryId, restricted, allow, deny } — segment ids. Any token. |
PUT /api/v1/content/:type/:id/access | Replace 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. Emptyallow= everyone. A deny always wins.PUTreplaces 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.
What to read next
- The media API — upload an asset for a media field.
- Preview and drafts — render the draft you just wrote.
- Errors and status codes — the 422 and 409 bodies.