Docs
Dashboard

Built-in modules

Knowledge base

A help centre with a navigation tree, freshness dates and helpful/not-helpful voting.

A built-in module: collections holding sections holding articles, each with a byline, a freshness date and a helpful/not-helpful tally. Nothing is configured first. Every project has these endpoints whether or not anything is written.

Like the blog, it is release-independent: ?release= is neither needed nor honoured.

Two things it has that a blog does not

Order is the answer. Every listing comes back in the order somebody dragged it into: collection, then section, then the manual order inside it. There is deliberately no sort parameter anywhere in this module. A help centre in date order is not a help centre.

Freshness. Whether an answer is still believed to be true. A help article that was correct in March and is wrong now is worse than no article, so an article records when somebody last confirmed it.

The tree

GET /v1/kb/tree

Visible collections with their sections nested inside, in reading order, with an article count on every node. One request draws a whole sidebar.

{
  "data": [
    {
      "id": "kbc_…",
      "slug": "getting-started",
      "title": "Getting started",
      "description": "The first ten minutes.",
      "image": null,
      "sections": [
        { "id": "kbs_…", "slug": "accounts", "title": "Accounts", "description": null, "article_count": 4 }
      ],
      "article_count": 9,
      "seo": { "title": "Getting started", "description": "…", "og_image": null, "canonical_url": null, "noindex": false }
    }
  ],
  "meta": { "module": "kb", "release_independent": true, "limit": 20, "offset": 0, "has_more": false, "next_offset": null }
}

Sections are nested rather than served flat with a collection_id on each. A sidebar is a tree, and regrouping a flat list to draw one is work already done for you.

Collections page; the sections inside them do not. A section only means anything as part of the collection that holds it, and splitting a node from its children would make the tree unbuildable.

Hidden collections and sections are omitted entirely: absent, not marked.

List articles

GET /v1/kb/articles

Published articles in reading order. body is omitted; fetch one article for it. Each item carries its resolved breadcrumb, byline, freshness and votes.

Parameter
limit1–100, default 20
offsetUse meta.next_offset
collectionA collection slug. Everything beneath it, sections included.
sectionA section slug.
qCase-insensitive substring of title or summary
media_urlspermanent to refuse expiring image URLs

An unknown collection or section is a 404 naming the real ones, not an empty list and never the whole knowledge base.

One article

GET /v1/kb/articles/{slug}

The whole article: rendered body, its toc, the resolved breadcrumb and byline, freshness and the vote tally.

GET /v1/kb/search?q=bucket not found

Ranked full-text search over titles, summaries, tags and bodies, best match first. A help centre lives on its search box, and readers type the symptom they saw, which is in a body, not a title; this is the endpoint for that box. q on the article list stays a substring filter for narrowing a list.

q reads like a search box: every word is required, a quoted phrase stays together, a leading minus excludes a word, and words are stemmed so “connecting” finds “connect”. It takes the same section and collection filters as the list, so a collection page can search within itself, pages by offset, and is empty rather than an error when nothing matches.

Each result is the list shape plus rank, relative within one response, and snippet, the passage around the match, HTML-escaped with the matched words wrapped in <mark>:

{
  "id": "kba_2c8RkXqGn4wVtYbMhJpDs",
  "slug": "connect-a-bucket",
  "title": "Connect a bucket",
  "rank": 0.4213,
  "snippet": "If the dashboard says <mark>bucket</mark> <mark>not</mark> <mark>found</mark>, the name is right but the region is not"
}

The article shape

{
  "id": "kba_…",
  "slug": "install-the-cli",
  "title": "Install the CLI",
  "summary": "Get the CLI on your machine.",
  "collection": { "id": "kbc_…", "slug": "getting-started", "title": "Getting started" },
  "section": { "id": "kbs_…", "slug": "accounts", "title": "Accounts" },
  "authors": [{ "id": "aut_…", "slug": "ada", "name": "Ada", "bio": "…", "social": {} }],
  "tags": ["setup", "cli"],
  "published_at": "2026-09-01T14:30:35.486Z",
  "updated_at": "2026-09-01T23:14:10.566Z",
  "word_count": 420,
  "reading_time_minutes": 3,
  "freshness": { "last_reviewed_at": "2026-09-01T23:14:10.566Z", "is_stale": false, "days_until_review": 180 },
  "votes": { "helpful": 12, "not_helpful": 3, "total": 15, "score": 80 },
  "seo": { "title": "…", "description": "…", "og_image": null, "canonical_url": null, "noindex": false }
}

