Docs
Dashboard

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

GET /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_shape each one serves;
  • every built-in module, with its endpoints, its shapes, how much is in it, and the notes that matter;
  • the core_endpoints for 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 .md to 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}/entries creates 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/categories
  • POST /v1/kb/collections, then POST /v1/kb/sections
  • POST /v1/blog/articles, POST /v1/kb/articles
  • PATCH /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:

  1. GET /v1/blog/categories, /v1/authors, /v1/blog/articles, /v1/kb/articles or /v1/kb/tree to collect the ids. All readable with the same secret key you are seeding with.
  2. PUT /v1/schema/content-types/{type} with the link fields.
  3. POST /v1/content/{type}/entries with 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.

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_type must 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 as null. 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 category comes back as the category entry; a reference field inside it stays an id. Do not expect a tree.
  • Set display_field on 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_referenced names 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