?filter= takes a JSON tree — the same grammar the admin’s query builder emits.
GraphQL’s filter: argument takes the same tree.
curl -G 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
--data-urlencode 'filter={"and":[
{"field":"featured","op":"eq","value":true},
{"field":"publishedAt","op":"gte","value":"2026-01-01"}
]}' \
--data-urlencode 'sort=-publishedAt' 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),
sort: '-publishedAt'
});
const response = await fetch(
'https://cms.example.com/api/v1/content/article?' + query,
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const { items, total } = await response.json(); # variables: { "filter": { "and": [
# { "field": "featured", "op": "eq", "value": true },
# { "field": "publishedAt", "op": "gte", "value": "2026-01-01" } ] } }
query Featured($filter: JSON) {
articles(filter: $filter, sort: "-publishedAt") {
total
items {
id
title
}
}
} - Always URL-encode the JSON on REST (
--data-urlencode,URLSearchParams). - In GraphQL, pass the tree as a
JSONvariable. Written inline,opis still a string:op: "eq". - Since 0.7.0, a published read filters the published version of a modified entry, never its unpublished edit.
The grammar
A node is a group or a rule. Groups nest.
type Node =
| { and: Node[] }
| { or: Node[] }
| { field: string; op: Operator; value: unknown };{
"and": [
{ "field": "publishedAt", "op": "gte", "value": "2026-01-01" },
{
"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 | A string, % as wildcard |
ilike | Case-insensitive pattern | A string, % as wildcard |
nilike | Does not match, case-insensitive | A string |
in | One of | An array (max 100) |
nin | None of | An array (max 100) |
null | Is empty / is not empty | true / false |
gt gte lt lte | Comparisons | A scalar |
within_last | Rolling window from query time | { "n": 7, "unit": "days" } |
- Which fields take which operators: text and select take all except
within_last. Numbers, booleans and ids take comparisons,in,nin,null.within_lastis for date and date-time only. - No
between. Usegte+ltein anand. - Negatives include empty.
ne,ninandnilikealso match rows where the field is empty. within_lasttakesn(positive integer) andunit(minutes,hours,days). It is measured when the query runs — the rolling meaning a stored alarm rule needs. (The admin’s deep links resolve it to a fixedgtecutoff instead.)
Escaping user input in ilike
% and _ are wildcards in like/ilike. Escape them before wrapping user
input in a “contains” pattern:
const escapeLike = (text) => text.replace(/[\\%_]/g, (c) => '\\' + c);
const rule = { field: 'title', op: 'ilike', value: '%' + escapeLike(input) + '%' };What is filterable
- The type’s own scalar fields: text, select, number, money, boolean, date, date-time.
id,createdAt,updatedAt,publishedAt.- On localized types:
locale,localeGroupId.
Not filterable: richtext, json, multiselect, and relation or media fields
directly (hop into them instead).
status is not filterable on the public API
Use ?status= (needs a full token). The admin API does expose status; there
the Modified state is status eq draft and publishedAt not null.
Relation hops
A field may be a dotted path across relations:
{ "field": "author.name", "op": "ilike", "value": "%ada%" }- At most two hops:
author.company.nameis allowed,author.company.country.nameis not. - Every segment is validated against the schema. An unknown field or a hop into an unreadable type is a 400.
- A hop is workspace-scoped and skips soft-deleted targets, but does not
check the target’s publish state. Add
author.statuswhen it must be published.
The admin API lists every filterable path for a type:
GET /api/content-schema/:name/filter-fields.
Limits
| Limit | Value |
|---|---|
| Serialised filter length | 4096 characters |
| Groups and rules in one tree | 50 |
and / or nesting | 5 levels |
Values in one in / nin | 100 |
| Relation hops in one path | 2 |
| Page size | 100 |
A malformed tree, unknown field or operator, or a value of the wrong shape is a 400:
{ "message": "…", "error": "Bad Request", "statusCode": 400 }Recipes
In a published category, newest first (sort=-publishedAt is a separate
parameter):
{"and":[
{"field":"category.slug","op":"eq","value":"engineering"},
{"field":"category.status","op":"eq","value":"published"}
]}Tagged with any 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"}
]}Updated in the last 24 hours (an incremental sync):
{"field":"updatedAt","op":"within_last","value":{"n":24,"unit":"hours"}}Missing an excerpt
{"field":"excerpt","op":"null","value":true}What to read next
- Reading entries — sorting, paging, fields, locale.
- Content alarms — stored filters on a schedule.
- Errors and status codes — what a rejected filter answers.