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

The delivery API

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

One public API in two protocols, the request headers it reads, and what a published read returns.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

One public API, two protocols, same tokens and same rules:

GET  /api/v1/content/:type    REST
POST /api/v1/graphql          GraphQL

GraphQL is an adapter over the REST internals, not a second API. The two always agree.

First request

Example language
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; status and publishedAt on publishable types; locale and localeGroupId on 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.

HeaderRequiredWhat it does
Authorization: Bearer orthacms_…AlwaysThe API token. Missing or invalid is a 401.
X-Workspace-Id: <uuid>When the token covers more than one workspacePicks the workspace. Outside the token’s bucket is a 403; malformed is a 400. Also selects the GraphQL schema.
X-Reader-Tags: premium, euNoWho you are reading for, when the segments plugin restricts content. Comma-separated tags. Absent = anonymous reader.
Content-Type: application/jsonOn JSON bodiesWrites and POST /api/v1/graphql. Uploads use multipart/form-data.
If-None-Match: <etag>NoRevalidate 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:

Example language
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-tags

Only 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 read token 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

RouteWhat it does
GET /api/v1/content-typesThe content types this token can read
GET /api/v1/content-types/:nameOne type’s field schema
GET /api/v1/content/:typeList published entries
GET /api/v1/content/:type/:idOne entry
GET /api/v1/content/:type/:id/relations/:fieldPage one relation field’s links
GET /api/v1/content/:type/:id/mediaOne entry’s media assets
GET /api/v1/content/:type/:id/translationsAn entry’s sibling translations
GET /api/v1/content/:type/group/:groupIdAn entry addressed by translation group
POST /api/v1/content/:typeCreate an entry
PATCH /api/v1/content/:type/:idUpdate an entry
POST /api/v1/content/:type/:id/publishPublish
POST /api/v1/content/:type/:id/unpublishUnpublish
DELETE /api/v1/content/:type/:idDelete
POST /api/v1/content/:type/bulkCreate and update many
POST /api/v1/content/:type/bulk/publishPublish many (also bulk/unpublish, bulk/delete)
GET /api/v1/content/:type/:id/accessWhich segments may read an entry
PUT /api/v1/content/:type/:id/accessSet them
POST /api/v1/media/assetsUpload an asset
GET /api/v1/media/assets/:id/rawDownload an asset
POST /api/v1/graphqlGraphQL queries and mutations
GET /api/v1/graphqlThe schema, as SDL
POST /api/v1/mcpMCP, when MCP_ENABLED=true
  • Every :id route has a group/:groupId twin that takes ?locale=.
  • The two access routes 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 switch

It 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.