Every Core dna site answers three kinds of API request under /cdna-api: REST for everything, GraphQL for reads that span several entity types, and web events for endpoints you define yourself as jobs. Which fits which task, one worked request for each, and what is true of all three. Jobs were called hooks until the module was renamed Orchestration; the API's routes still use the old name.
L1–L3
Unit 03 · Reading with GraphQL
The GraphQL API
Reading with GraphQLPage 17 of 25Updated
One POST endpoint that reads several entity types in one round trip, with only the fields you name. It is GraphQL-style rather than a full GraphQL server: the query shape it accepts, its arguments and filters, who can call it, what a query costs, and the rules that keep a valid-looking query from failing.
Who this is for. Mainly the headless developer, whose front end needs several kinds of content per page. The JavaScript developer on a server-rendered site rarely needs it, because a template can already read all of this without a request.
Every Core dna site has a GraphQL endpoint, and almost nobody knows. Its value is one round trip where REST needs several: pages, posts and a menu, each with only the fields you name, in one request. It is a GraphQL-style endpoint rather than a full GraphQL server: it has no schema to introspect and reads only. What it accepts is set out below exactly, because a query that is valid GraphQL elsewhere can fail here.
What it is and where it lives
One endpoint, one method: POST /cdna-api/graphql. The query goes in the body under the key query, as JSON or as a form field. A complete request:
{"result":"success","data":{"Page":[
{"id":1,"name":"Learn About the Best Coffee Today","fullPath":"/"},
{"id":4,"name":"Inspiration","fullPath":"/inspiration"}
]}}
Two things about the body that differ from other GraphQL servers:
The query text must begin with the word query. The platform reads the first five characters as that keyword before parsing the rest, so they must be there: send query { Page { id } }, never { Page { id } } alone.
query must be the only key in the body. Any other key, such as variables or operationName sent by a GraphQL client library, is refused. There are no variables: build the values into the query text.
Every query problem, a parse failure, an unsupported entity, a missing required argument, a key besides query, comes back as 500 with a message that names it: read the message to tell the causes apart. A GET is a 405.
Queries only
This endpoint reads. It has no mutations and no subscriptions: send only the query key, with a query, never a mutation, as its value. Every write, including uploads, goes through REST. Plan for that from the start: a headless front end that reads through GraphQL still needs REST for forms, carts and anything else it saves.
The query shape
Each top-level field names an entity type. Inside it, list the properties you want; a property that is a relation takes its own braces. The names are the same ones REST's properties parameter uses, listed per model in the Reference.
Separate fields with commas or line breaks, never with spaces alone. Spaces are stripped before the fields are split, so { id name } asks for one property called idname. The request still answers 200, and every record comes back as {"idname": "Error: Getter for property idname … not found"}. Line breaks between fields are fine; one-line queries need commas.
Three queries of increasing complexity, with the records an example site returns.
1. Fields from one type.
graphql
query {
Page(limit: 2, orderBy: {id: "ASC"}) {
id
name
fullPath
}
}
JSON
{"result":"success","data":{"Page":[{"id":1,"name":"Learn About the Best Coffee Today","fullPath":"/"},{"id":4,"name":"Inspiration","fullPath":"/inspiration"}]}}
2. An alias, a formatted date and a relation.posts: renames the result; title: renames a property and follows a dotted path; published(format: "Y-m-d") passes an argument to the getter; blog { … } and categories { … } reach into relations.
graphql
query {
posts: BlogPost(filter: {blog.id: 1}, first: 2, orderBy: {id: "ASC"}) {
id
title: activeRevision.name
published(format: "Y-m-d")
blog { id, name }
categories { name }
}
}
Name every getter argument, as in created(format: "Y-m-d"), not created("Y-m-d").
3. A type that needs an argument. A menu element only exists inside a menu, so MenuElement requires menu: include it in every query that reads MenuElement.
graphql
query {
nav: MenuElement(menu: 1, filter: {parent: null}, orderBy: {position: "ASC"}, first: 3) {
id
name
url
}
}
Record for all records, or CustomEntities_CustomEntity_<identifier> for one definition's, for example CustomEntities_CustomEntity_event
Anything else, orders and invoices included, is not available here. Read those through REST.
Arguments and filtering
The arguments in parentheses after an entity name map onto the same criteria REST uses:
Argument
Meaning
Example
filter
Criteria on the entity's properties. Dotted paths reach relations
filter: {parent.id: 1}
first or limit
How many records. The two are the same
first: 10
offset
How many to skip. There is no page
offset: 20
orderBy
Property and direction
orderBy: {published: "DESC"}
anything else
Added to filter
menu: 1 is filter: {menu: 1}
Inside filter, a suffix on the property changes the comparison: _contains, _not_contains, _not, _gt, _lt, _gte, _lte, _in, _not_in. OR and AND take a list of conditions. Use null (unquoted) to match an empty relation. Unlike REST, whose >= and <= do not filter, _gte and _lte work here: on this site id_gte: 1885 began at 1885.
The same filter naming as REST applies: name fields exactly as they appear in the model, and on pages, posts, FAQs, books and custom entity records, filter on activeRevision.name, not name. Keys in arguments are written bare and values as JSON: strings in double quotes, numbers and null as they are.
Fetching several entity types at once
This is the reason to use it. A headless home page needs the top-level pages for its navigation, the latest posts and the header menu. REST needs three requests; this is one:
{"result":"success","data":{
"sections":[{"id":79,"name":"Form Example","fullPath":"/form-example"},{"id":78,"name":"Form Integration Example","fullPath":"/form-integration-example"},{"id":4,"name":"Inspiration","fullPath":"/inspiration"},{"id":72,"name":"Header Menu","fullPath":"/partials/header-menu"}],
"latest":[{"id":35,"name":"New","fullPath":"/blog/new","published":"2024-03-18"},{"id":12,"name":"99 problems but a batch ain’t one","fullPath":"/blog/99-problems-but-a-batch-ain-t-one","published":"2022-04-10"},{"id":11,"name":"What is Roast Development?","fullPath":"/blog/what-is-roast-development","published":"2019-08-27"}],
"nav":[{"id":105,"name":"Get Started","url":null},{"id":84,"name":"Products","url":"/products"},{"id":15,"name":"Mega Menu","url":null},…]
}}
Give every part an alias. The response is keyed by it, and two parts that name the same type without aliases would collide. The GraphQL how-to has this as a server-side function a front end can call.
Authentication
The endpoint sits behind the same checks as REST and accepts the same credentials: an OAuth access token (the preferred way) or an API key (the older one) as Authorization: Bearer …, Basic credentials, or the session cookie of a signed-in backend user (who must then also send the X-Csrf-Token header, as for any REST write). What each caller gets:
Caller
Result
A server with an OAuth access token or API key
Answered, with what that credential's user may read. The usual case
A signed-in site member
Refused: a 404 by default, or a 403 where the site's access-denied setting asks for it. Members cannot use this endpoint
Nobody signed in
401
A browser on another origin
Cannot read the response: the platform sends no cross-origin headers
Authorisation is REST's too. Each part of a query is read through the same handler REST uses for that entity, so a query cannot reach anything its credentials could not reach through REST. For a headless front end that means calling it from your server with the credentials of a backend user who can read only what the site publishes.
Limits and performance
Size every query yourself: it has no default limit (a part without first returns every matching record), no depth limit and no complexity limit. Each top-level part is one database query; every relation you select is then loaded for each record as the response is built. So BlogPost { categories { name } } over a thousand posts is a thousand category lookups, on top of the one query for the posts.
Put first on every part, including ones you expect to be small.
Select the fields you render and no others; every extra relation multiplies.
Keep nesting to one or two levels. If you need a third, ask whether a second request would be cheaper.
Measure with the real data volume, not a staging site with twelve posts.
Cache the result in your own application, keyed by the query text. The platform caches no API response, and a POST passes through HTTP caches uncached.
When to prefer REST
Any write, and any file upload. This endpoint cannot do either.
One entity type per screen. A REST GET with properties shapes the response just as well, and a GET can be cached by URL.
An established client. If your integration already has a REST client, error handling and retries, another surface is more to maintain for no gain.
When the documentation matters more than the shaping. REST has a generated reference for every endpoint; this endpoint has this page.
Orders, invoices and anything else not in the table above.
Paging with totals. A REST list returns totalCount; this endpoint returns only the records.