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 04 · Custom endpoints without writing framework code

Custom endpoints with synchronous jobs

A job with a web event, marked synchronous and ending in an HTTP response action, is an API endpoint defined in the admin, with no repository change. What the flag costs and rules out, who the endpoint runs as, shaping JSON replies and errors, a worked lookup and a worked write, and how to secure it.

Who this is for. The JavaScript developer who needs one answer the REST API does not give, and the integrations engineer whose partner system wants to call the site. Nothing here needs a change to your repository: the endpoint is defined in the admin.

A job with a web event trigger is an HTTP endpoint. Mark the job synchronous, end it with an HTTP response action, and the caller gets back the status, headers and body that action writes. That is a custom API endpoint built from the same actions as any other job: retrieve records, branch on a condition, call another system, create or update a record, reply. Everything below was checked against a live endpoint on a development site.

Web events as endpoints

In the admin, jobs are under Customization › Orchestration. Jobs were called hooks, and the route (/cdna-api/webhook/…) and some class names still use the old name. Create one, add a trigger of type web event, and the platform gives it a URL:

https://www.example.com/cdna-api/webhook/CVGW4-HD8W9-E0AZp-Keb1Y-tch0w-USqdE

The last part is the web event's slug. A new web event gets six blocks of five random characters, as above; you can change it, and it must be unique on the site. Anything after the slug is passed to the job as path parameters, so …/webhook/<slug>/stores/sydney reaches the same job with ['stores', 'sydney']. Query parameters, headers and the body are passed as well. The job does nothing until it is published: a new job is created unpublished, and an unpublished job's web event answers 404.

A job template reads the request from event:

SmartyTwigReturns
$event->getQueryParameter('sku')event.getQueryParameter('sku')One query parameter, or null
$event->getPathParameters()event.getPathParameters()The path segments after the slug, as a list
$event->getHeader('x-signature')event.getHeader('x-signature')One request header's values, as a list, or null. Name the header in lower case: the headers are stored with lower-cased names, so 'X-Signature' finds nothing
$event->getBodyData()event.getBodyData()The body, parsed, when it was sent as JSON or as a form. It is a read-only collection, not an array: read a key with ['name'], or call toArray(), since is_array() is false on it
$event->getBody()event.getBody()The raw body, for signature checks
$event->getMethod()event.getMethod()GET, POST and so on

The other job variables (runData, customData, siteData, query) are the same as in any job; Jobs and events covers them under “Templates in a job”.

The synchronous flag

