Getting started
For AI agents
Read this first if you are an agent holding a key. The API describes itself, and that description is better than this site.
This page is written for an AI agent asked to integrate ZevContent, and for the person pointing one at it.
Ask the server, not the docs
/v1/introspection If you hold a key, start here rather than with this site. One request returns:
- every content type in the project, with its fields and the exact
response_shapeeach one serves; - every built-in module, with its endpoints, its shapes, how much is in it, and the notes that matter;
- the
core_endpointsfor custom content and schema; - the rich text document contract: every node, mark and variant you may send;
conventions: authentication, pagination, filtering, rate limits and the error envelope, with the real numbers.
It describes that project, not the product in general. These pages can go stale; that response is generated from the code that serves the request, so it cannot.
Machine-readable copies of this site
/llms.txt: a curated index of every page, per the llms.txt convention./llms-full.txt: every page concatenated, for one-shot context.<any page>.md: the clean markdown body of a single page. Append.mdto any URL here, such as/api/errors.md.
Two decisions to get right before you write code
Both are architecture, both are easy to get wrong, and both produce something that works on the day you build it and fails as a product afterwards. The full reasoning is in Choosing an architecture; this is the short version.
1. Never put a secret key in a browser. An sk_… reads drafts, writes
content, changes the schema and deletes things. In client-side JavaScript, a
PUBLIC_/NEXT_PUBLIC_ variable, or a committed file, it hands every visitor
that power. It is the customer’s password, not a config value.
Seed with a secret key from the terminal, then configure the deployed site
with a publishable key. If you are writing a .env for someone’s frontend
and reaching for sk_, stop.
2. Do not build a site that can only update by redeploying. Fetching everything at build time and never again means publishing an article changes nothing until someone deploys. The person asked for a CMS; that takes it away.
Register a webhook, so publishing triggers a rebuild or clears a cache. The
events are listed in Webhooks and in /v1/introspection
under webhooks.events. That list includes content.entry.* for your own
types, and release.published, which means every read can change at once.
The four things agents get wrong
1. Fetching everything. There is no way to ask for all of something, and
?limit=5000 is refused rather than clamped. Page it: read both
meta.next_offset and meta.next_cursor, pass back whichever is set, and stop
when both are null. Do not loop on has_more alone.
2. Filtering in the client. Every listing filters server-side, on indexed
columns, and validates against the declared schema. ?where[role]=engineer is
one indexed query; fetching a page and filtering it is a bug that only shows up
when the project grows. An unknown field is answered with the fields that
exist, so a wrong guess teaches you the right answer.
3. Treating an empty list and a 404 as the same thing. They are
deliberately different. A filter that matches nothing is 200 with an empty
array. Naming something that does not exist, such as an unknown category,
section or field, is a 4xx that names the real ones and often carries
did_you_mean.
If you get an empty list, your query was valid and there is nothing there.
4. Assuming a listing is the whole story. An article can be scheduled:
published, with a published_at that has not arrived. It is absent from
listings and counts until that moment, and its slug answers
blog_article_scheduled / kb_article_scheduled naming the date. That 404
does not mean the article does not exist, and a listing you cached yesterday
may be missing something today.
5. Retrying a 429 immediately. Read Retry-After and wait exactly that
long. It is the moment the refusal lifts, not when the window rolls. While you
are being refused, requests are not counted, so hammering neither helps nor
hurts. It just wastes time. Better: read X-RateLimit-Remaining-read on every
response and slow down before you are refused.
Errors are written to be acted on
Every failure is the same envelope, and the message is a sentence saying what to do next rather than a status restated:
{
"error": {
"type": "not_found_error",
"code": "unknown_kb_article",
"message": "This knowledge base has no published article at \"instal-the-cli\". Did you mean \"install-the-cli\"? List the published articles with GET /v1/kb/articles.",
"request_id": "6e5c4029-…",
"param": "slug",
"did_you_mean": "install-the-cli"
}
}
Branch on code, which is stable. message is for a human reading a log, and
may be reworded. See Errors.
Writing content
An agent can set a project up end to end with a secret key:
Your own content types
PUT /v1/schema/content-types/{type}defines a type and its fields.POST /v1/content/{type}/entriescreates an entry, optionally publishing it in the same call with"publish": true.PUT /v1/content/{type}saves a singleton.
The built-in modules, in dependency order, because an article references the others by id:
POST /v1/authors,POST /v1/blog/categoriesPOST /v1/kb/collections, thenPOST /v1/kb/sectionsPOST /v1/blog/articles,POST /v1/kb/articlesPATCH /v1/blog/articles/{slug},PATCH /v1/kb/articles/{slug}
Set published_at to a past date to keep an imported post’s own date. Bodies
are snake_case, matching what the same field is served under.
Rich text is a document, not an HTML string. /v1/introspection returns the
whole node schema under rich_text; Rich text explains
the parts worth understanding before you generate one.
Seeding images
An image field, a gallery field and an article’s featured_image_id all
take a med_… id. You can create one, so a site you build for somebody
arrives finished rather than with holes where the pictures go.
Three calls. The bytes never pass through ZevContent: you upload straight to the project’s own bucket with a URL we sign.
1. Ask for an upload.
POST /v1/media/uploads
{
"file_name": "harbour-at-dusk.jpg",
"content_type": "image/jpeg",
"size_bytes": 482310,
"width": 1600,
"height": 900,
"alt": "Fishing boats moored at dusk"
}
Send alt. Every image response carries an alt key, and nothing else
fills it in: an asset uploaded without one has an empty alt on the live site
until a person opens the dashboard. You are writing the content, so you are
the one who knows what the picture shows.
You get back upload_url, the headers to send with it, and media.id, which
is the med_… the file will have.
2. PUT the bytes to upload_url.
Send the headers you were given, verbatim. They are part of what was
signed, so adding, dropping or re-casing one makes the bucket reject the
upload with an error that will not explain itself. content_type and
size_bytes are signed too: if they do not match the bytes you send, the
upload is refused.
3. Confirm it landed.
POST /v1/media/uploads/med_.../confirm
{ "width": 1600, "height": 900, "alt": "Fishing boats moored at dusk" }
The body is optional, and every key in it is too. It exists because dimensions and alt are often produced by whatever resized or captioned the image, which happens after step 1. Send them at either step; sending them at neither leaves them null for good.
This is not bookkeeping. The asset is pending until we check the object is
really in the bucket, and a pending id cannot be referenced by content. A
confirm that comes back "status": "failed" means the object was not there:
start again at step 1 rather than retrying the confirm.
Once it is ready, use the id like any other:
POST /v1/blog/articles
{ "title": "How we deploy", "featured_image_id": "med_..." }
width and height are not derived server-side, so if you never send them
they stay null. A page with no dimensions cannot reserve space, and every
image shifts the layout as it loads. On an image-heavy site that is the
difference between a good Core Web Vitals score and a bad one.
Editing what you already wrote
PATCH /v1/content/{type}/{entryId}
{ "values": { "title": "Header: Covers" }, "publish": true }
values is merged: a key you omit is left alone, an explicit null clears
that one field. This is what makes schema evolution safe from a script. Add a
field to a type that already has entries, then backfill it one entry at a time
without touching the rest and without changing any entry id.
Undoing is the same call:
{ "publish": false }
That takes the entry off the public API and keeps its draft, so the work
survives and the site stops showing it. {"publish": true} puts it back.
Omit publish entirely and the publish state is left alone; send neither
values nor publish and it is refused rather than doing nothing.
Singletons patch too, and you should let them
PATCH /v1/content/site-settings
{ "values": { "tagline": "New tagline" }, "publish": true }
Same merge, same rules. PUT /v1/content/{type} still exists and still
replaces the whole object, which is almost never what a script means.
The failure this avoids is silent and it costs content. Read a singleton,
change one field, PUT the result, and every image on it is gone. An image
reads as { id, url, alt, width, height, content_type } and writes as the
med_… id, so the object you read back is refused; drop the key to get around
that and the replace clears the picture. A pilot lost their site logo this way
and abandoned the write.
If you really are replacing a whole object, every resolved value now carries
the id you would write back. See the read and write forms.
Name your entries in the dashboard
If you define a type whose first field repeats across entries, set
display_field on it. A navigation collection of one entry per link otherwise
lists as “About”, “Journal”, “Covers” two or three times over, because the same
label belongs in a header, a footer and a set of tiles, and the person you built
it for will ask why it looks duplicated.
PUT /v1/schema/content-types/menu-item
{ "display_field": "label", "subtitle_field": "location", "fields": [ … ] }
Do not invent a field to hold a display name. That is the workaround this exists to remove.
Never store a URL to your own content
When a field points at something inside this project, use link, not url.
{ "key": "goes_to", "label": "Goes to", "type": "link",
"options": { "target": "blog_category" } }
Targets: blog_article, blog_category, author, kb_article,
kb_collection, kb_section. Use link_list for several, in order.
A url field holding /blog/category/cloud-hosting is a copy of a slug, and
it rots the first time somebody renames that category: the menu keeps the old
path, every click 404s, and nothing warns anyone. A link field stores the id
and the slug is resolved on every read, so a rename propagates everywhere at
once. This is the single most common way a seeded site quietly breaks a month
after you hand it over.
Seeding, in order:
GET /v1/blog/categories,/v1/authors,/v1/blog/articles,/v1/kb/articlesor/v1/kb/treeto collect the ids. All readable with the same secret key you are seeding with.PUT /v1/schema/content-types/{type}with thelinkfields.POST /v1/content/{type}/entrieswith the ids as values.
Create the articles and categories BEFORE the entries that link to them: every
id is checked, and pasting one from the wrong list is refused by name
(unknown_link tells you which kind it actually was).
Reads resolve to { id, type, title, slug, url }, identically for every
target, so one menu template handles all of them. A deleted target reads as
null, and an unpublished article does too, so tell the person if you link
drafts on purpose.
Link types to each other rather than duplicating names
When one type needs to name entries of another (a product’s category, a page’s
menu, a post’s related posts) use a reference or reference_list field, not
a select with the names typed in and not a string holding an id. A select
drifts the first time a category is renamed; a string is validated by nothing
and resolved by nothing.
{ "key": "category", "label": "Category", "type": "reference",
"options": { "content_type": "category" } }
The rules that matter when you seed:
- Define the target first, or in the same request when a type links to
itself.
options.content_typemust be an existing collection in the release; a singleton is refused, and so is a slug that names nothing. - Create targets before referrers, so the ids exist. Every id you write is checked for existence, project, release and type, and refused by name.
- Publish targets too. Writing a link to a draft is allowed and the
response warns you (
meta.warnings[].code = "reference_unpublished"); until the target is published the site reads the link asnull. If you seed products published and categories as drafts, every product page shows no category and nobody told the person why. Read the warnings. - Reads resolve one level. A product’s
categorycomes back as the category entry; a reference field inside it stays an id. Do not expect a tree. - Set
display_fieldon the target. The dashboard picker lists the target’s entries by that field, so a category type whose first field is a slug will show slugs to writers. - A linked entry cannot be deleted until nothing points at it
(
entry_referencednames what does). Unpublish instead, or unlink first.
GET /v1/introspection lists every link under relations, and
GET /v1/content/{type}/{entryId}/referenced-by shows what points at an entry,
so nothing about the graph needs to be inferred.
DELETE /v1/content/{type}/{entryId}
Removes the entry and its draft. Irreversible, no trash. Reach for the unpublish first.
Content types and media are still dashboard-only. There is no DELETE for
either, so do not spend calls probing for one. A throwaway type created while
exploring is permanent until a person removes it, so prefer
PUT /v1/schema/content-types/{type} to reshape a type you already made over
creating a second one to try an idea.
There is no “give us a URL and you fetch it” endpoint, deliberately. It would point our network at any address a caller names, which is a request-forgery hole aimed at whatever our infrastructure can reach and yours cannot. Sending the bytes is a few lines more and gives away nothing.
Updated at, Sunday, September 20, 2026