Reference
GraphQL API
The same published content as the REST API, through one endpoint with a schema built from your collections. Relations resolve in the same request.
Endpoint#
POST/graphql
Run a query with a JSON body { query, variables, operationName, extensions }.
GET/graphql
The same, with the fields as query parameters (variables and extensions JSON-encoded). Useful behind a CDN.
The path has no /v1: it is https://api.console.citronos.com/graphql. It takes the same keys as REST (ck_pub_ for published content, ck_prev_ for drafts) in Authorization: Bearer … or x-api-key, and shares their per-key rate limit.
GraphQL is a module you switch on for the workspace, included from the Professional plan. While it is off, /graphql answers 404 for that workspace’s keys.
curl https://api.console.citronos.com/graphql \
-H "Authorization: Bearer $CITRON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"query ($n: Int) { products(pageSize: $n, locale: \"en\") { data { slug name } meta { total } } }","variables":{"n":10}}'type ProductsQuery = {
products: { data: { slug: string | null; name: string | null }[]; meta: { total: number } };
};
const data = await citron.graphql<ProductsQuery>(
`query Products($n: Int) {
products(pageSize: $n, locale: "en", filter: { category: { eq: "station" } }) {
data { slug name }
meta { total }
}
}`,
{ n: 10 },
);
console.log(data.products.meta.total);Your schema#
Each workspace has its own schema, rebuilt when a collection changes. Introspection works with any valid key, so GraphiQL, Apollo tooling or graphql-codegen can read it. Names come from collection slugs:
| Collection slug | Type | List field | Single field |
|---|---|---|---|
products | Product | products(…): ProductList! | product(id, slug, …): Product |
other-products | OtherProduct | otherProducts | otherProduct |
gallery | Gallery | galleryList | gallery |
site-settings (singleton) | SiteSetting | none | siteSetting(…): SiteSetting |
- Every entry type has
id,slug,publishedAt,updatedAtandsortOrder, then one field per collection field. - A list field returns
{ data: [Entry!]!, meta: PageMeta! }, withtotal,page,pageSizeandpageCountinmeta. - Image and media fields are
Assetobjects (url,width,height,alt,variants,focal), relations are the related entry type, rich text is aStringwith aformat: HTML | JSONargument (JSON as a string). - A single field returns
nullwhen the entry does not exist, rather than an error. - Live (external) collections are not in the schema. There are no mutations or subscriptions.
Filters, sorting and paging#
List fields take filter, sort, page, pageSize (at most 100), locale and status. Filters mirror REST: StringFilter and FloatFilter have eq ne lt lte gt gte in notIn (strings add contains), BooleanFilter has eq ne, and list fields take ListFilter { contains }. At most 10 conditions. sort is one { field, direction }.
query Stations($n: Int, $locale: String) {
products(
filter: { category: { eq: "station" }, capacity: { gte: 5 } }
sort: { field: publishedAt, direction: DESC }
page: 1
pageSize: $n
locale: $locale
) {
data {
id
slug
code
name
body(format: HTML)
cover { url width height alt focal { x y } }
specRows { label value }
related { slug name }
}
meta { total page pageSize pageCount }
}
}query Product($slug: String!) {
product(slug: $slug, locale: "bg") {
slug
name
body(format: JSON)
datasheet { url mimeType }
}
siteSetting(locale: "bg") {
siteName
phone
}
}Locales#
Pass locale on a root field and every localized value below it, relations included, comes in that language with the default locale as fallback. Without locale you get the default locale. Aliases let one request ask for two languages.
Drafts#
status: DRAFT reads working copies. It needs a ck_prev_ key; with a ck_pub_ key the query fails with a FORBIDDEN error. Related entries always resolve to their published version.
# Needs a ck_prev_ key; with a ck_pub_ key this is a FORBIDDEN error
query Drafts {
products(status: DRAFT, sort: { field: updatedAt, direction: DESC }) {
data { slug updatedAt name }
}
}Automatic persisted queries#
Send extensions.persistedQuery.sha256Hash (the hex SHA-256 of the query text) instead of the query. If the API knows the hash it runs the stored query; if not it answers PERSISTED_QUERY_NOT_FOUND with status 200, and you send the query once together with the hash. Stored queries live for 24 hours per workspace. A hash that does not match the query answers 400 PERSISTED_QUERY_HASH_MISMATCH.
# 1. Ask by hash only (a GET, so a CDN can cache it)
curl -G https://api.console.citronos.com/graphql \
-H "Authorization: Bearer $CITRON_API_KEY" \
--data-urlencode 'variables={"n":10}' \
--data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"9d26ad0be5e0f576dba38d7c8579002c6c89e13f114bd8565dd622081ce7a346"}}'
# → {"errors":[{"message":"PersistedQueryNotFound","extensions":{"code":"PERSISTED_QUERY_NOT_FOUND"}}]}
# 2. Not known yet: send the query once with its hash; it is kept for 24 hours
curl https://api.console.citronos.com/graphql \
-H "Authorization: Bearer $CITRON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"query ($n: Int) { products(pageSize: $n) { data { slug } meta { total } } }","variables":{"n":10},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"9d26ad0be5e0f576dba38d7c8579002c6c89e13f114bd8565dd622081ce7a346"}}}'// persisted: true does both steps for you and uses GET by default
const data = await citron.graphql<{ products: { data: { slug: string | null }[]; meta: { total: number } } }>(
'query ($n: Int) { products(pageSize: $n) { data { slug } meta { total } } }',
{ n: 10 },
{ persisted: true },
);
console.log(data.products.meta.total);Caching#
Successful answers to ck_pub_ keys are cached by the API for 60 seconds and carry Cache-Control: public, max-age=60, stale-while-revalidate=300, an ETag (so If-None-Match gives a 304) and Vary: Authorization, x-api-key, Accept. Answers to preview keys are no-store, and answers with errors are never kept in the API’s cache.
Depth and complexity limits#
A query may be at most 6 levels deep and cost at most 1,000. An object field costs 1, a list field 1 plus 10 times what it selects, and scalar fields and introspection are free. Over either limit, the query is rejected before it runs, with QUERY_TOO_DEEP or QUERY_TOO_COMPLEX.
# Cost: every list multiplies what is inside it by 10
# products 1 + data (1 + 10 × related (1 + 10 × related (1 + 10 × related (1)))) = 1112
query TooWide {
products {
data { related { related { related { slug } } } }
}
}{
"errors": [
{
"message": "Query complexity 1112 exceeds the limit of 1000",
"extensions": { "code": "QUERY_TOO_COMPLEX", "complexity": 1112, "limit": 1000 }
}
]
}Errors#
extensions.code | When |
|---|---|
BAD_USER_INPUT | An unknown filter field or operator, too many filters, an unknown locale, a single field without id or slug. |
FORBIDDEN | status: DRAFT with a ck_pub_ key. |
QUERY_TOO_DEEP, QUERY_TOO_COMPLEX | Over the limits above. |
PERSISTED_QUERY_NOT_FOUND | Unknown hash: send the query with it. |
PERSISTED_QUERY_HASH_MISMATCH | The hash is not the SHA-256 of the query (HTTP 400). |
Transport errors (a bad key, the module switched off, the rate limit) are HTTP errors like REST; see errors.
This reference covers the public API only and follows the API’s compatibility contract: public responses only ever gain fields. The OpenAPI description of these routes is at /developers/openapi.json.