---
title: Knowledge base
description: 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

```http
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.

```json
{
  "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

```http
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 | |
|---|---|
| `limit` | 1–100, default 20 |
| `offset` | Use `meta.next_offset` |
| `collection` | A collection **slug**. Everything beneath it, sections included. |
| `section` | A section **slug**. |
| `q` | Case-insensitive substring of title or summary |
| `media_urls` | `permanent` 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

```http
GET /v1/kb/articles/{slug}
```

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

> **INFO: Slugs survive a reorganisation**
>
> Moving an article between sections does **not** change its slug, so
> `/help/{slug}` URLs keep working after the tree is rearranged. Build your
> routes on the slug, not on the breadcrumb.

## The article shape

```json
{
  "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.

### `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

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

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

```json
{ "helpful": true }
```

```json
{
  "data": { "helpful": 13, "not_helpful": 3, "total": 16, "score": 81, "your_vote": true },
  "meta": { "module": "kb", "release_independent": true }
}
```

> **WARNING: Use a publishable key, never a secret one**
>
> This call is made from a browser. A secret key in a browser bundle is a secret
> key published. Use a `pk_…` key with your site in its origin allow-list.

- `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](/api/rate-limits).

## Notes

- Everything except the vote is **read-only** and works with any key kind.
- 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.