One public API, two protocols, same tokens and same rules:
GET /api/v1/content/:type REST
POST /api/v1/graphql GraphQLGraphQL is an adapter over the REST internals, not a second API. The two always agree.
First request
curl 'https://cms.example.com/api/v1/content/article?pageSize=2&fields=title,slug' \
-H 'Authorization: Bearer orthacms_…' const response = await fetch(
'https://cms.example.com/api/v1/content/article?pageSize=2&fields=title,slug',
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
if (!response.ok) throw new Error('Ortha answered ' + response.status);
const { items, total } = await response.json(); # POST /api/v1/graphql
query {
articles(pageSize: 2) {
total
items {
id
title
slug
}
}
} {
"items": [
{
"id": "9c4b1e77-…",
"createdAt": "2026-02-11T09:14:03.221Z",
"updatedAt": "2026-03-02T16:40:55.010Z",
"values": { "title": "Hello, Postgres", "slug": "hello-postgres" },
"status": "published",
"publishedAt": "2026-02-12T08:00:00.000Z",
"locale": "en",
"localeGroupId": "b2d1…"
}
],
"total": 128,
"page": 1,
"pageSize": 2
}- Envelope —
id,createdAt,updatedAt;statusandpublishedAton publishable types;localeandlocaleGroupIdon localized types. Always returned, not selectable. values— the type’s own fields. Relation and media fields are not in it; expand them.
Request headers
The headers the REST and GraphQL endpoints read. There is no other request header to know about.
| Header | Required | What it does |
|---|---|---|
Authorization: Bearer orthacms_… | Always | The API token. Missing or invalid is a 401. |
X-Workspace-Id: <uuid> | When the token covers more than one workspace | Picks the workspace. Outside the token’s bucket is a 403; malformed is a 400. Also selects the GraphQL schema. |
X-Reader-Tags: premium, eu | No | Who you are reading for, when the segments plugin restricts content. Comma-separated tags. Absent = anonymous reader. |
Content-Type: application/json | On JSON bodies | Writes and POST /api/v1/graphql. Uploads use multipart/form-data. |
If-None-Match: <etag> | No | Revalidate a cached GET. A match answers 304. |
There is no locale header. Pick the language with ?locale=de on REST, or
the locale: argument in GraphQL. See localized reads.
All of them together:
curl 'https://cms.example.com/api/v1/content/article?locale=de' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' \
-H 'X-Reader-Tags: premium, eu' // Server-side only — this code holds the token.
const response = await fetch(
'https://cms.example.com/api/v1/content/article?locale=de',
{
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID,
'X-Reader-Tags': ['premium', 'eu'].join(',')
}
}
); // Same headers on the GraphQL endpoint.
const response = await fetch('https://cms.example.com/api/v1/graphql', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID,
'X-Reader-Tags': 'premium, eu',
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: 'query($locale: String) { articles(locale: $locale) { total items { id title } } }',
variables: { locale: 'de' }
})
}); X-Reader-Tags
The default reader resolver since 0.7.0. It applies to REST, GraphQL and MCP reads alike, relations included.
- Each tag is matched against the tags of your segments. A reader carrying any one tag of a segment is in it.
- No header, or no segment exists yet: the anonymous reader. Unrestricted entries serve; restricted ones are a 404 (or missing from lists).
- Rename it with
SEGMENTS_READER_TAGS_HEADER:
# .env
SEGMENTS_READER_TAGS_HEADER=x-plan-tagsOnly your backend sets X-Reader-Tags
The header is trusted because the token is. Send it from the server that holds the token, with tags you derived from your own session or billing data. Never forward a value from the browser, and never put the token in a browser — anyone holding it could name any audience.
A different source (a JWT claim, a billing lookup) is a resolver of your own in
apps/server/config/segments.ts:
// apps/server/config/segments.ts
import type { SegmentsPluginConfig } from '@orthacms/segments-server';
export function segmentsConfig(): SegmentsPluginConfig {
return {
resolver: {
// Receives the Express request. Return [] for the anonymous reader.
resolve: async (request) => readTagsFrom(request)
}
};
}What a read returns
- Published entries only, by default. A
readtoken can never see drafts. - A modified entry serves its published version (since 0.7.0). Editing a
live entry does not take it off the API: reads keep returning the last
published values and links, with
status: "published", until the edit is published. Filters, search and sort match that version, never the unpublished edit. - Only granted types. A type the workspace was not granted is a 404, is not listed, and is absent from the GraphQL schema.
- Shared workspaces (0.7.0). A workspace holding a shared grant also reads the published entries of the source workspace. They appear in lists like its own. A type reachable only through a shared grant is read-only: writing it is a 403.
- Shallow. Relations and media come back only when you expand them.
Endpoints
| Route | What it does |
|---|---|
GET /api/v1/content-types | The content types this token can read |
GET /api/v1/content-types/:name | One type’s field schema |
GET /api/v1/content/:type | List published entries |
GET /api/v1/content/:type/:id | One entry |
GET /api/v1/content/:type/:id/relations/:field | Page one relation field’s links |
GET /api/v1/content/:type/:id/media | One entry’s media assets |
GET /api/v1/content/:type/:id/translations | An entry’s sibling translations |
GET /api/v1/content/:type/group/:groupId | An entry addressed by translation group |
POST /api/v1/content/:type | Create an entry |
PATCH /api/v1/content/:type/:id | Update an entry |
POST /api/v1/content/:type/:id/publish | Publish |
POST /api/v1/content/:type/:id/unpublish | Unpublish |
DELETE /api/v1/content/:type/:id | Delete |
POST /api/v1/content/:type/bulk | Create and update many |
POST /api/v1/content/:type/bulk/publish | Publish many (also bulk/unpublish, bulk/delete) |
GET /api/v1/content/:type/:id/access | Which segments may read an entry |
PUT /api/v1/content/:type/:id/access | Set them |
POST /api/v1/media/assets | Upload an asset |
GET /api/v1/media/assets/:id/raw | Download an asset |
POST /api/v1/graphql | GraphQL queries and mutations |
GET /api/v1/graphql | The schema, as SDL |
POST /api/v1/mcp | MCP, when MCP_ENABLED=true |
- Every
:idroute has agroup/:groupIdtwin that takes?locale=. - The two
accessroutes exist only when the segments plugin is registered.
Not the admin API
Routes under /api/ without v1 (/api/content, /api/users, …) are the
admin app’s own API. Session cookie, per-user RBAC, unversioned. A bearer token
does not reach them. Build against /api/v1.
The generated reference
A running server serves its OpenAPI document. On by default outside production;
API_DOCS=true turns it on in a deployed instance.
http://localhost:3000/reference # Scalar UI
http://localhost:3000/reference/json # raw OpenAPI
http://localhost:3000/api/v1/graphql/playground # GraphiQL, same switchIt is exhaustive and generated from the code — the place to check an exact parameter or response shape.
Hearing about changes
The API is pull. The webhooks plugin pushes: a signed POST on every entry create, update, publish, unpublish, delete, restore and purge. The body names the record; you read it back through this API. See reacting to changes.
Entry events only
Media, account and workspace changes fire no webhook, and there is no scheduled publishing.