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
| Type | Holds | Filterable with |
|---|---|---|
string | A single line | eq ne contains starts_with in is_null |
text | Multiple lines, no formatting | eq ne contains starts_with is_null |
richtext | A formatted document | is_null |
number | A number | eq ne gt gte lt lte in is_null |
boolean | True or false | eq ne is_null |
date | A date | eq ne gt gte lt lte is_null |
select | One of a fixed list | eq ne in is_null |
url | A link | eq ne contains starts_with is_null |
image | One picture | is_null |
gallery | Several pictures | is_null |
reference | One entry of another collection | eq ne in is_null |
reference_list | Several entries, in order | eq (contains) in (contains any) is_null |
link | One piece of built-in content | eq ne in is_null |
link_list | Several of them, in order | eq (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
/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.
| Type | Reads as | Write back |
|---|---|---|
image | { id, url, alt, width, height, content_type } | the med_… id |
gallery | array of those objects | array of med_… ids |
richtext | { html, json, plain, toc } | the document object, which is what json holds |
reference · reference_list | the linked entry, resolved | the 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
relationsin/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.updatedfor the category, not for every product in it. To invalidate the products, callreferenced-byon 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.
/v1/content/{type}/entries { "values": { "name": "Ada Lovelace", "role": "engineer" }, "publish": true }
/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