FOUNDATION

Content API

1. Create an API key

An administrator creates keys in the site's admin under Settings → CMS → API keys → New key. There are three templates to choose from:

TemplateWhat the key can do
Read onlyRead the schema, the generated contract, published content of every type, taxonomy terms and menus.
Post to chosen typesRead and write only the types you choose, and upload media. Handy for an automation that publishes one kind of content.
Read and writeFull access to content, terms (including their page copy), menus and media.

Keys made before menus came to the API (October 2026) can't read or change them. To add menus, edit the key and choose its template again.

You'll only see the key once, so store it somewhere safe, like a secrets manager. Don't put it in a web page, a repository or your logs. You can edit, rotate, revoke or delete it later from the same screen.

That screen also has a Copy instructions for AI button. It copies a briefing you can hand to an AI agent, with this site's address already filled in.

2. Base URL and authentication

Base URL   https://hq.<your domain>/api/v1
Header     Authorization: Bearer <API key>

GET /api/v1/ping doesn't need a key, so you can use it for uptime checks.

3. Good first calls

CallWhat it's for
GET /api/v1Checks the key works. Returns the site, what the key is allowed to do, and the endpoints.
GET /api/v1/helpLists every endpoint on this site, including a row for each content type, and shows whether your key can call it. It's generated live, so it's always current. This page is the overview.
GET /api/v1/schemaReturns the content model: every type, and each field's id, type and validation rules. You write values by field id.
GET /api/v1/contractReturns the generated TypeScript contract for the site's model, as plain text.

4. Reading content

GET /api/v1/content/{type}?limit=50&offset=0      published entries, whole records
GET /api/v1/content/{type}/{slug}                  one entry, with its status

Entries always come back complete, including their public path and parent. limit can be anything from 1 to 100. If a page is full, the response includes next, which is the offset to ask for next.

Add ?status=draft to list drafts instead (oldest first). That needs the content:write:{type} capability for the type. Post to chosen types and Read and write keys have it, Read only keys don't. The same keys can also fetch a single draft or scheduled entry by its slug. A Read only key only ever sees published entries.

5. Writing content

POST /api/v1/content/{type}
{
  "slug": "spring-open-day",            // optional
  "status": "draft",                    // draft (default), published or scheduled
  "publishedAt": "2026-11-01",          // a past date backdates the entry; a future date with "scheduled" publishes it then
  "parent": null,                       // to nest it under another entry of a hierarchical type (slug or id)
  "values": { "fld_title": "Spring open day", "fld_body": "<p>…</p>" }
}

PUT /api/v1/content/{type}/{slug}       // merges the values you send, after saving a revision
  • New entries are saved as drafts unless you send "status": "published". A successful create returns 201 with the entry's id, slug, status and path.
  • Updates are merged. Any field you don't send keeps its current value, and sending null clears a field. Status and publishedAt only change if you include them.
  • If the slug already exists for that type, you'll get a 409. Use PUT to update that entry instead.
  • Entry pickers accept entry ids or slugs. Category pickers accept term ids, slugs or labels. If you send a label that doesn't exist yet, the term is created and listed in termsCreated.
  • For image fields, send the key you get back from a media upload (see below).
  • Moving an entry to a new parent sets up 301 redirects from its old URL and from everything nested under it.
  • A few fields belong to the platform and can't be written with a key. The response tells you which ones.

6. Uploading media

POST /api/v1/media?filename=front-door.jpg&alt=The%20front%20door&description=Opening%20day%2C%20March%202026&path=gallery
Content-Type: image/jpeg
<the file's bytes>

→ { "id": "…", "key": "ab/cd/abcd…", "mime": "image/jpeg", "bytes": 182345, "width": 2400, "height": 1600 }

The key in the response is what goes in an image field. filename is required, and so is a Content-Length header. JPEG, PNG and WebP uploads also get resized copies at the sizes the site uses, plus a thumbnail, just like uploads made in the admin.

The other three parameters are optional. alt describes the image for people who can't see it. description is a note for the media library, such as who or what is in it and where and when it was taken, up to 2,000 characters. path puts the file in a folder.

7. Taxonomy terms

GET /api/v1/terms/{taxonomy}
GET /api/v1/terms/{taxonomy}/{slug}
PUT /api/v1/terms/{taxonomy}/{slug}    { "label", "description", "body", "seoTitle", "seoDescription", "image",
                                         "shareTitle", "shareDescription", "shareImage", "faq", "noindex", "position" }

