Docs
Dashboard

Concepts

Content types and entries

Singletons, collections, the ten field types, and what a response looks like.

A content type is a shape you define: a name, a kind, and a list of fields. An entry is one filled-in instance of that shape.

Singleton or collection

A type is one of two kinds, and the kind is fixed once the type exists.

Singleton. There is exactly one of it. A homepage hero, a site footer, an “about” page. GET /v1/content/homepage returns its values object directly, with no array and no paging, because there is nothing to page through.

Collection. There are many. Team members, case studies, pricing tiers. GET /v1/content/team-member returns a paginated array.

The field types

TypeHoldsFilterable with
stringA single lineeq ne contains starts_with in is_null
textMultiple lines, no formattingeq ne contains starts_with is_null
richtextA formatted documentis_null
numberA numbereq ne gt gte lt lte in is_null
booleanTrue or falseeq ne is_null
dateA dateeq ne gt gte lt lte is_null
selectOne of a fixed listeq ne in is_null
urlA linkeq ne contains starts_with is_null
imageOne pictureis_null
gallerySeveral picturesis_null
referenceOne entry of another collectioneq ne in is_null
reference_listSeveral entries, in ordereq (contains) in (contains any) is_null
linkOne piece of built-in contenteq ne in is_null
link_listSeveral of them, in ordereq (contains) in (contains any) is_null

The three unfilterable types are deliberate. richtext is a document tree and image/gallery are asset references. “Does this body contain the word X” is a search feature with its own index, not an equality test on JSON, and pretending otherwise would ship a filter that quietly scans every row.

reference and reference_list filters compare entry ids, and they are indexed: “every product in this category” is one indexed query, not a scan. is_null asks whether the field is set at all, not whether its target is published.

An operator a field type does not support is refused, not silently ignored. A contains on a boolean is a mistake, and answering it with “0 results” hides the mistake.

What a response looks like

GET /v1/content/{type}
{
  "data": [
    {
      "id": "ent_2c8RkXqGn4wVtYbMhJpDs",
      "name": "Ada Lovelace",
      "role": "engineer",
      "joined": "2024-03-01",
      "photo": {
        "url": "https://cdn.example.com/…",
        "alt": "Ada at her desk",
        "width": 1600,
        "height": 900,
        "content_type": "image/jpeg"
      }
    }
  ],
  "meta": {
    "type": "team-member",
    "kind": "collection",
    "release": { "id": "rel_…", "name": "Launch", "default": true },
    "limit": 20,
    "has_more": false,
    "next_cursor": null,
    "next_offset": null
  }
}

Scalar fields appear as themselves. An image resolves to an object; a gallery to an array of them; a richtext to { html, json, plain, toc }; a reference to the linked entry itself. You never receive a raw asset id, and you only receive a raw entry id one level down (see Linking content).

What each field reads as, and what it writes as

Most fields read back exactly as you wrote them. Five do not, because a read resolves them into something useful and a write names the thing being pointed at. Send the write form.

TypeReads asWrite back
image{ id, url, alt, width, height, content_type }the med_… id
galleryarray of those objectsarray of med_… ids
richtext{ html, json, plain, toc }the document object, which is what json holds
reference · reference_listthe linked entry, resolvedthe ent_… id, or an array of them
link · link_list{ id, type, title, slug, url }the target’s id, or an array of them

Every resolved object carries the id you would write back, so a read is enough to reconstruct a write. It is still easier not to: PATCH sends only the fields you are changing and leaves the rest alone.

Linking content

A type can point at another with a reference field (one entry) or a reference_list field (several, in order). This is how a product gets a category, a page gets a menu, a recipe gets related recipes, and a team member gets a department, without typing the same names into a select on every type and watching them drift the first time one is renamed.

PUT /v1/schema/content-types/product
{
  "name": "Product",
  "kind": "collection",
  "display_field": "title",
  "fields": [
    { "key": "title", "label": "Title", "type": "string", "required": true },
    { "key": "category", "label": "Category", "type": "reference",
      "options": { "content_type": "category" } },
    { "key": "related", "label": "Related", "type": "reference_list",
      "options": { "content_type": "product" } }
  ]
}

options.content_type names the collection the field links to, by slug. It must exist in the same release, or be the type being defined (a type may link to itself, as related does above). A singleton cannot be a target: it has one entry, and a link chooses between several. The target is fixed for the life of the field, like its type, because every entry already stores ids of that collection under it.

Writers see none of this. In the dashboard the field is a picker that lists the target collection’s entries by their titles, which is why setting display_field on the target matters: it is what makes a link readable.

What you write, and what you read back

A write stores ids:

POST /v1/content/product/entries
{ "values": { "title": "Pegasus", "category": "ent_QYa7…", "related": ["ent_Dxb7…"] } }

Every id is checked: it must name an entry that exists, in this project, in this release, of the target type. A wrong one is refused by name (unknown_entry, entry_wrong_type, entry_wrong_release), an entry cannot link to itself (self_reference), a list cannot repeat an id, and a list holds at most 100.

A read resolves them, one level deep:

{
  "id": "ent_Dxb7…",
  "title": "Pegasus",
  "category": {
    "id": "ent_QYa7…",
    "type": "category",
    "name": "Running",
    "parent": "ent_LS5x…"
  },
  "related": []
}

