Query parameters
Three routes build a query from the URL. Everything on this page applies to them and to their anonymous mirrors:
| Route | Builds a query from the URL |
|---|---|
GET /data/rest/{entity} | yes |
GET /data/rest/{entity}/id/{id} | yes — the path id becomes the primary-key predicate |
POST /data/rest/{entity}/search | yes — the body filter is AND-ed on top |
GET /data/anon/rest/{entity} | yes |
GET /data/anon/rest/{entity}/id/{id} | yes |
POST /data/anon/rest/{entity}/search | yes |
Mutation routes (POST/PUT/PATCH/DELETE /data/rest/{entity}…) and the bulk envelope
(/data/rest) parse the behaviour flags only. Query-AST keys and unreserved
keys are read and discarded there — they never become filters.
Nothing on this page reaches the rest of the surface, and each of those routes reads the query
string its own way: POST /data/graphql reads ?datastore= and nothing else, /data/actions/{name}
binds every query parameter as an action parameter on GET and DELETE, /data/x/* hands the
whole query string to the script, and each /data/admin/* route reads ?datastore= plus its own
parameters. All of those are on Operations.
Route shapes, bodies and status codes are on REST operations. The semantics behind each flag are on Request flags; the AST these parameters build is the query DSL.
How parameters are read
Section titled “How parameters are read”| Rule | Detail |
|---|---|
| Query-AST keys | Case-sensitive, exact match. ?select=, not ?Select= |
| Behaviour flags | Case-insensitive. ?sendStats=, ?sendstats=, ?SENDSTATS= are one flag |
| Repeated key | First value wins; the rest are dropped |
| Empty value | ?status= is skipped entirely, not matched against an empty string |
| Unknown key | Never a 400. On a read it becomes an equality filter |
| Non-numeric integer | ?limit=abc parses to 0 and is ignored |
Filtering
Section titled “Filtering”There is no operator syntax in the query string. Every filter is a string-typed equality, and all of them are AND-ed.
| Form | Compiles to |
|---|---|
?status=paid (any unreserved key) | "status" = 'paid' |
?status=paid&owner_id=42 | "status" = 'paid' AND "owner_id" = '42' |
?filter=status=paid | "status" = 'paid' — split on the first =, both sides trimmed |
POST …/search body {"filter":{"status":"paid"}} | one equality per top-level key, AND-ed onto the URL filters |
Values from the query string are always bound as strings. ?total=100 binds '100'; on a numeric
column PostgreSQL coerces it, on a column where the cast fails the request returns 500. The search
body is the only place a filter value keeps its JSON type, and only for strings, numbers and
booleans. A nested object such as {"total":{"$gt":100}} is stringified rather than interpreted as
an operator, an array is stringified, and null is bound as the literal text <nil> — never as
SQL NULL.
A ?filter= value with no = in it is dropped silently.
A filter on an undeclared column fails the compile before any SQL runs, and the failure is indistinguishable from a database fault on the wire — see Compile failures. That is also how a typo’d flag surfaces; see Parses but never runs.
Field selection
Section titled “Field selection”| Parameter | Value | Notes |
|---|---|---|
select | comma-separated field names | Wins when both are present |
fields | comma-separated field names | Accepted spelling of the same thing |
Names are trimmed; empty elements are dropped. Only plain field names are accepted — ?select= has
no aggregate, alias or DISTINCT syntax, so ?select=count() is refused as an identifier before
SQL is emitted and returns 500.
With no projection the response carries the entity’s declared fields in canonical order — the
compiler emits the column list, never SELECT *. With a projection and ?include=, the
entity’s primary key is prepended when your list omits it, so the rows come back one column wider
than you asked for.
Ordering
Section titled “Ordering”| Parameter | Value |
|---|---|
order_by | comma-separated sort terms — wins when both are present |
orderby | same |
Each term is field, -field, field desc, or field asc. The direction suffix is matched
case-insensitively; the default is ascending.
GET /data/rest/orders?orderby=-created_at,idOmit the parameter and the compiler still emits an ORDER BY — the entity’s declared default
order, otherwise primary key ascending — so pages are stable without one. That injected sort is not
visible to the cursor logic; see below.
Pagination
Section titled “Pagination”Offset and page
Section titled “Offset and page”| Parameter | Applied when | Result |
|---|---|---|
limit | > 0 | LIMIT <n> |
pagesize | limit absent or 0 | LIMIT <n> |
offset | > 0 | OFFSET <n> |
page | offset absent or 0, and a limit resolved | OFFSET (page - 1) * limit |
Page numbers are 1-based, so ?page=1 produces no offset. ?page=3 with no limit and no
pagesize is ignored silently — there is nothing to multiply by.
GET /data/rest/orders?page=3&pagesize=25The engine injects a 10,000-row cap: a request with no limit gets LIMIT 10000, and a limit
above the cap is lowered to it. A capped result looks exactly like a complete one — there is no
marker in the response.
Cursor
Section titled “Cursor”| Parameter | Value |
|---|---|
after | forward cursor |
before | backward cursor |
The meaning depends on whether you sent ?orderby=.
With ?orderby= | Without ?orderby= |
|---|---|
| The value is decoded as an opaque keyset token and compiled to a keyset predicate over the sort columns | The value is bound verbatim as a primary-key bound: after → pk > '<value>', before → pk < '<value>' |
Tokens come back in the envelope’s page block:
{"data":[…],"count":25,"page":{"has_next_page":true,"end_cursor":"eyJ2IjoxLCJmIjpb…"}}page is emitted only when a cursor could be minted, which requires rows in the result and an
explicit ?orderby=. The injected default sort does not qualify, so a read without ?orderby=
never returns end_cursor. has_next_page is a heuristic — it is true when the page came back
full, so the last page of an exact multiple reports one more page than exists.
Relation expansion
Section titled “Relation expansion”| Parameter | Value |
|---|---|
include | comma-separated relation names declared on the entity |
GET /data/rest/orders?include=customer,items&limit=20Each relation is fetched by a separate statement and grafted onto the parent rows under the
relation name. Over HTTP only the relation name is expressible: there is no query-string syntax for
a child projection, child filter, child sort, child limit, or nesting. ?include=user.org is read
as one relation named user.org and fails.
An unrecognised relation fails the compile like an unknown field does — 500, code
v2.query_failed, generic message. See Compile failures.
Grouping
Section titled “Grouping”| Parameter | Value |
|---|---|
groupby | comma-separated column names |
GROUP BY is emitted, and the default sort injection is skipped for grouped reads. Because
?select= cannot express an aggregate, a grouped read that projects any column outside the group
list fails in the database, not in the compiler. Grouped reads are practical from a declared action
or a scripted endpoint, not from the query string.
Behaviour flags
Section titled “Behaviour flags”Case-insensitive, truthy set true, t, yes, y, on, 1. Full semantics on
Request flags.
| Flag | Value | Effect on a read |
|---|---|---|
deleted | bool | Drops the injected soft-delete predicate so deleted rows are returned |
datastore | name | Selects the tenant datastore; omit for the schema default |
version | N, first, latest, latest-N, -N | Pins the version on entities with a versioned history strategy |
expression | jq program | Replaces the response body with the result of the program run over the whole envelope |
unmask | bool | Stacks the entity’s unmask access rule; does not change the body |
untokenize | bool | Stacks the entity’s decrypt access rule; does not change the body |
download | true | Get-by-id only: switches to file streaming. Returns 501 v2.file_downloader_unconfigured; without an id, 400 v2.download_needs_id |
field | field name | Names the file field for download |
On a get-by-id, a soft-deleted row without ?deleted=true returns 404 v2.row_not_found.
?deleted=true carries no permission of its own — any caller with read on the entity can use it,
including over /data/anon/rest/{entity}.
The mutation-side flags — purge, delta, deletechildren, noversion, scd/cdc,
customstateflow, event, sendhash, alias — are parsed on read routes too and change nothing
there. skipstateflow is the exception: it is not reserved, so on a read it becomes a filter and
fails the request. See Parses but never runs.
Response-shape flags
Section titled “Response-shape flags”| Flag | Spellings | Adds |
|---|---|---|
stats | stats, sendstats | stats: sql, duration_ms, include_sql[], rows_affected |
meta | meta, sendmeta | meta: entity, primary_key, fields[] (name, type, required, nullable) |
states | states, sendstates | states[]: one entry per row, keyed by stateflow name, each {field, current, events[]}. Omitted entirely when the entity declares no stateflow |
allowedactions | allowedactions, sendallowedactions | allowed_actions[]: which of create, read, update, delete, purge, restore, unmask, decrypt the entity declares an access rule for — the fixed four create, read, update, delete when it declares none |
allowed_actions is read off the entity declaration, not evaluated against the caller — a name in
the list is not a promise the request will pass its access rule.
count is always present and is the number of rows in data. total and stats.total_count are
not populated on reads — the engine never computes a total count, so there is no way to get a
match count alongside a page. On a mutation that returns no rows, total carries the affected-row
count instead.
Parses but never runs
Section titled “Parses but never runs”| Parameter | What happens |
|---|---|
having | Reserved, so it is not turned into a filter — and then never read. No HAVING is emitted, no error is returned |
depth | Reserved and never read. Include nesting is not controllable from the query string |
asof | Parsed into a point-in-time option and left out of the reserved set, so it also becomes a filter on a column named asof. See below |
skipstateflow | Same defect: parsed as a flag and also filtered on |
download | Routes to the file path, which returns 501 in the shipping service |
free-text query in the search body | Returns 501 v2.search_text_not_supported |
string filter in the search body | Returns 501 v2.search_dsl_not_supported |
Compile failures
Section titled “Compile failures”| Condition | Status | Code |
|---|---|---|
Undeclared field in select, orderby, groupby or a filter | 500 | v2.query_failed / v2.search_query_failed |
Unknown relation in include | 500 | v2.query_failed / v2.search_query_failed |
A value that is not a plain identifier, such as select=count() | 500 | v2.query_failed / v2.search_query_failed |
| Entity not in the addressed schema | 404 | v2.entity_not_found |
| Get-by-id matched nothing | 404 | v2.row_not_found |
| Access denied on the read | 403 | v2.query_failed / v2.search_query_failed |
expression fails to compile | 400 | v2.bad_expression |
expression fails at runtime | 500 | v2.expression_runtime |
| Search body is not JSON | 400 | v2.search_body_parse_failed |
Search body carries query | 501 | v2.search_text_not_supported |
Search body filter is a string | 501 | v2.search_dsl_not_supported |
Access denial is the exception that keeps its status: the request-time access gate returns a typed
error that reaches the HTTP layer intact, so it maps to 403. Status selection for the errors that
survive sanitising is partly substring matching on the message text, so branch on code, never on
the status.
Examples
Section titled “Examples”Filtered, projected, sorted, paged list:
curl -G https://<host>/data/rest/orders \ -H "Authorization: Bearer $TOKEN" \ -H "X-Customer: acme" -H "X-Product: erp" -H "X-Env: prod" -H "X-Tenant: main" \ --data-urlencode "status=paid" \ --data-urlencode "select=id,total,status,created_at" \ --data-urlencode "orderby=-created_at,id" \ --data-urlencode "limit=25"Follow the cursor, sending the same sort back:
curl -G https://<host>/data/rest/orders \ -H "Authorization: Bearer $TOKEN" \ -H "X-Customer: acme" -H "X-Product: erp" -H "X-Env: prod" -H "X-Tenant: main" \ --data-urlencode "orderby=-created_at,id" \ --data-urlencode "limit=25" \ --data-urlencode "after=eyJ2IjoxLCJmIjpb..."Structured search, with the URL supplying pagination and projection:
curl -X POST "https://<host>/data/rest/orders/search?limit=50&select=id,total" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Customer: acme" -H "X-Product: erp" -H "X-Env: prod" -H "X-Tenant: main" \ -H "Content-Type: application/json" \ -d '{"filter":{"status":"paid","region":"eu"}}'Soft-deleted rows from a second datastore, reshaped with jq:
curl -G https://<host>/data/rest/invoice \ -H "Authorization: Bearer $TOKEN" \ -H "X-Customer: acme" -H "X-Product: erp" -H "X-Env: prod" -H "X-Tenant: main" \ --data-urlencode "deleted=true" \ --data-urlencode "datastore=reporting" \ --data-urlencode "expression=.data | map({id, deletedon})"