One checkbox on the job, Synchronous execution, decides whether it is an API. Without it, a web event is asynchronous: the platform queues the job, answers at once with {"result":"success","data":{"batch":{…}}} (a batch is the API's name for a job run), and the actions run in the background a moment later. That suits a partner system that only needs to know the message arrived. With it, the job is synchronous: every action runs inside the request, and the caller waits for the reply. The admin's own help text says the same: synchronous execution is much slower, and applies only when the job is started by a web event or by another job; a job started by an entity event always runs in the background.

The cost is response time, and it is paid by whoever calls. A synchronous job with a lookup, a condition and a reply answered in about half a second, measured from Australia, including the connection. Each extra action adds its own time, and an HTTP request action adds the other system's.

What synchronous execution cannot do

  • No AI agent actions. An agent can pause a run to wait for a person, which a request cannot do. Saving an agent action on a synchronous job is refused with a message saying so.
  • Loops run one element after another, inside the request. On an asynchronous job a loop can hand its elements to background workers in parallel; on a synchronous one it cannot. A loop over a hundred records is a hundred iterations the caller waits for.
  • Nothing can be resumed. An asynchronous run that is interrupted waits and resumes later; a synchronous one runs to its end or to its first failure.
  • A failure is a 500. If any action fails, the caller gets HTTP 500 and a body naming the job and the run, not your error format. See shaping the response for how to keep predictable failures out of that path.

Allowed methods and authentication

A web event answers only the methods ticked on it; GET, POST, PUT and DELETE are ticked by default. Any other method is answered with the platform's own 404, {"result":"error","message":"Page or record not found"}, the same as a slug that does not exist. Untick everything the endpoint does not use.

A web event has one authentication setting. Off, which is the default, the URL is the only thing protecting the endpoint: anyone who has it can call it. On, the caller must be a signed-in backend user: an OAuth access token, an API key or an admin session. Site members do not count, so a member-only endpoint cannot use this flag.

Know what an open endpoint exposes before you publish one:

  • It runs as the job's author, not as the caller. The actions run with the permissions of the backend user who created the job, or with full admin rights if the job has no author. The caller's own identity does not limit what the job reads or writes.
  • It sees unpublished records. The Published filter is off for the whole of a synchronous run. The test endpoint for this page returned an unpublished page, with its name and path, to a request that carried no credentials. Filter on the publish date yourself if the answer must only include what the site has published.
  • It is in your front-end code. A URL called from a browser is readable by every visitor, so its slug is not a secret there. Treat such an endpoint as public and design it to return nothing a visitor should not see.

Securing a custom endpoint says what to add for each kind of caller.

Shaping the response

The HTTP Response action has four fields: the HTTP response code, the HTTP response headers (one Name: value per line), the body, and Stop execution, which ends the job after replying. All three text fields are templates. If a job runs more than one response action, the last one that succeeded is sent; use Stop execution so that is the one you meant.

Build JSON as a value and encode it. It is always valid, whatever the data contains. A structured result:

Template code
{$page = $runData->get('find-page')->record}
{$payload = [
  'id'   => $page->getId(),
  'name' => $page->getName(),
  'path' => $page->getFullPath()
]}
{$payload|json_encode}
{% set page = runData.get('find-page').record %}
{{ {
  'id': page.getId(),
  'name': page.getName(),
  'path': page.getFullPath()
}|json_encode }}

A structured error is the same thing with a different status code, 404 or 422, in a second response action on the other branch of a condition:

Template code
{$payload = ['error' => 'not_found', 'slug' => $event->getQueryParameter('slug')]}
{$payload|json_encode}
{{ {'error': 'not_found', 'slug': event.getQueryParameter('slug')}|json_encode }}

Do not type JSON into a Smarty body by hand. In Smarty a { starts a tag, so wrap a hand-typed body in {literal} tags, such as {literal}{"error":"not_found"}{/literal}, to keep the braces in the output and the intended status and body. Encoding a value avoids the question in both engines.

What the caller sees when something else goes wrong, so a client can tell the cases apart:

SituationStatusBody
Your response action ranIts status codeIts body
Wrong slug, unpublished job, or a method not ticked404{"result":"error","message":"Page or record not found"}
Authentication required and the caller is not a backend user403The platform's error envelope
An action failed500{"result":"error","data":{"batch":{…}}}, naming the job and the run's id
The job finished without a response action200{"result":"success","data":{"batch":{…}}}

The failure body carries the job's name, so name endpoint jobs as if the caller will read it.

A complete worked endpoint

A lookup a front end can call: given a page slug, answer with the page's id, name and path, or a 404. It is the endpoint this page was tested against.

  1. The job. Name it for what it answers, tick Synchronous execution, limit it to one site.
  2. The trigger. A web event with only GET ticked and authentication off.
  3. Action 1, Retrieve Records, with the identifier find-page. Entity: pages. Criteria: {"slug": "{$event->getQueryParameter('slug')}"} (on a Twig site, {{ event.getQueryParameter('slug') }}); each value in the criteria is a template, so the lookup uses the request. Limit: 1. The result is in runData as record (set when exactly one matched) and records.
  4. Action 2, Condition, with the identifier found, of type template. It matches when its template calls setMatching(true):
Template code
{if $runData->get('find-page')->record}{$result->setMatching(true)}{/if}
{% if runData.get('find-page').record %}{% do result.setMatching(true) %}{% endif %}
  1. In the condition's branch, an HTTP response: status 200, header Content-Type: application/json, the structured-result body above, stop execution on.
  2. In its else branch, an HTTP response: status 404, the same header, the structured-error body above.
  3. Publish the job, then call it:
Shell
curl -i 'https://www.example.com/cdna-api/webhook/CVGW4-HD8W9-E0AZp-Keb1Y-tch0w-USqdE?slug=apis'
HTTP
HTTP/1.1 200 OK
Content-Type: application/json

{"id":1521,"name":"APIs: REST, GraphQL and custom endpoints","path":"/developer-docs/apis"}
Shell
curl -i 'https://www.example.com/cdna-api/webhook/CVGW4-HD8W9-E0AZp-Keb1Y-tch0w-USqdE?slug=no-such-page'
HTTP
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"not_found","slug":"no-such-page"}

Every call is a job run, listed under Orchestration › Batch Logs with each action's status and output, which is where you look when the reply is not what you expected. The how-to has the same endpoint as a checklist.

A worked endpoint that writes

An endpoint can take a body and write. This one files a ticket from another system as a page: the caller posts JSON with a name and its own id for the ticket, the job creates an unpublished page under a parent the site chose, and the answer is 201 with the new page's id, or 422 when the name is missing. It runs on this documentation site, filing under its API sandbox page.

  1. The job. Synchronous execution ticked, limited to one site, named for what it does.
  2. The trigger. A web event with only POST ticked and authentication on. An endpoint that writes is never left open.
  3. Action 1, Condition, of type template, with the identifier has-name. It matches when the body carries a non-empty name:
Template code
{$body = $event->getBodyData()->toArray()}
{if isset($body['name']) && is_string($body['name']) && trim($body['name']) != ''}
  {$result->setMatching(true)}
{else}
  {$result->setMatching(false)}
{/if}
{% set body = event.getBodyData().toArray() %}
{% if body.name is defined and body.name is not iterable and body.name|trim != '' %}
  {% do result.setMatching(true) %}
{% else %}
  {% do result.setMatching(false) %}
{% endif %}
  1. In its branch, Action 2, Create Record, with the identifier create. Entity: pages. Its create data is a template that renders the new record as JSON, in the same shape as a REST create:
Template code
{$body = $event->getBodyData()->toArray()}
{$payload = [
  'parent'    => 1885,
  'revisions' => ['name' => trim($body['name']), 'layout' => 30, 'activeRevision' => true]
]}
{if isset($body['externalId'])}{$payload['externalId'] = $body['externalId']}{/if}
{$payload|json_encode}
{% set body = event.getBodyData().toArray() %}
{% set payload = {
  'parent': 1885,
  'revisions': {'name': body.name|trim, 'layout': 30, 'activeRevision': true}
} %}
{% if body.externalId is defined %}{% set payload = payload|merge({'externalId': body.externalId}) %}{% endif %}
{{ payload|json_encode }}
  1. Then an HTTP response: status 201, header Content-Type: application/json, stop execution on, and a body that reads the new id from the create action's run data:
Template code
{$body = $event->getBodyData()->toArray()}
{$payload = ['id' => $runData->get('create')->id, 'name' => trim($body['name'])]}
{$payload|json_encode}
{% set body = event.getBodyData().toArray() %}
{{ {'id': runData.get('create').id, 'name': body.name|trim}|json_encode }}
  1. In the else branch, an HTTP response: status 422, the same header, the body {"error":"name_required","message":"Send a JSON body with a non-empty name."} built as a value, stop execution on.
  2. Publish the job, then call it with credentials:
Shell
curl -i -X POST 'https://www.example.com/cdna-api/webhook/api-sandbox-add-record' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{"name": "Filed through a job", "externalId": "ticket-4021"}'
HTTP
HTTP/1.1 201 Created
Content-Type: application/json

{"id":1922,"name":"Filed through a job"}

The new page was there, unpublished, under the parent, with externalId ticket-4021. The call took under a second. The same endpoint's other answers:

RequestStatusBody
A body without name422{"error":"name_required","message":"Send a JSON body with a non-empty name."}
No credentials403{"result":"error","message":"Access to the specified record has been denied. Please contact your administrator if you think this is a mistake."}
A GET, not ticked on the web event404{"result":"error","message":"Page or record not found"}

Four things this endpoint shows:

  • getBodyData() is not an array. It is a read-only collection, so an is_array() check on it is false. The first version of this job tested it that way, and answered 422 to every request. Call toArray() first, as above.
  • A job cannot create custom entity records. The Create record action takes pages, blogs and posts, books and their units and pages, FAQ pages and questions, categories, tags, global components, files and folders, products with their variants and descriptions, rewrites, people, users and groups, and a few others; custom entity types are not among them. To write one from a job, call the REST API from an HTTP request action, or have the job create a record type it can.
  • The page is written as the job's author. The caller's credentials let it in; they do not limit what the job writes (above).
  • A retried call files the ticket twice. Creates are not safe to repeat (Retries). To make this endpoint safe, start the job with a Retrieve records action on externalId, and answer with the page already there when it finds one.

When a create fails, the run's log in Batch Logs holds the parsed create data, which usually shows why.

Securing it

Protect an endpoint by who calls it: tick authentication for any endpoint your own servers call; check a signature for a partner that signs its requests; treat anything a browser calls as public. Securing a custom endpoint has the signature check in both engines, what to add yourself, and the signs that an endpoint has outgrown a job.

Go deeper

Was this page helpful?

Your answer goes to the docs team.