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.

The examples on this page use the tables of a sample invoicing app (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
ParameterFormatExample
whereA JSON filter tree, URL-encoded (below){"status":"on_hold"}
orderUp to 3 columns, column.asc or column.descdue_at.asc,amount.desc
columnsColumn names, comma-separatedinvoice_number,amount,due_at
includeUp to 3 references, named without _uidvendor
limitRows per page, 1 to 1,000 (default 50)25
offsetRows to skip50
afterKeyset paging: empty for the first page, then paging.nextCursor
total1 to count every matching row into paging.total1

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 }
}
  • count is the number of items on this page.
  • total is null unless you ask for it with total=1. Counting every matching row is a second query, so it runs only when asked.
  • hasMore is true while another page may follow. Without total, a full page counts as one that may have more.
  • Every list is a page. Without limit it holds 50 rows; a limit above 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:

WriteMatches
{"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. $contains and $startsWith take 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: null is IS NOT NULL, and $eq: null is IS NULL.
  • Rows and references are matched by uid. {"uid": "<uid>"} finds one row. A reference is filtered under its _uid name: {"vendor_uid": "<a vendor's uid>"} lists that vendor's invoices, and {"vendor_uid": {"$in": [...]}} several vendors'. Filtering on id, org_id or the raw vendor_id is refused with ROW_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.asc or column.desc. The direction defaults to asc. order=status.asc,due_at.desc sorts by status, then by due date within each status.
  • id means creation order. The id itself is never returned, but order=id.asc sorts by it.
  • A uid carries no order, so sorting by uid is refused.
  • A reference sorts under its _uid name, 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 fieldTakes
measuresUp to 12 of { op, column, as }. op is count, count_distinct, sum, avg, min or max; column is optional for count.
groupByUp 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.
filtersThe same filter tree as where, with the same limits.
havingConditions on a measure's as name: { "total": { "$gt": 10000 } }.
sort"<as> DESC" or "<as> ASC".
limitAt 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

CodeStatusMeans
QUERY_INVALID400Malformed JSON in where, an unknown operator or column, or a sort direction other than asc or desc.
QUERY_TOO_COMPLEX400Past a limit: conditions, nesting, $in values, text length, sort columns or size.
PAGE_CURSOR_INVALID400A cursor this API did not issue for this table, or after sent with order or offset.
COLUMN_REDACTED400A filter or sort on a redacted column.
ROW_FIELD_NOT_ACCEPTED400id, a scope column or a raw foreign key; details names the field to use.
QUERY_TIMEOUT422The query ran longer than one request may.
AGG_SPEC_INVALID422An 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.

Next

On this page