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.
Search
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 →
200and 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 →
4xxnaming 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