Docs
Dashboard

API reference

Pagination and filtering

Every listing pages, nothing returns everything, and filtering happens in the database.

Nothing returns everything

Every listing on /v1 is paginated server-side, and there is no parameter that asks for all of something.

This is deliberate. An endpoint that will return ten thousand rows if asked is an endpoint that eventually does, at the worst possible moment, on somebody else’s traffic. Bounding it here means the ceiling is ours to reason about rather than every integrator’s to remember.

limit is 1–100, default 20. A value outside that range is refused (invalid_limit), not clamped: a caller building a paging loop must never silently receive a different page size than it asked for.

The one loop that works everywhere

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.

let params = { limit: 50 };
const all = [];

for (;;) {
  const res = await fetch(url + '?' + new URLSearchParams(params), { headers });
  const { data, meta } = await res.json();
  all.push(...data);

  if (meta.next_offset != null) params.offset = meta.next_offset;
  else if (meta.next_cursor != null) params.cursor = meta.next_cursor;
  else break;
}

Do not compute the next offset yourself, and do not loop on has_more alone.

Why there are two styles

Cursor. /v1/blog/articles, and /v1/content collections in their default order. A cursor encodes a position in that order, so rows inserted while you are paging cannot make you skip or repeat one.

Offset. Everything else: /v1/kb/*, /v1/authors, /v1/blog/categories, and /v1/content whenever sort is used.

That last case is the one that surprises people. A cursor is a position in the default order, so asking for a different order invalidates it: next_cursor goes null and next_offset fills in. The response tells you which one it answered with, so read it rather than assuming.

Treat a cursor as opaque. It is a base64 position, not a key you can construct.

Offset paging stops at 10,000. A page that deep costs the database a sort of everything before it, so it is refused rather than left to time out. To walk a whole collection, drop sort and page with the cursor, which has no limit; to reach one row, narrow the list with where instead.

Filtering

Filtering happens in the database, on indexed columns, and is validated against the declared schema.

Custom content: where[…]

?where[role]=engineer
?where[price][gte]=100
?where[tag][in]=a,b,c
?where[archived_at][is_null]=true

where[field]=value is equality; where[field][op]=value for anything else. Conditions are ANDed. There is no OR and no nesting, because a query that needs them is describing a report, not a page, and should run against your own copy of the data.

Operators: eq ne gt gte lt lte contains starts_with in is_null.

Which ones apply depends on the field type. See the table in Content types. An operator the type does not support is refused, not silently ignored.

in takes at most 50 values. So does ?ids=.

A reference field filters by the linked entry’s id (where[category]=ent_…); on a reference_list, eq means contains and in means contains any of.

Built-in modules: named filters

The modules have a fixed schema, so they expose named filters instead:

  • Blog: category, author, tag, q
  • Knowledge base: collection, section, q

All of them take slugs, not ids, because a page URL already contains the slug and looking up an id first would be a request per page load to learn something you already know.

category and author match an article carrying that slug anywhere in its list, so a co-authored post appears on both authors’ pages.

Two kinds, and the difference matters.

q on /v1/blog/articles and /v1/kb/articles is a case-insensitive substring match on titles and summaries or excerpts, backed by a trigram index. It narrows a list, combines with the other filters, and keeps the list’s own order. It does not read bodies, stem or rank.

GET /v1/blog/search and GET /v1/kb/search are ranked full-text search over titles, summaries, tags and bodies, best match first, with a snippet of the passage that matched. That is the one a search box calls. See Blog and Knowledge base.

Empty is not the same as unknown

  • A filter that matches nothing → 200 and an empty array. Your query was valid; there is nothing there.
  • A name that does not exist, such as a category, section, collection or field → 4xx naming the real ones.

So an empty list is never a typo, and a typo is never an empty list. This is worth relying on: it is the difference between “we have no posts in Engineering” and “you spelled Engineering wrong”, which are otherwise indistinguishable.

Knowing when to refetch

Content changes when somebody edits it, not on every page view, so cache it. The question a cache needs answered is “may I keep what I have?”, and that is one request:

curl https://api.zevcontent.net/v1/changes -H "Authorization: Bearer $ZEVCONTENT_KEY"
{
  "data": {
    "latest_change": "2026-09-02T09:16:00.846Z",
    "modules": {
      "blog": "2026-09-02T09:16:00.846Z",
      "kb": "2026-09-01T22:14:10.566Z",
      "authors": "2026-09-02T09:15:57.364Z",
      "categories": "2026-09-02T09:15:58.009Z",
      "content": "2026-09-01T16:31:21.213Z"
    },
    "next_scheduled_change": null
  }
}

Poll it, compare latest_change, and cache everything else hard until it moves. Per module, so a site that only renders a blog does not rebuild because somebody fixed a help article. It draws on the read budget and is cheap by design: a handful of indexed MAX() reads.

Fetching only what changed

When latest_change moves, you do not have to read every page again. The three lists that grow, /v1/blog/articles, /v1/kb/articles and /v1/content/{type}, take updated_since: an ISO 8601 date-time, and only rows edited at or after it come back.

curl "https://api.zevcontent.net/v1/blog/articles?updated_since=2026-09-02T09:16:00.846Z" \
  -H "Authorization: Bearer $ZEVCONTENT_KEY"

Pass the latest_change you last acted on. The filter is echoed in meta.filters, and it pages like any other list. A deleted row is not there to be listed, so deletions never appear here; webhooks carry those.

Conditional requests

Every GET on /v1 carries a weak ETag over its body. Send it back and an unchanged response is a 304 Not Modified with no body:

curl -i https://api.zevcontent.net/v1/kb/tree \
  -H "Authorization: Bearer $ZEVCONTENT_KEY" \
  -H 'If-None-Match: W/"z0Ip3kqXQeF7c8wR2tYbN1mL9sA"'

It saves the transfer, not the read: the response is still built on our side and still counts against the read budget. The saving that matters most is on your side, in bytes and parsing, on a poll that usually finds nothing new. On a project whose media bucket is private the image URLs are signed afresh on every read, so the tag changes every time and a 304 never comes; cache by /v1/changes there instead.

Webhooks push the same news to you the moment it happens. Polling /v1/changes is the cheap half of the same benefit for a site that would rather ask than listen.

Sorting

/v1/content takes ?sort=: a field key, or created_at, published_at, updated_at. Prefix with - for descending.

The knowledge base has no sort, deliberately. Every listing comes back in the order somebody dragged it into. A help centre in date order is not a help centre.

Updated at, Sunday, September 20, 2026