?filter= takes a JSON tree — the same grammar the admin’s query builder emits,
so one query language covers both surfaces.
curl -G 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer apograph_…' \
--data-urlencode 'filter={"and":[
{"field":"featured","op":"eq","value":true},
{"field":"publishedAt","op":"gte","value":"2026-01-01"}
]}' const filter = {
and: [
{ field: 'featured', op: 'eq', value: true },
{ field: 'publishedAt', op: 'gte', value: '2026-01-01' }
]
};
const query = new URLSearchParams({ filter: JSON.stringify(filter) });
const response = await fetch(
'https://cms.example.com/api/v1/content/article?' + query,
{ headers: { Authorization: 'Bearer ' + process.env.APOGRAPH_TOKEN } }
);
const { items } = await response.json(); query Featured {
articles(
filter: {
and: [
{ field: "featured", op: eq, value: true }
{ field: "publishedAt", op: gte, value: "2026-01-01" }
]
}
) {
total
items {
id
title
}
}
} The grammar
A node is either a group or a rule.
type Node =
| { and: Node[] }
| { or: Node[] }
| { field: string; op: Operator; value: unknown };Groups nest, so any boolean expression is expressible:
{
"and": [
{ "field": "status", "op": "eq", "value": "published" },
{
"or": [
{ "field": "category.slug", "op": "eq", "value": "news" },
{ "field": "featured", "op": "eq", "value": true }
]
}
]
}Operators
| Operator | Meaning | Value |
|---|---|---|
eq | Equals | A scalar |
ne | Does not equal | A scalar |
like | Case-sensitive pattern match | A string, % as wildcard |
ilike | Case-insensitive pattern match | A string, % as wildcard |
nilike | Does not match | A string |
in | One of | An array |
nin | None of | An array |
null | Is empty / is not empty | true for empty, false for not |
gt | Greater than | A scalar |
gte | Greater than or equal | A scalar |
lt | Less than | A scalar |
lte | Less than or equal | A scalar |
within_last | A rolling window, measured from query time | { "n": 7, "unit": "days" } |
There is no between operator on the wire — a range is a gte and an lte in
an and group, which is exactly what the admin’s Between control serialises to.
within_last takes n as a positive integer and unit as minutes, hours
or days. It is measured from when the query runs, not from when the filter
was written, and that distinction is invisible in a URL. The admin resolves
its own within-last control into a fixed gte cutoff when it serialises a
filter into a link, deliberately: a shared deep link should keep showing the
same rows. Send the operator yourself and you get the rolling meaning — which
is what a stored, replayed filter such as an alarm rule
needs, or “not updated in 90 days” quietly becomes “not updated since the day
the rule was written”.
The null operator carries its meaning in the value: {"op":"null","value":true}
is “is empty”, and false is “is not empty”.
ilike and literal metacharacters
ilike takes a raw pattern, so % and _ are wildcards. If you are passing
user input through to a “contains” filter, wrap it yourself and escape those
characters — \%, \_, \\ — or a search for 50% matches far more than you
meant.
What is filterable
The type’s own scalar fields, plus id, createdAt, updatedAt,
publishedAt, and — on localized types — locale and localeGroupId.
Not filterable: richtext (comparing serialised trees is meaningless), and
relation or media fields directly.
status is not filterable on the public API
The public API serves published entries only, so a status rule could only ever
be a no-op or match nothing. Use ?status= instead, which needs a full token.
The admin API does expose status as a filterable field — which is how the
“modified” state is queried there: status eq draft and publishedAt not
null.
Relation hops
A rule’s field is a dotted path, and it may cross a relation:
{ "field": "author.name", "op": "ilike", "value": "%ada%" }Every path is validated against the type’s derived filter schema, segment by segment. The API never builds SQL from a string you send: an unknown field, or a hop into a type the workspace was not granted, is a rejected filter rather than a query.
To discover what a type offers, the admin API exposes
GET /api/content-schema/:name/filter-fields, which returns every filterable
path with its label, type, and — for a relation’s own id — the target type, so
a UI can render a record picker instead of a uuid box.
Limits
| Limit | Value |
|---|---|
| Serialised filter length | 4096 characters |
| Page size | 100 |
The length cap is a first line of defence; the parser has its own node and depth caps that bound the parsed shape regardless.
A malformed tree, an unknown field, an unknown operator, or a value of the wrong shape for its operator is a 400.
Worked examples
Published in a category, newest first
{"and":[
{"field":"category.slug","op":"eq","value":"engineering"},
{"field":"publishedAt","op":"null","value":false}
]}Anything tagged one of several ids
{"field":"tags.id","op":"in","value":["9c4b…","1f9a…"]}A date range
{"and":[
{"field":"publishedAt","op":"gte","value":"2026-01-01"},
{"field":"publishedAt","op":"lte","value":"2026-03-31"}
]}Missing a required editorial field
{"field":"excerpt","op":"null","value":true}What to read next
- Reading entries — the list parameters a filter combines with: sorting, paging, sparse fieldsets and locale.
- Content alarms — a stored filter tree evaluated on
a schedule, and why
within_lastis rolling there. - Errors and status codes — what a rejected filter answers, and what the 400 says.