Guide 06 · Developer docs

The APIs: REST, GraphQL and custom endpoints

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 05 · Credentials and access

Authentication and authorisation

Most integration bugs are the wrong credential, not a wrong implementation. The five strategies in the order the platform tries them, which one each kind of client should use, API keys, a complete OAuth flow, the CSRF 403, visitor routes, and why an admin key proves nothing about production.

Who this is for. All three API readers, and above all the integrations engineer. Most integration bugs on Core dna are the wrong choice of credential, not a wrong implementation of the right one: a script tested with an admin key that meets a 401 in production, a browser call that works from the admin and 403s from the site. The reference for each credential's header is on the API reference's Authentication page; this page is about choosing.

The five strategies, in the order they are tried

For every request the platform looks for a credential in this order and uses the first it finds. It does not fall back: a request with a bad Authorization header fails, even if it also carries a valid session cookie.

  1. A login token in the query string, ?loginToken=…. Checked before anything else.
  2. No Authorization header: the session cookie. What a browser sends after someone signs in on the site.
  3. Authorization: Bearer CDNA-A-…: an OAuth access token, recognised by its CDNA-A- prefix.
  4. Authorization: Bearer … with any other value: an API key.
  5. Authorization: Basic …: a backend user's username and password, base64-encoded as username:password.

The header's name is matched in any case, so a CDN or proxy that lower-cases it to authorization changes nothing. The scheme is not: Bearer and Basic must be written exactly so. What each mistake answers, from a live site:

Request headerStatusmessage
none401(empty body, header x-reason: not logged in)
Authorization: Bearer <unknown key>403Authentication failed
Authorization: bearer <key> (lower case)403Authorization method not supported; use Bearer or Basic
Authorization: Token <key>403Authorization method not supported; use Bearer or Basic
Authorization: Basic <no colon>403Basic authorization requires a username and password separated by a colon
Authorization: Bearer CDNA-A-<unknown>403Token doesn't exist or is not valid

A 401 means no credential was found; a 403 means one was found and refused. That distinction is the first thing to read in a failing integration.

Choosing one

ClientUseWhy
A script on the site's own pages (same origin)The session cookie, plus the CSRF token on writesThe browser already has it. There is nothing to store in the page
Your own server: a headless front end, a sync job, a CI taskOAuth, authorization code, signed in once as a backend user made for the integration, then its refresh tokenLimited to that user's permissions, and the token that travels with each request expires in four hours. An API key on the same user is the older way and still works
A mobile or desktop app acting for a staff memberOAuth, authorization code with PKCEThe person signs in on the site; the app never sees a password, and its token expires in four hours
A third-party service that users connect to the siteOAuth, authorization code, a registered clientEach person grants access themselves and can revoke it; you never hold their password
A one-off script run by a developerAn API key, deleted afterwardsBasic credentials work but put a real password in a shell history
A link that signs someone in (a reset or confirmation email)A login tokenMade for that job: limited uses and an expiry

Two things never work, whatever the credential: a browser on another origin reading a response (the platform sends no cross-origin headers), and a signed-in site member calling anything but the visitor routes (below).

API keys

Issuing. A key belongs to a backend user. In the admin, open the user's record and add a key under API Keys. Create a user for each integration rather than using a person's account: the key then does what that user may do and no more, and removing the integration is deleting one user.

Scoping. Two limits apply. The user's permissions decide what the key can read and write, exactly as if that user were signed in to the admin. A list of IP addresses on the key, if you set one, refuses any request from elsewhere with IP <address> not permitted for this API key. Set it for any key used from a server with a fixed address.

Rotating. Add the new key, deploy it, and watch the old key's last used date on the user's record stop moving; then delete the old one. There is no expiry to wait for: a key works until it is deleted.

What a key sees. A key is a backend user, so it sees what an editor sees through the API, which is not what a visitor sees on the site. It is refused nothing a template would hide: form submissions, people, orders. Unpublished records are the exception: a list leaves them out unless the request adds retrieveUnpublished=1 or the site's setting to show them is on, while a request for one record by its id returns it either way. An integration that behaves differently from the site is usually reading more than the site shows, not less.

API keys have no prefix of their own. Anything after Bearer that does not start with CDNA-A- is looked up as a key.

OAuth clients

Every site is an OAuth 2 authorization server and publishes its configuration at /.well-known/oauth-authorization-server. From a live site:

