graphql api
ChiliCMS ships a GraphQL session API for the built-in Post and Page types.
Dynamic content types are served over REST today while first-class generated
GraphQL for custom models matures.
This page is reference. For the typed client most apps reach for first, read the sdk reference; for the model behind both, read concepts.
what GraphQL covers today
GraphQL is generated for the built-in Post and Page types only. Content
types you add with defineContentType are served over REST, not GraphQL — a
GraphQL query for a dynamic type fails schema validation as an unknown field
rather than half-resolving. First-class GraphQL for dynamic types is tracked
follow-up work; until it lands, REST is the surface for everything beyond Post
and Page.
GraphQL is also the session-authed surface, not the public delivery channel. It runs on your session token and returns drafts as well as published rows, which is what an editor or a build step wants. The public, published-only read path is REST with a delivery token (see the published read path).
endpoint
One endpoint serves every query and mutation:
POST /graphqlSend a JSON body with query, optional variables, and optional
operationName:
curl https://api.chilicms.dev/graphql \
-H "Authorization: Bearer $CHILI_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ posts { id title } }"}'The bearer is a session token, the same credential the Studio uses. A delivery
token does not authenticate here — present one and the server returns 401
before any resolver runs. The two are different credential classes on purpose,
and the boundary is enforced at the gate, not in a resolver.
schema
The generated SDL declares two custom scalars, an object type per content type,
create/update input types, and combined Query and Mutation roots.
"ISO 8601 datetime string."
scalar DateTime
"Opaque JSON value. Used for rich-text body in v0; structured RichTextDocument lands in v1."
scalar JSON
type Post {
id: ID!
title: String!
slug: String!
body: JSON!
publishedAt: DateTime
locale: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
posts: [Post!]! @cost(complexity: 10)
post(id: ID!): Post
pages: [Page!]! @cost(complexity: 10)
page(id: ID!): Page
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post
deletePost(id: ID!): Boolean!
createPage(input: CreatePageInput!): Page!
updatePage(id: ID!, input: UpdatePageInput!): Page
deletePage(id: ID!): Boolean!
}Page mirrors Post field-for-field. Each content type contributes one list
query, one by-id query, and three mutations.
scalar mapping
Field kinds in your model map to GraphQL types like this:
| field kind | GraphQL type | notes |
|---|---|---|
uuid | ID | id is ID! |
string | String | length and regex live in the resolver, not the schema |
slug | String | required at the DB layer, optional in input — the server derives it from title |
richText | JSON | Tiptap document. Structured RichTextDocument types land in v1 |
datetime | DateTime | ISO 8601 string |
boolean | Boolean | |
locale | String | one of en, de, fr, es, nl, it — checked at the DB layer |
GraphQL has no native length, regex, or datetime constraints, so those rules sit
in the resolver and the database, not in the SDL. tenantId is never on the
public surface — it is set server-side from your session and excluded from both
the object type and every input.
queries
List a type, or fetch one by id:
query Posts {
posts {
id
title
slug
publishedAt
}
}query OnePost($id: ID!) {
post(id: $id) {
id
title
body
}
}The list query takes no filter, sort, or pagination arguments yet — posts
returns every row your session can see. Filtering and Relay-style pagination are
forward work; the cost limiter is already wired to honor first/last/limit
arguments when they ship, so adding them later does not change the limit model.
post(id:) returns null when no row matches the id under your tenant. Reads run
inside a row-level-security transaction scoped to your tenant, so a wrong-tenant
id reads as not-found — the database draws the boundary, not the application.
mutations
Create, update, and delete each return predictably:
mutation Create($input: CreatePostInput!) {
createPost(input: $input) {
id
slug
publishedAt
}
}{
"input": {
"title": "Hello world",
"body": { "type": "doc", "content": [] }
}
}create<Type>returns the new row, non-null. Omitslugand the server derives it fromtitle.update<Type>(id, input)patches — every input field is optional, and an omitted field keeps its current value. It returns the updated row, ornullwhen no row matched the id under your tenant.delete<Type>(id)returnstruewhen a row was deleted,falsewhen nothing matched.
Setting publishedAt to a future time schedules the row: it stays off the public
read path until that moment. Set it to now or the past and it is live
immediately. Every mutation writes an audit row and fans out a webhook
(post.create, post.update, post.delete, and the Page equivalents) in the
same transaction, so the audit log and the write commit or roll back together.
the published read path
GraphQL returns drafts because it is authenticated as you. The public surface — published rows only, no session — is REST with a delivery token:
curl https://api.chilicms.dev/api/v1/posts \
-H "X-ChiliCMS-Delivery-Token: $CHILI_DELIVERY_TOKEN"A delivery token is read-only and tenant-scoped: it returns only published
content (publishedAt set and in the past) for exactly one tenant. Drafts and
scheduled rows are invisible on this path, and a scheduled url returns the same
404 as a missing one, so it is not enumerable. Most front ends read through the
sdk, which wraps this path.
limits
The server validates every document before execution and rejects pathological queries up front:
| limit | value | behavior |
|---|---|---|
| max depth | 8 | a deeper selection set is rejected 400 |
| max cost | 1000 | the summed query cost over budget is rejected 400 |
Cost is read off the schema. List fields carry @cost(complexity: 10); scalar
and by-id fields default to 1. Cost is multiplicative under list parents, which
models the row fan-out of nested lists — so the budget bites on deep nesting, not
on a flat field selection. The v0 schema’s worst case is three levels deep
(body is a JSON scalar with nothing to select beneath it), so the depth cap
of 8 is headroom, not a constraint you will hit by hand.
A query that breaks a limit comes back as a GraphQL error with the specific
number, for example Query cost is 1300; the maximum is 1000. — so you can see
exactly what tripped it.
what’s next
- sdk reference — the typed client over the same schema.
- concepts — schema-as-code, draft vs published, and delivery.
- self-host — run all of this on your own Postgres.