Querying
Filter with where, sort with order_by, paginate and count.
The list endpoint, GET /v1/{realm}/{database}/{collection}, accepts these query parameters:
| Parameter | Default | Meaning |
|---|---|---|
where | — | JSON object with filter conditions |
order_by | id | Comma-separated fields; prefix a field with - for descending |
limit | 20 | Page size, 1–100 |
skip | 0 | Number of documents to skip |
after | none | The next_cursor of the previous page: continues the list after it (see Paging with a cursor). Can’t be combined with skip |
count | false | true adds total, the number of documents matching where |
deleted | none | In a collection with soft delete: only lists the deleted documents (the trash), include both. Needs the restore rule besides read |
Each parameter may appear at most once. Any other parameter returns 400 invalid_query.
GET /v1/shop/orders/items?where={"status":"active","price":{"$between":[10,20]}}&order_by=-_meta.created_at&limit=10&count=true
{ "items": [ ... ], "limit": 10, "skip": 0, "has_more": true, "next_cursor": "…", "total": 134 }
The example is shown unencoded for readability.
where
- Top-level keys are field paths. Use dot notation for nested fields, for example
address.city. - Multiple keys, and multiple operators on one field, are combined with AND. Use
$orfor alternatives. - A bare value is shorthand for
$eq:{"status": "active"}is the same as{"status": {"$eq": "active"}}.
{
"status": "active",
"price": { "$gte": 10, "$lt": 50 },
"name": { "$istartsWith": "wid" },
"discount": { "$isNull": true }
}
Operators
| Group | Operator | Value | Matches when the field… |
|---|---|---|---|
| Comparison | $eq, $ne | value | equals / doesn’t equal the value |
$gt, $gte, $lt, $lte | number or string | is greater / less than the value | |
| Sets & ranges | $in, $nin | array | is / isn’t one of the values |
$between | [min, max] | is within the range, inclusive | |
| Null | $isNull | true / false | is null or missing / has a non-null value |
$notNull | true / false | the opposite of $isNull | |
| Text | $like, $ilike | pattern | matches the whole pattern: % is any run of characters, _ is exactly one character |
$startsWith, $istartsWith | string | starts with the value | |
$endsWith, $iendsWith | string | ends with the value | |
$contains, $icontains | string | contains the value |
- Operators prefixed with
iare case-insensitive. All other text matching is case-sensitive. - Text operators apply only to string fields.
- All characters in text values are literal, except
%and_in$like/$ilike. There is no escape character for them. $ne,$ninand$eq: nullalso match documents where the field is missing, as does$isNull: true.
Array fields
- A scalar value matches if any element matches. For example,
{"tags": "sale"}finds documents whosetagscontain"sale". - An array value matches the whole array exactly, including order.
- Properties of objects inside arrays can be queried by dot path, for example
lines.sku.
$or and $and
$or matches documents that match any of the objects in its array; $and matches documents that match all of them. Each object in the array is a where object of its own, with the same fields, operators and checks:
{
"published": true,
"$or": [
{ "title": { "$icontains": "go" } },
{ "body": { "$icontains": "go" } }
]
}
This finds published documents whose title or body contains “go”. Keys next to $or still combine with AND.
- Groups can be nested, for example an
$andinside an$or, up to 4 levels deep. - The array must not be empty, and neither may its objects.
$andis rarely needed, because keys already combine with AND. It’s useful inside an$orbranch, or to put two alternatives side by side:{"$and": [{"$or": [...]}, {"$or": [...]}]}.- The limits apply to the whole
where: conditions in every group count towards the 32. - In realms with access rules,
$oronly narrows what the caller may read. It can never widen it, because the read rule is always applied on top. - Other keys starting with
$(such as$nor,$notor$where) are rejected.
Errors inside groups name their position, for example where.$or.1.body.$icontains.
Fields and types
Every field in where and order_by must be one of:
- declared in the collection’s
schema.json; id;_meta.created_at,_meta.updated_ator_meta.version;_meta.owner,_meta.created_byor_meta.updated_by(strings;_meta.ownercan benull). Only documents in realms withauth: enabledhave them; elsewhere these fields are always missing.
Values are checked against the field’s schema type:
- a string for an
integerfield is rejected; 1.5for anintegerfield is rejected;nullis only accepted for fields whose type includesnull(use$isNullotherwise);_meta.created_atand_meta.updated_attake RFC3339 timestamps and are compared as dates, for example{"_meta.created_at": {"$gte": "2026-09-01T00:00:00Z"}}. So do the fields the schema stores as dates (x-backd-store), in any offset; text operators are refused on them.
Limits
| Limit | Value |
|---|---|
Size of where | 4096 bytes |
Conditions (operators) in where | 32 |
| Length of a text operator value | 256 characters |
Wildcards (%, _) in a $like / $ilike pattern | 4 |
Fields in order_by | 8 |
Nesting of $or / $and | 4 levels |
Errors
Invalid queries return 400 invalid_query, with one detail per problem:
{
"error": {
"code": "invalid_query",
"message": "invalid query parameters",
"details": [
{ "path": "where.age.$gt", "reason": "must be of type integer" },
{ "path": "where.secret", "reason": "unknown field" },
{ "path": "order_by.nope", "reason": "unknown field" }
],
"request_id": "d2fk1a30h2ipb32i2hh0"
}
}
Ordering and pagination
order_by=-age,namesorts byagedescending, thennameascending.idascending is always added as a final tiebreaker unlessorder_byalready includesid, so pages are stable. Since ids are time-sortable, the default order is creation order.has_moreistruewhen documents exist beyond this page.skippages by offset, so deep pages get slower and can shift if data changes between requests. A cursor doesn’t have those problems.
Paging with a cursor
When has_more is true, the answer has a next_cursor. Send it as after, with the same where and order_by, to get the next page:
GET /v1/shop/orders/items?order_by=-price,name&limit=50
GET /v1/shop/orders/items?order_by=-price,name&limit=50&after=eyJvIjoi…
- A cursor is a position, not a reading of the document it came from: it keeps working if that document is edited or deleted meanwhile, so a loop that processes and deletes documents is safe, and documents added or removed elsewhere never shift a page. Each page costs the same however far you are, if the order is indexed.
- Treat it as opaque. For the default order (
idascending) it is just theidof the last document, soafter=<id>continues after any document you know. For any other order it is an encoded position that only continues the sameorder_by: anotherorder_by, a made-up value orskipanswers400 invalid_queryonafter. - Equal values and missing or
nullvalues are handled: documents with the same sort value are told apart byid, andnullor missing values sort first, as in a normal list. - It follows
order_byfields that hold one kind of scalar value (strings, numbers, booleans, dates, ids). For fields that hold arrays, objects or several types there is nonext_cursor, andafteris refused: page those withskip. - It never shows more than the first page would:
whereand the read rules apply to everything after the cursor.count=truestill counts the whole list, not what is left. - The
audit,jobsandinvocationslists of the admin API still page withskip.