API reference
Content
Reading and writing your own content types with the /v1/content and /v1/schema endpoints.
The endpoints for the types you define. For the blog, knowledge base and authors, see Built-in modules.
Fetch a type
/v1/content/{type} {type} is the type slug, exactly as content_types[].slug reports it in
introspection.
A singleton answers with its values object. A collection answers with a paginated array.
| Parameter | |
|---|---|
limit | Collections only. 1–100, default 20. Refused outside that, not clamped. |
cursor | Collections in their default order. Opaque. |
offset | Collections when sort is used. |
where[…] | Filter by the type’s own fields. See filtering. |
sort | A field key, or created_at / published_at / updated_at. - prefixes descending. |
ids | Comma-separated ent_… ids, at most 50. Returns exactly those, in the sort order. |
release | A rel_… id. Omit for the default release. |
media_urls | permanent to refuse expiring image URLs. |
curl "https://api.zevcontent.net/v1/content/team-member\
?where[role]=engineer&sort=-joined&limit=10" \
-H "Authorization: Bearer $ZEVCONTENT_KEY"
{
"data": [
{
"id": "ent_2c8RkXqGn4wVtYbMhJpDs",
"values": { "name": "Ada Lovelace", "role": "engineer", "joined": "2024-03-01" },
"published_at": "2026-08-14T09:00:00.000Z",
"updated_at": "2026-08-20T11:12:00.000Z"
}
],
"meta": {
"type": "team-member",
"kind": "collection",
"release": { "id": "rel_…", "name": "Launch", "default": true },
"limit": 10,
"has_more": false,
"next_cursor": null,
"next_offset": null
}
}
Fetch one entry
/v1/content/{type}/{entryId} By its ent_… id. Use this for a detail page when you already hold the id;
filter the listing instead when you hold a slug-like field value.
Takes release and media_urls.
reference and reference_list fields come back as the linked entries,
resolved one level deep. See Linking content
for the shape and the rules.
What links to an entry
/v1/content/{type}/{entryId}/referenced-by Every published entry with a reference or reference_list field pointing
at this one, and the field it points through. Any key kind.
{
"data": [
{ "id": "ent_Dxb7…", "type": "product", "field": "category",
"url": "/v1/content/product/ent_Dxb7…" }
],
"meta": { "type": "category", "entry": "ent_QYa7…", "count": 1, "release": { … } }
}
The entry itself need not be published: an unpublished target is exactly the
case where you want to know which live pages now render it as null. This is
also how a cache learns what to invalidate when a linked entry changes, since
webhooks do not cascade.
Filtering by a built-in link
?where[goes_to]=cat_Ch8Hg… entries whose menu points at one category
?where[featured]=art_6cRpC… lists that CONTAIN this article
?where[goes_to][is_null]=true no link set
Same operators and the same indexed containment as a reference. See Linking to your blog and knowledge base.
Filtering by a link
?where[category]=ent_QYa7… products in one category
?where[category][in]=ent_a,ent_b in any of these
?where[related]=ent_Dxb7… lists that CONTAIN this entry
?where[category][is_null]=true no category set
Indexed, like every other filter. is_null asks whether the field is set, not
whether its target is published.
Create an entry
/v1/content/{type}/entries Secret key only. Collections only.
{
"values": { "name": "Ada Lovelace", "role": "engineer" },
"publish": true
}
values is keyed by field key and validated against the type: a missing
required field is missing_required_field, a wrong type is
invalid_field_value, an unknown key is unknown_field naming the ones that
exist.
Omitting publish creates 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.
Update an entry
/v1/content/{type}/{entryId} Secret key only. Change field values, the publish state, or both.
{
"values": { "role": "lead" },
"publish": true
}
values is merged, which is the difference between this and POST: a key
you omit is left alone, and an explicit null clears that one field. So
adding a field to a type that already has entries can be backfilled from a
script, one field at a time, without touching anything else and without
changing any entry id.
Omit publish and the publish state is left as it is. Send {"publish": false}
alone to take an entry off the public API while keeping its draft, which is the
reversible way to undo a write experiment.
Sending neither is refused (empty_patch) rather than quietly doing nothing.
/v1/content/{type}/{entryId} Removes the entry and its draft. Irreversible, with no trash. Prefer
{"publish": false} when you only want it off the site.
Update a singleton
/v1/content/{type} Secret key only. Singletons only. This is the one to reach for.
values is MERGED, exactly as it is for an entry: a key you omit is left
alone, and an explicit null clears that one field. publish works the same
way, and omitting it leaves the publish state as it is.
curl -X PATCH "https://api.zevcontent.net/v1/content/site-settings" \
-H "Authorization: Bearer $ZEVCONTENT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"values": {"tagline": "New tagline"}, "publish": true}'
Patching a singleton nobody has saved yet creates it, so you do not have to know whether it exists.
Replace a singleton
/v1/content/{type} Secret key only. Singletons only.
Replaces the singleton’s draft with values, creating its one entry on first
save, and publishes it in the same call with "publish": true.
It is a replace, not a merge: values is the whole object. A key you omit is
cleared, not left alone.
The schema itself
/v1/schema/content-types Secret key only. Every type definition with its fields. These are the same definitions introspection embeds, on their own endpoint for a client that wants the schema without the rest of the map.
/v1/schema/content-types/{type} Secret key only. Create or update a type and its fields. This is how a project is modelled from code rather than in the dashboard, and it is what lets an agent set one up end to end. Idempotent: applying the same body twice lands in the same state.
{
"name": "Team member",
"kind": "collection",
"fields": [
{ "key": "name", "label": "Name", "type": "string", "required": true },
{
"key": "role",
"label": "Role",
"type": "select",
"options": {
"choices": [
{ "value": "engineer", "label": "Engineer" },
{ "value": "designer", "label": "Designer" }
]
},
"help_text": "Shown as a filter on the team page."
},
{ "key": "photo", "label": "Photo", "type": "image" }
]
}
| Field | |
|---|---|
name | The human name, shown in the dashboard. |
kind | singleton or collection. Immutable once the type exists. |
fields[].key | The key in the values object. The permanent name. |
fields[].label | What a writer sees. |
fields[].type | One of the twelve field types. Immutable once the field exists. |
fields[].required | Optional, default false. |
fields[].options | select: { "choices": [{ "value", "label" }] }, in order. Not a string array. value is stored, label is what a writer sees. reference and reference_list: { "content_type": "<collection slug>" }. link and link_list: { "target": "blog_article" | "blog_category" | "author" | "kb_article" | "kb_collection" | "kb_section" }. Every target is immutable once set. |
fields[].help_text | Optional, shown under the field in the dashboard. |
display_field | Optional. Which field names an entry in the dashboard list. A key from fields, or null for the first field. |
subtitle_field | Optional. A second field shown under it, for what tells similar rows apart. |
Naming entries in a list
By default the dashboard lists an entry under its first field, which is right for almost every structure and wrong for the ones that need it most.
A navigation collection is the clearest case: one entry per link, with a label, a location and an order. The same label legitimately belongs in a header, a footer column and a set of tiles, so the list comes back as seventeen rows reading “About”, “Journal”, “Covers” over and over, and the first question anybody asks is why everything looks duplicated.
display_field fixes it without inventing a field to hold a label:
{
"display_field": "label",
"subtitle_field": "location"
}
Rows then read Covers with header underneath, Covers with footer-left, and so on. A key that names no field is refused rather than ignored, because a nomination that silently falls back looks exactly like one you never made.
Two things are immutable once set, because changing them breaks every frontend already reading:
- A type’s kind (
content_type_kind_immutable). A singleton becoming a collection changes the response from an object to an array. - A field’s type (
field_type_immutable). Astringbecoming anumberchanges what every reader receives. - A reference field’s target (
reference_target_immutable). Every entry already stores ids of that collection under it. Remove the field and add another to point somewhere else. - A link field’s target (
link_target_immutable), for the same reason.
Everything else is editable: labels, options, order, and adding or removing fields.
Only published content is served
/v1/content serves published entries. A draft is invisible here and visible
in the dashboard, which is what lets somebody work on a page for a week without
it appearing on your site.
An entry that exists but is not published answers entry_not_published rather
than pretending it does not exist.
Updated at, Sunday, September 20, 2026