JSON
{
  "issuer": "https://www.example.com",
  "authorization_endpoint": "https://www.example.com/oauth/authorize",
  "token_endpoint": "https://www.example.com/oauth/token",
  "revocation_endpoint": "https://www.example.com/oauth/access-token",
  "response_types_supported": ["code", "token"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "scopes_supported": ["offline", "profile", "mcp"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
  "registration_endpoint": "https://www.example.com/oauth/register"
}

Registering a client. In the admin, OAuth clients are added under the OAuth module, where each gets a client id, a secret (which can be regenerated) and its allowed redirect URIs and grant types. A site can also accept dynamic client registration, a POST to /oauth/register, which tools such as MCP clients use to register themselves; it is a per-site setting, and registration_endpoint appears in the metadata only when it is on. Read the metadata rather than assuming.

The flow, with PKCE. The app makes a random code_verifier, hashes it into a code_challenge, and sends the person to sign in:

https://www.example.com/oauth/authorize?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fapp.example.org%2Fcallback
  &scope=offline
  &state=RANDOM_STATE
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256

(Shown on several lines; it is one URL.) The person signs in on the site and consents; the site redirects to redirect_uri with ?code=…&state=…. Check that state is the one you sent, then exchange the code:

Shell
curl https://www.example.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d redirect_uri=https://app.example.org/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d code_verifier=THE_VERIFIER
JSON
{
  "token_type": "Bearer",
  "expires_in": 14400,
  "access_token": "CDNA-A-…",
  "iss": "https://www.example.com",
  "scope": "offline",
  "refresh_token": "…"
}

The access token lasts four hours (expires_in is 14400 seconds) and is sent as Authorization: Bearer CDNA-A-…. A refresh token comes back when the client may use the refresh grant; ask for the offline scope. An expired access token answers 403 "Token doesn't exist or is not valid", not 401, so a client should treat that 403 as the signal to refresh once and retry.

Keep the refresh token from every answer. A refresh token can be exchanged fifty times and expires sixty days after it was issued, however often it is used. Every refresh answer carries a refresh_token: the same one for the first forty-nine exchanges, and on the fiftieth a new one, valid for another sixty days, while the old one stops working. A client that stores the refresh token from each answer can go on refreshing for as long as it reaches fifty exchanges within sixty days, which any integration that refreshes daily does; one that keeps only the first token is locked out after its fiftieth refresh. Exchange the refresh token before the access token expires:

Shell
curl https://www.example.com/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=THE_REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID

A confidential client (one with a secret, running on a server) adds client_secret to the body, beside client_id. Send both in the body even though the metadata lists client_secret_basic: the token endpoint reads the client's id and secret from the body only, and an Authorization: Basic header is taken as a backend user's username and password, refused with a 403 page before the OAuth server sees it. Many OAuth libraries default to Basic, so set this explicitly. An authorization code can be used once: a second use is refused with invalid_grant and revokes the tokens the first use issued, so never retry a code exchange blindly.

The token acts as the person who signed in. If that person is a site member, the token meets the same refusal a member's session does on everything outside the visitor routes. OAuth is how a staff member's tools reach the API, not a way round the member restriction.

CSRF

The symptom: a POST that works when you try it from the admin, or with an API key, answers 403 with Missing or invalid CSRF token when your script sends it from the site. The token is checked only when someone is signed in through the session, the method writes, and the request has no Authorization header; so it concerns scripts on the site's own pages and never a server with an OAuth token or an API key. Templates receive it as _csrfToken, and a script sends it as the X-Csrf-Token header. The CSRF token, and the 403 on a POST has the routes that check it, the template code, and the fix for a cached page.

Visitor routes

Without a credential, the API answers 401. A handful of routes are open anyway, because visitors must reach them signed out: the cart, checkout (guest checkout is a mode of the checkout, not a separate API), payment methods, form submission, sign-in, registration and password reset. Web events are open by default, as covered in Custom endpoints.

A warning for anyone writing a route or a job that serves callers who are not signed in: those routes skip the platform's sign-in check entirely, so the code behind them must not assume there is a user. A synchronous job, for example, runs as its author whoever called it; $event tells you who called only if the request carried a credential.

A site member and a backend user

The same endpoint answers them differently, which catches out anyone who tests with an admin key:

RequestBackend user (OAuth token or API key)Site member (session)
GET /cdna-api/pages/pagesPublished pages; unpublished too with retrieveUnpublished=1404 by default
GET /cdna-api/users/accountThe key's own userThe member's own profile
POST /cdna-api/graphqlAnswered404 by default

A member is refused with whatever the site answers to a signed-in visitor who may not see something: a 404 by default, or a 403 where the security setting Access Denied (Authenticated) is set to Show Forbidden error (403). So a member's refusal looks exactly like a route that does not exist.

Decide which column production traffic is in before writing the client. A headless front end's server is the backend-user column. A script on the site's pages, run by visitors, is the member column, or signed out altogether, and can use only the visitor routes. A test that passes with an admin key proves nothing about either.

Login tokens

A login token signs a person in from a link: https://www.example.com/any/page?loginToken=…. The platform issues them itself for password reset, and an administrator can create one through POST /cdna-api/users/login-token, which only admins may call. Each token names its user and can carry a maximum number of uses, an expiry, a time it stays valid after first use, and a redirect. A token in a URL ends up in browser history and in the Referer of the next request, so give every token you create a short expiry and a single use.

Go deeper

Was this page helpful?

Your answer goes to the docs team.