Querying
Filter, sort, project, include referenced rows, page and aggregate a table over REST.
A table's list endpoint, GET /{appUid}/autocrud/{table}, takes a filter tree, a sort, a choice of
columns, referenced rows to include, and two ways to page.
invoices, vendors, payments). Your app has only the tables you create, so put your own table and column names in their place. About the example app| Parameter | Format | Example |
|---|---|---|
where | A JSON filter tree, URL-encoded (below) | {"status":"on_hold"} |
order | Up to 3 columns, column.asc or column.desc | due_at.asc,amount.desc |
columns | Column names, comma-separated | invoice_number,amount,due_at |
include | Up to 3 references, named without _uid | vendor |
limit | Rows per page, 1 to 1,000 (default 50) | 25 |
offset | Rows to skip | 50 |
after | Keyset paging: empty for the first page, then paging.nextCursor | |
total | 1 to count every matching row into paging.total | 1 |
Any other query parameter is refused rather than ignored.
curl --get "$AUTHORIC_URL/$APP_UID/autocrud/invoices" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'where={"status":{"$in":["received","matched"]},"due_at":{"$lt":"2026-12-01"}}' \
--data-urlencode 'order=due_at.asc' \
--data-urlencode 'include=vendor' \
--data-urlencode 'limit=25'The client SDK builds the same request from list({ where, order, ... }).
The response
Lists always return the same envelope:
{
"items": [
{
"uid": "0199b8e5-2f61-7a3c-8d40-91b6e7c2a5f3",
"invoice_number": "MS-88461",
"vendor_uid": "0199b8e1-9c04-7a8b-b2d3-6e5f1c8a4d07",
"amount": "8760.00",
"status": "received",
"due_at": "2026-11-20T00:00:00.000Z",
"vendor": {
"uid": "0199b8e1-9c04-7a8b-b2d3-6e5f1c8a4d07",
"name": "Meridian Surgical Supply",
"status": "active"
}
}
],
"paging": { "limit": 25, "offset": 0, "count": 1, "total": null, "hasMore": false }
}countis the number of items on this page.totalisnullunless you ask for it withtotal=1. Counting every matching row is a second query, so it runs only when asked.hasMoreistruewhile another page may follow. Withouttotal, a full page counts as one that may have more.- Every list is a page. Without
limitit holds 50 rows; alimitabove 1,000 is lowered to 1,000. - Soft-deleted rows are never listed.
Filtering with where
where is a JSON object. Each key names a column, and its value says what to match:
| Write | Matches |
|---|---|
{"status": "on_hold"} | Equality |
{"memo": null} | IS NULL |
{"amount": {"$gte": 1000}} | A comparison: $eq, $ne, $gt, $gte, $lt, $lte |
{"status": {"$in": ["received", "matched"]}} | Any of up to 100 values |
{"due_at": {"$between": ["2026-11-01", "2026-11-30"]}} | An inclusive range, [low, high] |
{"memo": {"$contains": "tariff"}} | The column contains this text |
{"invoice_number": {"$startsWith": "ST-"}} | The column starts with this text |
{"$or": [{...}, {...}]}, {"$and": [...]} | Any of, or all of, the trees listed |
- Keys are ANDed.
{"status": "approved", "amount": {"$gte": 1000}}needs both, and several operators on one column are ANDed too:{"amount": {"$gte": 100, "$lt": 1000}}. - Text matching is literal.
$containsand$startsWithtake plain text of up to 200 characters;%and_are matched as themselves, never as wildcards. Whether case matters follows the column's collation, which usually ignores case. $ne: nullisIS NOT NULL, and$eq: nullisIS NULL.- Rows and references are matched by uid.
{"uid": "<uid>"}finds one row. A reference is filtered under its_uidname:{"vendor_uid": "<a vendor's uid>"}lists that vendor's invoices, and{"vendor_uid": {"$in": [...]}}several vendors'. Filtering onid,org_idor the rawvendor_idis refused withROW_FIELD_NOT_ACCEPTED, naming the field to use instead. - The column must exist. An unknown column or operator is refused with
QUERY_INVALID, so a typo fails loudly instead of returning the whole table.
A filter holds at most 20 conditions, nests $and / $or at most 3 deep, and where is at most
4 KB. Past any of these the request is refused with QUERY_TOO_COMPLEX.
{
"$or": [
{ "status": "disputed" },
{ "status": "on_hold", "amount": { "$gte": 10000 } }
]
}Sorting with order
- Up to 3 columns, comma-separated, each
column.ascorcolumn.desc. The direction defaults toasc.order=status.asc,due_at.descsorts by status, then by due date within each status. idmeans creation order. Theiditself is never returned, butorder=id.ascsorts by it.- A
uidcarries no order, so sorting byuidis refused. - A reference sorts under its
_uidname, in the order the referenced rows were created (not alphabetically by uid). You will rarely want that; sort by a column of your own.
Choosing columns
columns=invoice_number,amount answers only those columns. The row's uid always comes back. A
reference is named by its _uid name (vendor_uid). A redacted
column may be selected, and comes back masked, as it does without columns.
Including referenced rows
include=vendor adds each invoice's vendor beside its vendor_uid, under the reference's name
without _uid. Up to 3 references, comma-separated.
- Each included row is read as you. The referenced table's own permissions, organization scope
and redaction apply, exactly as if you had listed it yourself. A row you cannot read comes back
null, the same as a reference that is empty or points at a deleted row, so an include never reveals that a row exists. - One level deep. An include follows a reference from this row to the row it names. It does not list child rows.
- The included rows are read once per page, not once per row.
Paging
There are two ways to page, and they answer different needs.
Offset paging is for a screen of results. Pass limit and offset, and always an order.
Without one the database is free to return rows in any order, and pages may overlap or skip rows.
# First page
curl "$AUTHORIC_URL/$APP_UID/autocrud/invoices?order=due_at.asc,id.asc&limit=25&offset=0&total=1" \
-H "Authorization: Bearer $TOKEN"
# Next page
curl "$AUTHORIC_URL/$APP_UID/autocrud/invoices?order=due_at.asc,id.asc&limit=25&offset=25" \
-H "Authorization: Bearer $TOKEN"Keyset paging is for walking a whole table. Pass after, empty for the first page, then each
page's paging.nextCursor, until it is null. Pages run in creation order. A row added or removed
between requests never shifts another onto two pages, and a deep page costs what the first one does.
# First page: an empty cursor
curl --get "$AUTHORIC_URL/$APP_UID/autocrud/invoices" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'where={"status":"approved"}' \
--data-urlencode 'limit=200' \
--data-urlencode 'after='
# Next page: the previous page's paging.nextCursor
curl --get "$AUTHORIC_URL/$APP_UID/autocrud/invoices" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'where={"status":"approved"}' \
--data-urlencode 'limit=200' \
--data-urlencode "after=$NEXT_CURSOR"The cursor is opaque and sealed by the API. It works only for the table it came from and only as
given. A changed cursor, one from another table, or one sent with order or an offset is a 400
with PAGE_CURSOR_INVALID. A keyset page answers offset: null.
The older form
filter=column:value and sort=column DIR still work. filter is one equality (everything after
the first colon is the value, so filter=memo:Retainer: first half matches that whole memo), and it
is ANDed with where when both are given. sort is one column, and is ignored when order is
given. New code should use where and order.
Counts and totals
GET /{appUid}/autocrud/{table}/aggregate?spec=<JSON> computes counts, sums, averages, minimums
and maximums in the database, grouped, and answers only the groups.
curl --get "$AUTHORIC_URL/$APP_UID/autocrud/invoices/aggregate" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'spec={"groupBy":[{"column":"status"}],"measures":[{"op":"count","as":"invoices"},{"op":"sum","column":"amount","as":"total"}],"sort":"total DESC"}'{
"rows": [
{ "status": "paid", "invoices": 7, "total": "71842.00" },
{ "status": "matched", "invoices": 3, "total": "34920.00" }
],
"meta": { "groups": 2, "matchedGroups": 2, "hasMore": false, "truncated": false, "limit": 1000 }
}| Spec field | Takes |
|---|---|
measures | Up to 12 of { op, column, as }. op is count, count_distinct, sum, avg, min or max; column is optional for count. |
groupBy | Up to 8 of { column }, optionally with truncate (year, quarter, month, week, day, hour) for a date, or bucket (a positive width) for a number. A reference groups by its _uid name. |
filters | The same filter tree as where, with the same limits. |
having | Conditions on a measure's as name: { "total": { "$gt": 10000 } }. |
sort | "<as> DESC" or "<as> ASC". |
limit | At most 1,000 groups. |
An aggregate takes the same permission as listing the table, and sees the same rows: your
organization's, never a soft-deleted one. A redacted column cannot be grouped, measured or
filtered. A spec that names an unknown or redacted column is a 422 with AGG_SPEC_INVALID.
Filtering is not authorization
where narrows what you asked for. It does not decide what you are allowed to see: policy and your
organization apply before your filter runs, and the filter is one group ANDed after them, so an
$or can never reach past them. Removing a filter never widens access, and a filter on another
organization's vendor uid simply matches nothing.
A redacted column cannot shape an answer either. Filtering or
sorting on one is refused with COLUMN_REDACTED.
Slow queries
A list (and its count) may run for 5 seconds. A query that runs longer is stopped and answers
422 QUERY_TIMEOUT. Narrow the filter, or add an index on the columns it filters or sorts by.
Errors to branch on
| Code | Status | Means |
|---|---|---|
QUERY_INVALID | 400 | Malformed JSON in where, an unknown operator or column, or a sort direction other than asc or desc. |
QUERY_TOO_COMPLEX | 400 | Past a limit: conditions, nesting, $in values, text length, sort columns or size. |
PAGE_CURSOR_INVALID | 400 | A cursor this API did not issue for this table, or after sent with order or offset. |
COLUMN_REDACTED | 400 | A filter or sort on a redacted column. |
ROW_FIELD_NOT_ACCEPTED | 400 | id, a scope column or a raw foreign key; details names the field to use. |
QUERY_TIMEOUT | 422 | The query ran longer than one request may. |
AGG_SPEC_INVALID | 422 | An aggregate spec the API cannot run; issues names each problem. |
Inside a handler
A handler reads the same governed rows with the same filter language:
sdk.readList, sdk.readPage (keyset paging) and sdk.aggregate. It can also write by condition
(updateWhere, deleteWhere), which the REST API does not offer. Full signatures are in the
Handler SDK reference.