category is the linked entry as GET /v1/content/category/{id} would serve it, with its images resolved. Inside it, parent is a reference field of the category type, and it stays an id: resolution stops at one level, so a page costs two extra queries however many links it carries and two types that point at each other cannot loop. Fetch deeper by that id when you need it.

When the target is not published

The public API serves published content. A link to a draft, an unpublished entry or a deleted one reads as null for a reference and is omitted from a reference_list, the same rules image and gallery follow. Writing such a link is allowed, so an import can create products before their categories; the write response carries a warnings array naming them.

Both directions are visible

  • What an entry points at is the resolved value.
  • What points at an entry is GET /v1/content/{type}/{entryId}/referenced-by.
  • The whole graph is relations in /v1/introspection, generated from the field definitions.
  • In the dashboard, an entry shows its links as titles and a “Linked from” list of everything that points at it.

Deleting something that is linked

Refused, until nothing points at it. The refusal names the entries that do (entry_referenced), and the dashboard shows them before anyone tries. The same rule holds for a type another type’s field targets (content_type_referenced). Unpublishing is always allowed; it is reversible, and the read rules above already cover it.

Two consequences worth knowing before you build on links:

  • Webhooks do not cascade. Changing a category fires content.entry.updated for the category, not for every product in it. To invalidate the products, call referenced-by on the category, or key your cache on the ids a page embeds.
  • Cloning a release remaps every link. Every entry in a clone gets a new id, and every reference is rewritten to the clone’s ids, so a clone is self-contained and never points back at the release it came from.

Linking to your blog and knowledge base

reference links your own entries to each other. link points at the built-in modules: a blog article or category, an author, a knowledge base article, collection or section.

PUT /v1/schema/content-types/menu-item
{
  "name": "Menu item",
  "kind": "collection",
  "display_field": "label",
  "fields": [
    { "key": "label", "label": "Label", "type": "string", "required": true },
    { "key": "goes_to", "label": "Goes to", "type": "link",
      "options": { "target": "blog_category" } },
    { "key": "featured", "label": "Featured reading", "type": "link_list",
      "options": { "target": "blog_article" } }
  ]
}

options.target is one of blog_article, blog_category, author, kb_article, kb_collection, kb_section. It is fixed for the life of the field, like the field’s type.

Why not just use a url field

Because a typed-in address is a copy, and copies go stale.

A menu built from url fields holds /blog/category/cloud-hosting. Rename that category and the menu still holds the old path: every visitor who clicks it gets a 404, and nothing anywhere warns you. The link and the thing it points at have no relationship, so nothing can keep them in step.

A link field stores the id. The title and slug are looked up on every read, so:

Category renamed "Cloud Hosting" → "Cloud Platform"
Menu entry: not touched, not republished
Next read:  { "title": "Cloud Platform", "slug": "cloud-platform", … }

The menu followed, because it never held the name in the first place.

Use url for addresses you do not own, like a link to somebody else’s site. Use link for anything inside this project.

What you write, and what you read back

A write stores ids, which you get from the module’s own list endpoint (GET /v1/blog/categories, /v1/authors, /v1/blog/articles, /v1/kb/articles, /v1/kb/tree):

POST /v1/content/menu-item/entries
{ "values": { "label": "Guides", "goes_to": "cat_Ch8Hg…",
              "featured": ["art_6cRpC…"] } }

A read resolves them, and every target resolves to the same five keys, so one template renders a menu whatever it points at:

{
  "label": "Guides",
  "goes_to": {
    "id": "cat_Ch8Hg…",
    "type": "blog_category",
    "title": "Cloud Hosting",
    "slug": "cloud-hosting",
    "url": "/v1/blog/categories/cloud-hosting"
  },
  "featured": [ { "id": "art_6cRpC…", "type": "blog_article", "…": "…" } ]
}

url is where to fetch it from this API. Your own site’s route is yours to build, usually from type and slug.

A link to something deleted reads as null (or is omitted from a list), and an article that is not published behaves the same way, exactly as reference and image do. So a menu never contains a hole for a frontend to crash on.

What you can build with this

Only published content is served

/v1 serves published entries. A draft is invisible here and visible in the dashboard. That is what lets somebody work on a page for a week without it appearing on your site.

Fetching an entry that exists but is not published answers entry_not_published or content_not_published, which says so rather than pretending the entry does not exist.

Templates

The dashboard offers templates when you create a type: a gallery, a team member, a pricing tier, a case study and others. Picking one copies the fields into your project.

A copy, not a link, so improving a template later never changes a type somebody is already using, and you are free to rename, delete and add fields the moment it lands. The template is a starting point, not a parent.

Writing from code

Both writes need a secret key.

POST /v1/content/{type}/entries
{ "values": { "name": "Ada Lovelace", "role": "engineer" }, "publish": true }
PUT /v1/content/{type}

Replaces a singleton’s draft, creating its one entry on first save.

Omit publish and you get a draft. That is the right default for a migration script or an agent, because content generated by a machine should usually be reviewed before it is public.

The type itself can be created from code too, with PUT /v1/schema/content-types/{type}, which is what lets an agent set a project up end to end.

Updated at, Sunday, September 20, 2026