The public content API, spoken as GraphQL. An adapter over the REST services: same bearer tokens, same workspace bucket, same scopes, same visibility rules. A token minted before it existed works unchanged.
POST /api/v1/graphql queries and mutations (one operation per request)
GET /api/v1/graphql the schema as SDL (text/plain)
GET /api/v1/graphql/playground GraphiQL — rides the API_DOCS switchThe playground follows the Scalar API reference: on outside production,
API_DOCS=true|false overrides. With it off the route does not exist. The page
needs no token; paste one into its header editor.
Install and register
npm install @orthacms/content-graphql// apps/server/src/plugins.ts — after ContentPlugin
import { ContentGraphqlPlugin } from '@orthacms/content-graphql';
const content = ContentPlugin({ types: contentTypes });
ContentGraphqlPlugin({
content, // by value: two types colliding as GraphQL names fail the boot
...config.plugins.contentGraphql,
playground: config.docs.enabled === true
});create-orthacms-app asks which protocols to enable. GraphQL is unticked by
default; add it later with the two steps above.
Headers
| Header | When |
|---|---|
Authorization: Bearer orthacms_… | Always |
Content-Type: application/json | On POST |
X-Workspace-Id: <uuid> | When the token covers more than one workspace |
X-Reader-Tags: premium, eu | To read as a reader — see segments |
A query
query Latest($locale: String) {
articles(pageSize: 10, sort: "-publishedAt", locale: $locale) {
total
items {
id
title
slug
publishedAt
author {
name
}
tags(pageSize: 5) {
total
items {
name
}
}
}
}
} curl -X POST https://cms.example.com/api/v1/graphql \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{"query":"query($locale: String) { articles(pageSize: 10, locale: $locale) { total items { id title } } }","variables":{"locale":"de"}}' const response = await fetch('https://cms.example.com/api/v1/graphql', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'Content-Type': 'application/json',
'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID
},
body: JSON.stringify({
query: 'query Latest($locale: String) { articles(pageSize: 10, locale: $locale) { total items { id title slug } } }',
variables: { locale: 'de' },
operationName: 'Latest'
})
});
const { data, errors } = await response.json(); More reads:
# one entry, by id or by translation group + locale
query {
article(id: "0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90") { title }
de: article(localeGroupId: "4a7d9e21-3b6c-4f58-a1e0-9c2b8d7f6e53", locale: "de") {
title
translations { locale title }
}
}
# filter, search, drafts (DRAFT and ANY need a full-access token)
query {
articles(
search: "autumn"
filter: { and: [{ field: "featured", op: "eq", value: true }] }
status: ANY
) {
items { id title status publishedAt }
}
contentTypes { name kind label publishable i18n }
}# the schema your token sees, as SDL
curl https://cms.example.com/api/v1/graphql -H 'Authorization: Bearer orthacms_…'Names
The schema is generated from your content types. For a type named blog_post:
| What | Name |
|---|---|
| Object / list envelope | BlogPost / BlogPostList ({ items, total }) |
| Single read | blogPost(id:) — or localeGroupId: + locale: on a localized type |
| List read | blogPosts(page, pageSize, sort, search, filter, locale, status) |
A single type | blogPost(locale:) returns the record — no list |
| Mutations | createBlogPost, updateBlogPost, publishBlogPost, unpublishBlogPost, deleteBlogPost |
| Inputs | BlogPostInput (values), BlogPostRelationsInput (relation deltas) |
- An owning single relation is the target object (
author { name }). - Many-to-many and inverse relations are
{ items, total }withpage/pageSize. - Media fields are lists of
{ id name url thumbUrl alt … }. - A relation to a type the workspace was not granted is omitted from the schema.
Mutations
Need a full-access token.
mutation Draft {
createArticle(
input: { title: "Autumn catalogue", slug: "autumn-catalogue" }
relations: { tags: { link: ["b3e1c7d9-5a2f-4e86-9d10-7c4f2a8b6e31"] } }
locale: "en"
) {
id
status # DRAFT — a create never publishes
tags { total }
}
}
mutation Ship {
publishArticle(id: "0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90") {
status
publishedAt
}
}update…is a partial update: omitted fields are untouched,nullclears.- Relation deltas take
link,unlink,order;setfor an owning single relation;by: LOCALE_GROUPto pass translation-group ids. delete…returnstrue.
Errors
Every executed operation answers HTTP 200. The status lives in
errors[].extensions:
{
"errors": [
{
"message": "This token’s scope does not allow content:create.",
"path": ["createArticle"],
"extensions": { "code": "FORBIDDEN", "status": 403 }
}
],
"data": null
}extensions.code | status |
|---|---|
BAD_REQUEST | 400 |
UNAUTHENTICATED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
CONFLICT | 409 |
VALIDATION_FAILED | 422 — with extensions.issues, per field |
INTERNAL_SERVER_ERROR | 500 — message masked |
data and errors can arrive together: a nullable field that failed comes
back null beside the rest.
Budgets
Checked after parsing, before execution. Set in apps/server/config/graphql.ts:
| Variable | Default | Bounds |
|---|---|---|
GRAPHQL_MAX_QUERY_LENGTH | 16384 | Characters of query text, checked before parsing |
GRAPHQL_MAX_DEPTH | 8 | Nesting depth |
GRAPHQL_MAX_FIELDS | 500 | Fields selected in total, aliases included |
GRAPHQL_MAX_COMPLEXITY | 1000 | Sum of pageSize down each nesting path |
GRAPHQL_SCHEMA_CACHE_TTL_MS | 60000 | How long a built schema is reused |
// apps/server/config/graphql.ts
export function contentGraphqlConfig(): ContentGraphqlPluginConfig {
return {
limits: {
maxDepth: readPositiveInt('GRAPHQL_MAX_DEPTH', 8),
maxComplexity: readPositiveInt('GRAPHQL_MAX_COMPLEXITY', 1000),
maxFields: readPositiveInt('GRAPHQL_MAX_FIELDS', 500),
maxQueryLength: readPositiveInt('GRAPHQL_MAX_QUERY_LENGTH', 16_384)
},
schemaCacheTtlMs: readPositiveInt('GRAPHQL_SCHEMA_CACHE_TTL_MS', 60_000)
};
}- One operation per request.
- An
id:orlocaleGroupId:read costs 1, not a page. - Introspection is enabled and exempt from the budgets.
- No rate limit — the REST API has none either.
The schema differs per token
The schema is built per grant set — the content types the token’s workspace was granted. Two tokens can see different SDL, and an ungranted type is a validation error rather than a 404. The cache TTL is freshness only: every resolver re-checks the live grants.
See the GraphQL API for the full delivery guide.