graphql api

The GraphQL session API — schema, queries, mutations, and the limits the server enforces.

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 /graphql

Send 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 kindGraphQL typenotes
uuidIDid is ID!
stringStringlength and regex live in the resolver, not the schema
slugStringrequired at the DB layer, optional in input — the server derives it from title
richTextJSONTiptap document. Structured RichTextDocument types land in v1
datetimeDateTimeISO 8601 string
booleanBoolean
localeStringone 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. Omit slug and the server derives it from title.
  • update<Type>(id, input) patches — every input field is optional, and an omitted field keeps its current value. It returns the updated row, or null when no row matched the id under your tenant.
  • delete<Type>(id) returns true when a row was deleted, false when 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:

limitvaluebehavior
max depth8a deeper selection set is rejected 400
max cost1000the 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.