Every request below needs Authorization, plus X-Workspace-Id on a
multi-workspace token. The full header list is in the
overview.
curl 'https://cms.example.com/api/v1/content/article?pageSize=10&sort=-publishedAt' \
-H 'Authorization: Bearer orthacms_…' const response = await fetch(
'https://cms.example.com/api/v1/content/article?pageSize=10&sort=-publishedAt',
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const { items, total } = await response.json(); query Latest {
articles(pageSize: 10, sort: "-publishedAt") {
total
items {
id
title
slug
publishedAt
}
}
} Listing
GET /api/v1/content/:typeName
| Parameter | Default | Effect |
|---|---|---|
page | 1 | 1-based page number. |
pageSize | 25 | Rows per page, max 100. |
sort | -updatedAt | A whitelisted column, - for descending. |
search | — | Case-insensitive substring match on text-like fields. |
filter | — | A JSON filter tree. See filtering. |
fields | all | Comma-separated names to return in values. |
locale | default locale | Which language to read, on a localized type. |
status | published | draft and any need a full token. See preview. |
relations, media, translations | — | preview turns an expansion on. See expanding. |
An unknown query parameter is a 400, not ignored.
{ "items": [ … ], "total": 128, "page": 1, "pageSize": 25 }Paging through everything:
async function readAll(type) {
const items = [];
for (let page = 1; ; page += 1) {
const response = await fetch(
`${process.env.ORTHACMS_URL}/api/v1/content/${type}?pageSize=100&page=${page}`,
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const body = await response.json();
items.push(...body.items);
if (body.page * body.pageSize >= body.total) return items;
}
}Sorting
?sort=title ascending
?sort=-publishedAt descending- Sortable:
createdAt,updatedAt,publishedAt,locale, and the type’s own scalar fields. - An unknown key falls back to
-updatedAt(no error). idis always the final tiebreaker, so paging is stable.
Search
curl -G 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
--data-urlencode 'search=100%'- Matches text, richtext and select fields, case-insensitively.
%and_are matched literally:100%finds100%.
Search is ILIKE, not a search engine
No index, no ranking, no stemming, no typo tolerance. On a large table it is a sequential scan. For real search, feed an engine from the API.
Sparse fieldsets
?fields=title,slug,publishedAt- Narrows
valuesand the SQL projection — an unselected rich-text column is never read. - The envelope is always returned.
- An unknown name is a 400. So is a relation or media field — expand those instead.
In GraphQL, the selection set does the same thing.
Reading one entry
GET /api/v1/content/:typeName/:id — takes the same fields, locale,
status and expansion parameters as the list.
curl 'https://cms.example.com/api/v1/content/article/9c4b1e77-…?fields=title,body' \
-H 'Authorization: Bearer orthacms_…' const response = await fetch(
'https://cms.example.com/api/v1/content/article/' + id + '?fields=title,body',
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
if (response.status === 404) return null;
const entry = await response.json(); query Article($id: ID!) {
article(id: $id) {
id
title
body
}
}
# A missing entry is data.article === null, not an error. A 404 covers all of these alike: missing, in another workspace, soft-deleted, never published, or restricted to segments the reader is not in.
{
"message": "No published \"article\" entry with id \"9c4b1e77-…\".",
"error": "Not Found",
"statusCode": 404
}Since 0.7.0: an edited live entry keeps serving
Saving an edit to a published entry does not make it 404. Reads return the
last published version — its values, its links, status: "published" — until
the edit is published. updatedAt is when that published version was written.
Localized reads
?locale=de- Absent: the default locale. Unknown slug: 400.
- No fallback. A record with no German row is missing from a
?locale=delist.localeFallbackis not accepted here (400). Ask for the default locale explicitly if you want it.
Addressing by translation group
A record has one localeGroupId across every language. Use it in URLs instead
of per-locale ids.
curl 'https://cms.example.com/api/v1/content/article/group/b2d1…?locale=de' \
-H 'Authorization: Bearer orthacms_…' const url =
'https://cms.example.com/api/v1/content/article/group/' +
encodeURIComponent(groupId) +
'?locale=' + encodeURIComponent(locale);
const response = await fetch(url, {
headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN }
}); query ArticleByGroup($group: ID!, $locale: String) {
article(localeGroupId: $group, locale: $locale) {
id
locale
title
}
} - Without
?locale=, the default locale’s row. - No published row in that locale: 404. On a type that is not localized: 400.
| Route | Returns |
|---|---|
GET …/group/:groupId | That group’s entry in the requested locale |
GET …/group/:groupId/relations/:field | One relation field’s links |
GET …/group/:groupId/media | That entry’s media assets |
GET …/group/:groupId/translations | The group’s other locale rows |
Sibling translations
?translations=preview adds the rest of the group — one read builds a language
switcher.
curl 'https://cms.example.com/api/v1/content/article/group/b2d1…?locale=en&translations=preview&fields=slug' \
-H 'Authorization: Bearer orthacms_…'{
"id": "9c4b…",
"values": { "slug": "hello-postgres" },
"locale": "en",
"localeGroupId": "b2d1…",
"translations": [
{ "id": "5e0a…", "values": { "slug": "hallo-postgres" }, "locale": "de", "localeGroupId": "b2d1…" }
]
}(Envelope trimmed.) Siblings are ordered by locale, published only,
honour fields, and are not expanded. 400 on a type that is not localized.
In GraphQL, select translations { locale slug }.
Single types
A single type (a home page, site settings) is served by the list route —
take items[0]. GraphQL gives it one field that returns the record or null.
curl 'https://cms.example.com/api/v1/content/home_page?locale=de' \
-H 'Authorization: Bearer orthacms_…' const response = await fetch(
'https://cms.example.com/api/v1/content/home_page?locale=de',
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const home = (await response.json()).items[0] ?? null; query Home {
homePage(locale: "de") {
heroTitle
ctaLabel
ctaHref
}
} Restricted content
With the segments plugin, send X-Reader-Tags from
your backend to read as a particular reader. Without it you get only
unrestricted entries.
curl 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Reader-Tags: premium' // On your server. Tags come from YOUR session, never from the browser.
const tags = session.user?.plan === 'premium' ? ['premium'] : [];
const response = await fetch('https://cms.example.com/api/v1/content/article', {
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
...(tags.length ? { 'X-Reader-Tags': tags.join(',') } : {})
}
}); curl -X POST 'https://cms.example.com/api/v1/graphql' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Reader-Tags: premium' \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles { total items { id title } } }"}' A restricted entry the reader may not see is missing from lists and from
total, and a 404 on a single read. The same holds for expanded relations.
See request headers for the rules.
Discovering the schema
| Route | Returns |
|---|---|
GET /api/v1/content-types | Every type this token can read |
GET /api/v1/content-types/:name | One type’s fields, with kind, validation rules and options |
curl https://cms.example.com/api/v1/content-types \
-H 'Authorization: Bearer orthacms_…'{
"items": [
{
"name": "article",
"kind": "collection",
"label": "Articles",
"description": "Long-form posts — the reference content type.",
"publishable": true,
"paranoid": true,
"i18n": true
},
{ "name": "home_page", "kind": "single", "label": "Home page", "description": "The localized landing page.", "path": "/", "publishable": true, "paranoid": false, "i18n": true }
]
}Both routes list only what the workspace can read — its own grants plus types it reads from shared workspaces. Anything else is a 404.
Caching
- Reads send no
Cache-Control. Your CDN’smax-agedecides. - Each JSON response carries a weak
ETag; send it back asIf-None-Matchfor a 304. - With
X-Reader-Tags, responses differ per reader. Key your cache on the tags, or cache only anonymous reads. - To re-render on publish rather than on a timer, use a webhook.
What to read next
- Filtering — the
?filter=tree and every operator. - Expanding relations and media — author and cover image in the same read.
- Preview and drafts — reading unpublished rows safely.