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.

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=.

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.

q is a case-insensitive substring match on titles and summaries or excerpts, backed by a trigram index so it stays fast on a large corpus.

It is not ranked full-text search and it does not stem: “running” will not match “run”. It combines with the other filters, which is how a category or collection page offers search within itself without a second endpoint.

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.

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, Wednesday, September 2, 2026