section can be null

An article belongs to exactly one collection and at most one section. section is null when it sits directly in its collection, which an “Overview” or “Start here” article usually does.

The key is always present, so check the value, not the key. A breadcrumb built blindly as collection › section › title renders ”› null” on the commonest kind of overview page.

Dates

Field
published_atWhen the article says it was published. Null on an article published before this was recorded.
updated_atWhen the row last changed. Always server-set.
freshness.last_reviewed_atWhen somebody last confirmed the article is still correct. See below.

published_at is editable, exactly as on the blog: a past date keeps a help centre’s real dates through an import, and a future date schedules the article, which is then absent from listings and counts until that moment and answers 404 kb_article_scheduled by slug.

It changes nothing about order here. A knowledge base is read in the order somebody dragged it into, never by date, so backdating an article does not move it. Use published_at for what it says and nothing else.

freshness

Always present, never null as an object.

  • last_reviewed_at: when somebody last confirmed this is still correct.
  • is_stale: reviewed, and the review has lapsed.
  • days_until_review: negative once overdue, null when never reviewed.

A never-reviewed article is last_reviewed_at: null with is_stale: false, because “nobody has checked” is not the same claim as “this is out of date”.

Nothing is hidden on the strength of it: a stale article is still served. Show a “last reviewed” notice, or demote it in your own search. That is your call.

votes

Always present, zeroed for an article nobody has voted on. Served in listings as well as on one article, so a card can print “12 of 15 found this helpful” without a second request.

Voting

POST /v1/kb/articles/{slug}/vote

The only write on /v1/kb, and the only endpoint in ZevContent a reader’s browser calls directly.

{ "helpful": true }
{
  "data": { "helpful": 13, "not_helpful": 3, "total": 16, "score": 81, "your_vote": true },
  "meta": { "module": "kb", "release_independent": true }
}
  • helpful must be a real JSON boolean. The string "true" is refused rather than guessed at, because on the one endpoint a stranger can write to, a body that records the opposite of what it says is not acceptable.
  • One vote per visitor per article. Voting again changes the vote rather than adding one, so a tally cannot be inflated by clicking twice.
  • No address is stored. A repeat voter is recognised by a keyed digest that includes the article id, so it cannot be reversed into an address or joined across articles into a reading history.
  • It draws on the separate vote budget of 30 a minute. See Rate limits.

Writing

Secret key only, except the vote. An import runs collections, then sections, then articles, because an article needs somewhere to live before it can exist.

POST /v1/kb/collections
POST /v1/kb/sections
POST /v1/kb/articles
PATCH /v1/kb/articles/{slug}
{
  "title": "Install the CLI",
  "summary": "Get the CLI on your machine.",
  "collection_id": "kbc_J2kNGE88J9wBA4m7bAE5a",
  "section_id": "kbs_TFp3RLUqfsKhRuYXjPBhm",
  "body": { "version": 2, "content": [ "…" ] },
  "published_at": "2022-06-15T10:00:00.000Z",
  "publish": true
}

collection_id is required unless section_id is given, which implies one. Omitting publish creates a draft. The keys are the same snake_case ones the article is served under.

A collection or section body is { "name", "slug"?, "description"? }, plus collection_id on a section.

Notes

  • Reads and the vote work with any key kind. The other writes need a secret key. See Writing.
  • Only published articles are served. A draft is a 404 that says it is a draft; an archived article disappears from /v1 the moment it is archived while staying in the dashboard, so a help centre never serves a retired answer.
  • The tree is the navigation and the article list is the content: two requests, not more. GET /v1/kb/tree draws the sidebar, GET /v1/kb/articles?collection={slug} fills the page, and both page independently.
  • tags are free-form labels, browsable in the dashboard. There is no public tag filter on this module.
  • There is no ?author= filter here. kb_article_count on an author tells you how many they have written; if you need a page listing them, ask. It is an addition, not a workaround.

Updated at, Sunday, September 20, 2026