POST /api/v1/graphql run one query or mutation
GET /api/v1/graphql the schema, as SDL
GET /api/v1/graphql/playground GraphiQL (when API_DOCS is on)Same tokens, same workspace bucket, same scopes and visibility rules as REST. It calls the same services, so the answers agree.
Sending a request
A JSON POST with query, optional variables, and operationName when the
document holds more than one operation. Headers are the REST ones — see request
headers.
const query = `
query Homepage($locale: String, $pageSize: Int) {
articles(pageSize: $pageSize, sort: "-publishedAt", locale: $locale) {
total
items { id title slug }
}
}
`;
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, // multi-workspace tokens
'X-Reader-Tags': 'premium', // optional, server-side only
'Content-Type': 'application/json'
},
body: JSON.stringify({
query,
variables: { locale: 'de', pageSize: 6 },
operationName: 'Homepage'
})
});
const { data, errors } = await response.json(); // HTTP 200 whenever it ran curl -X POST 'https://cms.example.com/api/v1/graphql' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' \
-H 'Content-Type: application/json' \
-d '{
"query": "query Homepage($locale: String) { articles(pageSize: 6, locale: $locale) { total items { id title slug } } }",
"variables": { "locale": "de" }
}' POST /api/v1/graphql HTTP/1.1
Host: cms.example.com
Authorization: Bearer orthacms_…
X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90
Content-Type: application/json
{"query":"query Homepage($locale: String) { articles(locale: $locale) { total items { id title } } }","variables":{"locale":"de"}} {
"data": {
"articles": {
"total": 42,
"items": [
{ "id": "9c4b…", "title": "Hallo, Postgres", "slug": "hallo-postgres" }
]
}
}
}X-Workspace-Idalso picks the schema: you see that workspace’s types.X-Reader-Tagsapplies to every nested relation too. Send it only from a backend.- No
Origincheck and no CORS: call it from your server, not a browser.
GraphiQL
/api/v1/graphql/playground is served outside production, or with
API_DOCS=true. The page needs no token; paste headers into GraphiQL’s header
editor:
{
"Authorization": "Bearer orthacms_…",
"X-Workspace-Id": "3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90"
}A full query
query Homepage($locale: String, $filter: JSON) {
articles(pageSize: 6, sort: "-publishedAt", filter: $filter, locale: $locale) {
total
items {
id
localeGroupId
title
slug
author {
name
}
tags(pageSize: 5) {
total
items {
name
}
}
coverImage {
url
alt
}
translations {
locale
slug
}
}
}
} {
"locale": "en",
"filter": { "and": [{ "field": "featured", "op": "eq", "value": true }] }
} Naming
A content type called blog_post becomes:
| Thing | Name |
|---|---|
| Object type | BlogPost |
| One entry | blogPost(id:), or blogPost(localeGroupId:, locale:) on a localized type |
| A page of entries | blogPosts(page:, pageSize:, sort:, search:, filter:, locale:, status:) → BlogPostList { items total } |
| Write input | BlogPostInput, plus BlogPostRelationsInput when it has relations |
| Mutations | createBlogPost, updateBlogPost, publishBlogPost, unpublishBlogPost, deleteBlogPost |
- A
singletype gets one field,homePage(locale:, status:), returning the record ornull. publish…/unpublish…exist only on publishable types.- Plurals follow English rules (
category→categories). Check the SDL. contentTypes { name kind label publishable paranoid i18n }lists readable types with the flags SDL cannot express.
Arguments and fields
| Where | What |
|---|---|
| List fields | page, pageSize (max 100), sort ("-publishedAt"), search, filter (a JSON tree — see filtering), locale, status |
status: | PUBLISHED (default), DRAFT, ANY. The last two need a full token. |
| Many-to-one relation | The target object, e.g. author { name }. null when unset or not visible. |
| Many-to-many / inverse relation | tags(page:, pageSize:) { items total }. pageSize defaults to 20. |
| Media field | A list of MediaAsset — id name url thumbUrl previewUrl kind mimeType alt decorative tracks — with limit: (default 20). |
translations | The other published locale rows, on localized types. |
Selecting { id title } narrows the SQL projection exactly like REST’s
?fields=. Fragments are followed.
Where it differs from REST
| REST | GraphQL |
|---|---|
A single type is a list; take items[0] | homePage(locale:) returns the record |
Relations need ?relations=preview | Select them; any depth, within the cost limits |
{ items, total, page, pageSize } | { items, total } |
"published" | PUBLISHED |
DELETE → 204 | deleteArticle → true |
| A missing entry is a 404 | A missing entry is null |
| Errors use HTTP status | HTTP 200, status in errors[].extensions.status — see errors |
Mutations
Need a full token. input holds the field values directly (no values
wrapper); relation changes go in relations. Bulk and access routes have no
GraphQL counterpart.
mutation CreateArticle($input: ArticleInput, $relations: ArticleRelationsInput, $locale: String) {
createArticle(input: $input, relations: $relations, locale: $locale) {
id
status
localeGroupId
}
} {
"locale": "en",
"input": { "title": "Hello", "slug": "hello" },
"relations": { "tags": { "link": ["1f9a…"] } }
} mutation {
publishArticle(id: "9c4b…") {
id
status
publishedAt
}
} - A create always lands as a draft on a publishable type.
update…,publish…,unpublish…anddelete…takeid, orlocaleGroupId+localeon a localized type.- Merge semantics: an omitted field is left alone, an explicit
nullclears it. - In
relations:link,unlink,order,set(owning single relation), andby: LOCALE_GROUPto pass translation-group ids. - A mutation’s result can select its relations, even on a draft.
- A media field takes an asset id (a list of ids when
multiple). A per-usagealtcan only be written over REST — see the media API. - Updating a published entry keeps its published version live until you publish again.
A schema per workspace
The schema is built per workspace grant set:
- A type the workspace cannot read is not in the schema. Naming it is a validation error, not a 404.
- A relation to an unreadable type is omitted from the object and the input.
- A type the workspace reads only from a shared workspace is queryable; its
mutations fail with
FORBIDDEN(status 403). - Two tokens can get different SDL. That is correct.
The schema is cached per grant set for a minute (GRAPHQL_SCHEMA_CACHE_TTL_MS).
Every resolver re-checks live grants, so a stale schema can describe a revoked
type but never read it.
Fetch the SDL for codegen:
curl https://cms.example.com/api/v1/graphql \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' > schema.graphqlSee generating a typed client.
Cost limits
Checked after parsing, before execution.
| Limit | Default | Env var | Catches |
|---|---|---|---|
maxQueryLength | 16384 | GRAPHQL_MAX_QUERY_LENGTH | Huge documents, before parsing |
maxDepth | 8 | GRAPHQL_MAX_DEPTH | Deep nesting |
maxFields | 500 | GRAPHQL_MAX_FIELDS | Many selections or aliases |
maxComplexity | 1000 | GRAPHQL_MAX_COMPLEXITY | Page sizes multiplied down the nesting |
| Operations per request | 1 | — | Several operations without operationName |
# .env
GRAPHQL_MAX_DEPTH=6
GRAPHQL_MAX_COMPLEXITY=500Or in code:
ContentGraphqlPlugin({
content,
limits: { maxDepth: 6, maxComplexity: 500 }
});Over budget:
{
"errors": [
{
"message": "Query is 9 levels deep; the limit is 8. Split it, or fetch the deeper records in a second request.",
"extensions": { "code": "GRAPHQL_LIMIT_EXCEEDED" }
}
]
}- Complexity assumes every list comes back full. A field with
id:orlocaleGroupId:counts as one record. - Introspection is on and exempt from the budget.
Nested reads batch per level
Nested fields in one execution are batched into one query per level, not one per row.
Page two of a relation is not batched
page: 2 or later on a relation field inside a list costs one query per row.
Prefer a larger pageSize on page 1.
What to read next
- Front-end frameworks — calling GraphQL from a server route, and codegen.
- Filtering — the
filter:grammar. - The GraphQL plugin — registering the adapter.