The list comes back with the taxonomy's kind (category, tag or attribute) and its settings: { required, defaultTerm, archive, routePattern }, so you know whether its terms have pages and where they live.

A term comes back as { id, slug, label, parent, description, body, seoTitle, seoDescription, image, shareTitle, shareDescription, shareImage, faq, noindex, position, updatedAt, used }. An empty string just means nobody has written that copy yet.

When you write a term's page copy, the fields you send are merged with what's stored, and null or "" clears a field. seoTitle and shareTitle can be up to 120 characters, seoDescription and shareDescription up to 320. body is HTML, and image and shareImage take a key from a media upload. faq is up to 20 questions as [{ "q", "a" }], shown on the term's page and given to search engines as its answers. noindex (true or false) keeps the page out of search, and position (a whole number, or null for alphabetical) sets the term's place in its list. You can't change a term's slug or where it sits in the tree through the API. That's done in the admin.

8. Menus

GET /api/v1/menus                 every menu, by location
GET /api/v1/menus/{location}      one menu, for example header
PUT /api/v1/menus/{location}      replace that menu

Each menu comes back as a tree. An item has an id, a label (null when a link to a page uses the page's own title), a link, newTab and its children. A link is one of these:

{ "kind": "entry", "id": "…", "type": "page", "slug": "about", "path": "/about/", "published": true }
{ "kind": "url", "url": "/pricing/" }
{ "kind": "term", "id": "…" }
{ "kind": "heading" }

The children of a header link are its drop-down, and each child is a column. A heading with links under it is an ordinary sub-menu. A heading can also carry content, which turns it into one of three other kinds of column:

ColumnIts content
A list from a tag{ "kind": "feed", "type": "service", "termId": "…", "order": "order", "limit": 6, "show": "names" }. order is order (the type's own order) or newest, limit is 1 to 24, and show is names, summaries or pictures. termId is optional. Links placed under it appear after the list.
Text{ "kind": "text", "body": "Most questions are answered online.", "button": "See the options", "url": "/support/" }. The button is optional.
Card{ "kind": "card", "text": "Renew or change a service.", "button": "Log in", "url": "https://portal.example.com", "icon": "portal" }. The heading's label is the card's title.

Buttons on text and cards can also take newTab and highlight. A highlighted button stands out, in whatever way the site's design decides.

A PUT replaces the whole menu with the items you send, in the same shape, so read the menu first, change what you need and send all of it back. To link to a page, give its id, or its type and slug. Menus go three levels deep at most. If something wouldn't work on the site, such as a button with nowhere to go or a list with no type, nothing is saved and you get a 422 that lists every problem. Links to draft or deleted pages are saved, and listed in problems so someone can put them right.

9. Errors

Errors come back as JSON with an error message explaining what went wrong.

StatusMeaning
400The request couldn't be read, for example because of malformed JSON or a missing part.
401The key is missing, wrong or revoked. The response is identical in all three cases, so it doesn't reveal which.
403The key isn't allowed to do that. The need field in the response names the capability it's missing. You'll also get a 403 if you try to write one of the platform's own fields.
404That type, entry, term or menu doesn't exist on this site.
405That method isn't supported at that URL.
409The slug is already taken for that type (use PUT to update it), or the site isn't ready yet to store what you sent.
411A media upload was sent without a Content-Length header.
413The file is bigger than the platform allows.
422A value failed a field's validation, you used a field id that isn't on that type, or a menu wouldn't work on the site. The response lists them.
503The site or its file storage can't be reached right now. Try again in a moment.

10. Putting it together

KEY="…"; SITE="https://hq.example.co.uk/api/v1"

curl -s "$SITE" -H "Authorization: Bearer $KEY"                 # check what this key can do
curl -s "$SITE/schema" -H "Authorization: Bearer $KEY"          # look up the field ids for "post"

KEY_IMG=$(curl -s -X POST "$SITE/media?filename=cover.jpg&alt=Our%20new%20studio" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: image/jpeg" --data-binary @cover.jpg | jq -r .key)

curl -s -X POST "$SITE/content/post" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{\"values\": {\"fld_title\": \"We moved\", \"fld_cover\": \"$KEY_IMG\"}}"   # saved as a draft