# ZevContent developer documentation — full text bundle > Every page on `docs.zevcontent.com` concatenated into one file. Page boundaries are marked by `===` separators carrying the page title and canonical URL so an LLM can cite back to the source. For a curated map without the full body text, see https://docs.zevcontent.com/llms.txt. For a single page, append `.md` to its URL. --- === # Introduction > What ZevContent is, what it is not, and the one decision you make before anything else. Source: https://docs.zevcontent.com/guide/ --- ZevContent is a **headless content backend**. You describe the shape of your content, people who are not developers fill it in, and your frontend reads it over a REST API. Headless means we have no opinion about your frontend. There is no theme, no template language and no renderer — we serve JSON, and what you build with it is entirely yours. If you are looking for something that produces a website, this is the wrong tool. ## The one decision: your own type, or a built-in module Everything in ZevContent is one of two things, and knowing which you need is most of the setup. **Custom content types** are shapes you define. A `team-member` with a name, a role and a photo. A `pricing-tier` with a price and a feature list. A `homepage` singleton with a headline. You choose the fields, we store and serve them, and the response shape follows your definition exactly. **Built-in modules** are content types *we* define, document and maintain. There are three: a [blog](/modules/blog), a [knowledge base](/modules/knowledge-base), and the shared [author directory](/modules/authors) both of them credit. You create nothing first — every project has these endpoints from the moment it exists, and they serve an empty list until somebody writes something. > **INFO: Why a blog is built in and a gallery is not** > > A blog is not a shape, it is a **set of behaviours**: slugs that must stay > stable, an SEO chain with fallbacks, reading time, ordered co-authors, a > category that is a page and a tag that is not. Ten teams modelling that > themselves would produce ten subtly broken versions of the same thing, and > each would discover the missing parts in production. > > A gallery is a shape: a title and a list of images. Nothing about it needs our > opinion, so it is a custom type — and there is a template in the dashboard to > start from. ## What you get without asking Because a built-in module has a schema we control, it can do work for you that a custom type cannot: - **SEO arrives resolved.** `seo.title` falls back to the article title, `seo.description` to the excerpt and then to the opening of the body, `seo.og_image` to the featured image. You render what you are given. See [SEO](/concepts/seo). - **References arrive as objects.** A listing carries the resolved author and category, not `author_id` and `category_id`, so twenty article cards cost one request rather than twenty-one. - **Derived figures are computed.** `reading_time_minutes`, `word_count`, and the knowledge base's `freshness` verdict. ## What it is not - **It is not a page builder.** There are no layouts, sections or blocks that render themselves. - **It does not host your images.** Media goes to *your* object storage, and bytes never pass through us — see [Media](/concepts/media). - **It is not a database.** The query surface is deliberately bounded: filters are ANDed, there is no OR and no nesting. A query that needs those is describing a report, not a page. ## Where to go next If you have a key, the fastest path is the [quickstart](/guide/quickstart) — three curl commands and you have read real content. If you are an AI agent, or you are pointing one at this API, read [For AI agents](/guide/agents) first. The short version: call `/v1/introspection` before you read anything here, because it describes *your* project rather than the product in general. === # Quickstart > From a key to real content in three requests. Nothing to install. Source: https://docs.zevcontent.com/guide/quickstart --- There is no SDK and no build step. A key and `curl` are the whole toolchain. ## 1. Get a key Create a project at [dashboard.zevcontent.com](https://dashboard.zevcontent.com), then **Settings → API keys**. You are offered two kinds; take a **secret** key for now, because you are calling from a terminal. ```bash export ZEVCONTENT_KEY=sk_... ``` The key selects the project. There is no project id in any URL — the same path serves different content for different keys. > **WARNING: Which key goes where** > > A secret `sk_…` key is server-side only. The moment this reaches a browser > bundle it is public, and anyone holding it can read everything the project > serves. Browsers get a publishable `pk_…` key instead, locked to your origins. > [API keys](/guide/keys) explains the difference in one page. ## 2. Ask the project what it holds ```http GET /v1/introspection ``` ```bash curl https://api.zevcontent.net/v1/introspection \ -H "Authorization: Bearer $ZEVCONTENT_KEY" ``` This is the single most useful request in the API. It returns every content type with its fields and the exact shape each response will have, every built-in module with its endpoints, the rich text contract, and the rules for authentication, paging, filtering, rate limits and errors. It describes **your** project, so it cannot be out of date with the content you are about to fetch. [Introspection](/api/introspection) covers what comes back. ## 3. Read something The built-in modules work immediately, with nothing configured: ```bash curl "https://api.zevcontent.net/v1/blog/articles?limit=5" \ -H "Authorization: Bearer $ZEVCONTENT_KEY" ``` ```json { "data": [ { "slug": "how-we-deploy", "title": "How we deploy", "excerpt": "A short tour of the pipeline.", "authors": [{ "id": "aut_…", "name": "Ada", "slug": "ada" }], "categories": [{ "id": "cat_…", "name": "Engineering", "slug": "engineering" }], "reading_time_minutes": 6, "published_at": "2026-08-14T09:00:00.000Z", "seo": { "title": "How we deploy", "og_image": { "url": "https://…" } } } ], "meta": { "module": "blog", "limit": 5, "has_more": false, "next_cursor": null } } ``` Note what you did **not** have to do: no second request for the byline, no second request for the category, and no fallback chain to build `seo` yourself. Your own types work the same way, at `/v1/content/{type}`: ```bash curl "https://api.zevcontent.net/v1/content/team-member?where[role]=engineer&sort=name" \ -H "Authorization: Bearer $ZEVCONTENT_KEY" ``` ## 4. Page properly Every listing is paginated, and there is no way to ask for all of something. Read both `meta.next_offset` and `meta.next_cursor`: exactly one is set on a page that has a successor, and **both are null on the last page**. ```bash # The loop that works against every listing here. next=0 while [ -n "$next" ]; do page=$(curl -s "https://api.zevcontent.net/v1/kb/articles?limit=50&offset=$next" \ -H "Authorization: Bearer $ZEVCONTENT_KEY") echo "$page" | jq '.data[].title' next=$(echo "$page" | jq -r '.meta.next_offset // empty') done ``` Do not compute the next offset yourself, and do not loop on `has_more` alone. [Pagination and filtering](/api/pagination) explains why the two styles exist. ## Next - [API keys](/guide/keys) — which key, and where it is safe. - [Content types and entries](/concepts/content-model) — modelling your own shapes. - [API overview](/api/) — the whole surface on one page. === # API keys > Two kinds of key, what each one may do, and why the browser gets its own. Source: https://docs.zevcontent.com/guide/keys --- Every request carries a key, and the key selects the project: ``` Authorization: Bearer sk_… ``` There is no project id in any URL. The same path serves different content for different keys, which means you can point a staging deploy at a different project by changing one environment variable. ## The two kinds | | `sk_…` secret | `pk_…` publishable | |---|---|---| | Where it runs | Server-side only | Safe in a browser | | Origin restriction | None | Locked to the origins you list | | Can list releases | Yes | No | | Can write | Yes | No, except a knowledge base vote | | Recoverable after creation | **No** | Yes | ### Secret keys A secret key is shown **once**, when you create it. We store a hash, not the key, so there is no "reveal" button and support cannot recover it — losing one means creating another and revoking the old. Treat it like a database password. It reads everything the project serves, including unpublished releases, and it can write. ### Publishable keys A publishable key is designed to be readable. It ships in page source, in a mobile bundle, in a `view-source` — that is the point, and it is why the key alone is not trusted as a claim about who is calling. Two things bound it: 1. **An origin allow-list.** Add your site's origins to the key and a browser request from anywhere else is refused. `www.` is ignored when matching, so listing `https://example.com` covers `https://www.example.com`. 2. **A tighter rate budget.** Publishable keys are counted per key **and** per address, so one visitor hammering your help centre is limited without limiting every other visitor holding the same key. > **WARNING: The origin allow-list is a browser control, not a wall** > > It works because browsers send `Origin` and cannot be made to lie about it. > `curl` can send anything, so an allow-list does not make a publishable key > secret — it stops your key being used to power somebody else's site, which is > the realistic abuse. Never put content behind a publishable key that you would > not put on the page it renders. ## Check a key ```http GET /v1/ping ``` ```bash curl https://api.zevcontent.net/v1/ping \ -H "Authorization: Bearer $ZEVCONTENT_KEY" ``` Confirms the key resolves and names the project it belongs to. This is the first call to make when something is not behaving — it separates "wrong key" from "wrong request", which are the two problems that look identical from a 404. ## Rotating and revoking Revoking is immediate: the next request with that key is refused. There is no grace period, so create the replacement, deploy it, and revoke afterwards. Because a key belongs to one project, rotating one never affects another. ## Which key for which job - **A static site build** — secret. It runs on a build machine, not a browser, and it can ask for `media_urls=permanent` to refuse expiring image URLs. - **A React or Vue frontend fetching at runtime** — publishable, with your origins listed. - **A help centre with helpful/not-helpful voting** — publishable. The [vote endpoint](/modules/knowledge-base#voting) is the one write a browser should ever make, and it explicitly expects a publishable key. - **A migration script or an AI agent creating content** — secret. Writes require one. === # 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. Source: https://docs.zevcontent.com/guide/agents --- 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 ```http 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. > **INFO: The rule of thumb** > > Read `/v1/introspection` to learn what to send. Read these pages to learn > *why* — the reasoning, the trade-offs, and the mistakes the shape is built to > prevent. If the two ever disagree, the API is right and this is a bug worth > reporting. ## Machine-readable copies of this site - **`/llms.txt`** — a curated index of every page, per the [llms.txt convention](https://llmstxt.org). - **`/llms-full.txt`** — every page concatenated, for one-shot context. - **`.md`** — the clean markdown body of a single page. Append `.md` to any URL here: `/api/errors.md`. ## 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 — 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. 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: ```json { "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](/api/errors). ## Writing content An agent can set a project up end to end with a **secret** key: - `PUT /v1/schema/content-types/{type}` — define a type and its fields. - `POST /v1/content/{type}/entries` — create an entry, optionally publishing it in the same call with `"publish": true`. - `PUT /v1/content/{type}` — save a singleton. Rich text is a document, not an HTML string. `/v1/introspection` returns the whole node schema under `rich_text`; [Rich text](/concepts/rich-text) explains the parts worth understanding before you generate one. > **WARNING: Drafts are the default** > > Omitting `publish` creates a draft, which is deliberate: content generated by > an agent should be reviewed by a person before it is public. Passing > `"publish": true` skips that review — do it only when the person you are > working for has asked you to. === # Troubleshooting > The failures people actually hit, and what each one is really telling you. Source: https://docs.zevcontent.com/guide/troubleshooting --- ## "It returns an empty list and I know there is content" An empty list means your query was **valid** and matched nothing. Three usual causes: 1. **Nothing is published.** `/v1` serves published content only. A draft is invisible here and visible in the dashboard, which is exactly how it should look while somebody is still writing. 2. **You are on the wrong release.** Custom content is release-scoped. Without `?release=` you are reading the default release, which may not be the one your colleague just edited. The built-in modules are release-*independent*, so this never explains a missing blog or knowledge base article — see [Releases](/concepts/releases). 3. **The filter matched nothing.** `?tag=setup` with no such tag is an empty list, not an error, because a tag is free-form and "no articles carry this" is a real answer. > **INFO: An empty list is never a typo** > > Naming something that does not exist — an unknown category, section, field or > sort key — is a `4xx` that names the real ones, not an empty list. So if you > got `200` with `[]`, the names in your query were all correct. ## "I get 401 and the key is right" `401` means the key did not resolve. Call `GET /v1/ping` — it confirms the key and names its project, which separates "wrong key" from "wrong request". If ping succeeds and your real request still fails, you are not looking at an authentication problem. ## "It works in curl and fails in the browser" `origin_not_allowed`. A publishable key is locked to the origins listed on it, and `curl` does not send an `Origin` header, so it is never checked there. Add the origin — scheme, host and port, exactly as the browser sends it — to the key in the dashboard. `www.` is ignored when matching, so `https://example.com` covers `https://www.example.com`. `http://localhost:3000` and `http://localhost:5173` are different origins. ## "My image URLs stopped working" Your project's bucket is **private**, so image URLs are presigned and expire. The object carries `url_expires_at` saying exactly when. That is fine for a page fetching at runtime and fatal for a static build, which bakes the URL into HTML that outlives it. Pass `?media_urls=permanent` and the API will **refuse** rather than hand you links that die — turning a silent breakage weeks later into an error at build time. If you need permanent URLs, make the bucket public. See [Media](/concepts/media). ## "I get 429 and I am not making many requests" Check which budget. The header names it: `X-RateLimit-Remaining-read`, `-write` or `-vote`. Budgets are separate, so reading all day never uses up your allowance to vote. Two things that surprise people: - A **publishable** key is counted per key *and* per address. If your server is making requests with a publishable key, every one of them shares a single address budget. Use a secret key server-side. - `Retry-After` is when the refusal **lifts**, not when the counting window rolls. Wait exactly that long. Retrying sooner earns another 429. See [Rate limits](/api/rate-limits). ## "`where[…]` seems to be ignored" Two possibilities, and the response tells you which: - **A `400`** naming the fields that exist means you used a field key that is not on the type. `response_shape` in `/v1/introspection` lists them. - **A `400`** naming operators means the operator does not apply to that field type. `contains` on a boolean is refused rather than silently matching nothing, because answering it with "0 results" would hide the mistake. `where` only exists on `/v1/content`. The built-in modules take named filters instead — `category`, `author`, `tag`, `q` for the blog; `collection`, `section`, `q` for the knowledge base — because their schema is fixed. ## "Sorting changed how paging works" It did, and the response says so. A cursor encodes a position in the *default* order, so asking `/v1/content` for a `sort` switches it to offset paging: `next_cursor` goes null and `next_offset` fills in. Read both keys and pass back whichever is set. That one loop works everywhere. ## "The knowledge base ignores `?sort=`" There is deliberately no `sort` anywhere in the knowledge base. Every listing comes back in the order somebody dragged it into — collection, then section, then the manual order inside it. A help centre in date order is not a help centre. If you need another order, sort client-side, knowing you are overriding an editorial decision. ## "A slug I know exists returns 404" Read the `code`: - `kb_article_not_published` / `blog_article_not_published` — it exists and is a draft. Publish it. - `kb_article_archived` — it was retired on purpose. Restore it, or remove the link. - `unknown_kb_article` / `unknown_blog_article` — no such slug. Check `did_you_mean` in the error; it is usually a typo. ## Still stuck Every response carries `X-Request-Id`, and every error repeats it as `request_id`. Quote it — it leads straight to the log lines for that exact request. === # Content types and entries > Singletons, collections, the ten field types, and what a response looks like. Source: https://docs.zevcontent.com/concepts/content-model --- 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. > **INFO: The kind cannot change later** > > A singleton that becomes a collection would change the response from an object > to an array, which breaks every frontend reading it. So the kind is immutable > (`content_type_kind_immutable`). If you are unsure, a collection with one entry > is the recoverable mistake; a singleton that should have been a collection is > not. ## 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` | 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. 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 ```http GET /v1/content/{type} ``` ```json { "data": [ { "id": "ent_2c8RkXqGn4wVtYbMhJpDs", "values": { "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" } }, "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": 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 }`. You never receive a raw asset id. > **INFO: Ask for the shape rather than deriving it** > > `/v1/introspection` returns a `response_shape` per type describing exactly > this, key by key, including the extra `url_expires_at` a private project adds > to every image. It is generated from the same code that serves the response, > so it is the answer rather than an approximation of it. ## 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. ```http POST /v1/content/{type}/entries ``` ```json { "values": { "name": "Ada Lovelace", "role": "engineer" }, "publish": true } ``` ```http PUT /v1/content/{type} ``` Replaces a singleton's draft, creating its one entry on first save. Omit `publish` and you get a draft — 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. === # Releases > Version control for content, why custom types have it and built-in modules do not. Source: https://docs.zevcontent.com/concepts/releases --- A **release** is a complete copy of your custom content — the types, their fields, and every entry including drafts. One release per project is the **default**, and that is what `/v1/content` serves when you do not ask for another. ## What it is for You want to change the shape of your content without breaking the site that is currently reading it. So: 1. **Clone** the default release. You now have a second, complete copy with its own `rel_…` id. 2. **Change it** — add a field, rename one, restructure a type, edit entries. The live site sees none of this; it is still reading the default. 3. **Test it** by pointing a preview deploy at the new release: `?release=rel_…`, or the same environment variable set differently. 4. **Promote** it when the new frontend is ready. The release becomes the default and every request without a `?release=` follows. Promotion is a flag flip, not a copy — nothing is moved at the moment of going live, so nothing can half-move. Rolling back is the same flip in reverse: the old release still exists, untouched, at the same id. ## Reading a specific release ```http GET /v1/content/{type}?release=rel_… ``` Omit the parameter and you get the default. Every response says which release answered it: ```json "meta": { "release": { "id": "rel_RJuPMMbZ…", "name": "Launch", "default": true } } ``` ## Release ids are staging by obscurity A non-default release has no other address. Anyone who knows its `rel_…` id can read it, which is exactly what makes a preview deploy easy — and exactly why the id must not leak. > **WARNING: Publishable keys cannot list releases** > > `/v1/introspection` includes the full `releases` list for a **secret** key > only. A publishable key ships in page source, so listing release ids to it > would hand every visitor an index of your unreleased content. > > A publishable key still sees `release` — the one serving its request, whose id > it either already knew or is the default. The key is *absent* rather than > empty for publishable keys, so a client can tell "not allowed to see" from > "there are none". ## Built-in modules are release-independent Blog articles, knowledge base articles, collections, sections and authors hang off the **project**, not a release. `?release=` is neither needed nor honoured on `/v1/blog`, `/v1/kb` or `/v1/authors`, and every response from those endpoints says so: ```json "meta": { "module": "blog", "release_independent": true } ``` Concretely: - Publishing an article shows it immediately, whatever release is default. - Cloning a release does **not** copy the back catalogue. - Promoting a release does **not** revert an article somebody edited yesterday. This is deliberate. A release exists to change the **shape** of content, and the shape of a blog article is ours — it does not change when you restructure your own types. Tying the two together would mean a writer's Tuesday afternoon correction disappearing because a developer promoted a release on Wednesday. > **INFO: If your blog seems out of date, releases are not the reason** > > It is the first thing people suspect and it is never the cause. Check whether > the article is published, and check you are reading the project you think you > are with `GET /v1/ping`. ## What a clone copies Everything in the custom content model: types, fields, entries, **draft and published values**, and the publishing timestamps. A cloned release is a complete working copy, not a published-only export — so a page half-written when you cloned is still half-written in the copy, which is what you want when the point is to test a migration. Media is not copied and does not need to be: an asset belongs to the project, and both releases reference the same one. === # Rich text > One document, three views, and a node schema that is ours rather than an editor's. Source: https://docs.zevcontent.com/concepts/rich-text --- A `richtext` field stores a **document** and serves three views of it. ```json "body": { "html": "

Install

Run npm i

", "json": { "version": 2, "content": [ /* nodes */ ] }, "plain": "Install Run npm i …", "toc": [ { "level": 2, "text": "Install", "id": "install" } ] } ``` - **`html`** — semantic HTML, ready to render. External links carry `rel="nofollow noopener"`. Images carry `width`/`height` when known and become `
` when captioned. A callout is `