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 01 · The REST API

Responses and errors

Every JSON answer has the same envelope, and the status codes mean the same thing everywhere. What each code tells a client to do, the 401 that has no body, the 403 that means “refresh your token”, and how to put a validation error next to the field that caused it.

Who this is for. Anyone writing the part of a client that reads responses. The full table is on Requests and responses; this page is about acting on it.

The envelope

A JSON answer has result, success or error. A success carries data, shaped by your selector, and a list adds totalCount. A write adds a message such as “The Page has been saved successfully.”. An error carries a message, and a failed validation also validationErrors.

Branch on the HTTP status first and on result second. A handful of routes answer outside the envelope and say so on their reference page: a file upload, the country lookups, a form submission's status, a web event (whose body is whatever its job returns).

What each status tells a client to do

StatusMeansDo
200Done. Writes answer 200 too, never 201 (registering a member is the one exception)Read data; on a read of a list, check it against what you filtered for (Confirming a request did what you asked)
400A parameter is malformed, such as a limit that is not a numberFix the request; do not retry it as it is
401No credentials. The body is empty; a header x-reason: not logged in says whyAuthenticate. Do not try to parse the body
403A credential was refused: an access token that has expired or been revoked (“Token doesn't exist or is not valid”), or a cookie session's write without its CSRF tokenOn the token message, refresh the token once and retry; otherwise fix the request
404No such route or no such record: both say “Page or record not found”. By default it is also the answer to a caller who may not do this, such as a site member on an admin route; a site setting, Access Denied (Authenticated), can make that a 403Check the path against the reference, then the caller's permissions
409A conflict, such as continuing a form submission that is already completeStart again from fresh data
422Validation failed: nothing was savedShow validationErrors beside the fields (below)
500The server could not complete the requestLog it with the request; retry only a read

Form submissions add 202 (a payment is pending) and 207 (stored, but some answers failed the form's rules, so the submission is not complete; it carries validationErrors like a 422); Submitting a form through the API covers them.

The expired token is a 403, not a 401. A client that refreshes only on 401 will treat an expired token as a permission problem and give up. Match the message, refresh once, and stop if the retry fails the same way. And a 404 on a record you know exists usually means the caller may not see it, not that it is gone.

Ask for JSON

Send Accept: application/json on every request. With it, errors come back as JSON: a malformed parameter, for example, is a 400 that names the parameter.

Putting a validation error beside its field

The error integrators meet most is a 422 with validationErrors, keyed by the name of the field you sent. Each value is a list, because a field can fail more than one rule.

JSON
{
  "result": "error",
  "validationErrors": {
    "age": [
      { "message": "Cannot be empty", "code": "bb203946-d5ab-84b9-2afc-41eff2dfe753", "propertyPath": "age" }
    ]
  },
  "message": "Forms\\Models\\Api\\InputNumber.age: Cannot be empty"
}

Read the keys of validationErrors, not the message. For a form submission each key is the form field's name, so it maps straight back to <input name="age">; for a record it is the property you sent, such as code or person.email. The message joins every failure into one string with internal class names in it: log it, never show it.

JavaScript
// After a fetch() that wrote a record or submitted a form.
// A form submission reports failed answers with a 207, which response.ok counts as success.
const body = await response.json();
if ((response.status === 422 || response.status === 207) && body.validationErrors) {
  for (const [field, errors] of Object.entries(body.validationErrors)) {
    const input = form.elements.namedItem(field);
    if (input) input.setCustomValidity(errors.map(e => e.message).join(' '));
  }
  form.reportValidity();
}

Clear the custom validity on each field's next input event, or the browser keeps refusing the form after the user has fixed it. A key that matches no field (person.email on a checkout form whose input is named email) belongs in a message at the top of the form rather than nowhere. Writing the client layer puts this in the one module that owns every request.

Go deeper

Was this page helpful?

Your answer goes to the docs team.