Developers/API Reference/Querying & Pagination
Querying & Pagination
How to filter, sort, page through, and aggregate CRUD data across GET list, POST /list-bulk, and POST /query — plus references and fully-qualified-name resolution.
Overview
Supero gives you three different ways to read a collection of records, and they are not interchangeable: GET list for simple paged listing, POST /list-bulk for filtered + paginated + hierarchy-aware reads, and POST /query for the richest filter grammar. Picking the wrong one is the single most common cause of "my filter is being ignored" bugs. This page covers all three in depth, plus following references, resolving fully-qualified names, and running aggregations. For create/get/update/delete, see CRUD Endpoints.
All of it lives under the same base path, scoped by your domain name:
text
https://api.supero.dev/api/v1/crud/{domain}/{type} # GET list
https://api.supero.dev/api/v1/crud/{domain}/list-bulk # POST bulk read
https://api.supero.dev/api/v1/crud/{domain}/query # POST rich query
https://api.supero.dev/api/v1/aggregate/{domain}/{type}/... # aggregationEvery call needs either Authorization: Bearer <jwt> or an X-API-Key: ak_... header — see Authentication for how to obtain either credential. Read scope (which domain/project/tenant you can see) comes from that credential — an optional project or tenant query parameter can narrow it further, never widen it.
GET, /list-bulk, or /query — Which One
Why GET list can't filter
🚨 GET list drops unknown query params
GET /api/v1/crud/{domain}/{type} only recognizes a fixed set of query params: limit, offset, cursor, sort_by, sort_order, include_total for pagination, and parent_id / parent_uuid, parent_type, tenant, project for scoping. Anything else you add — ?status=active, ?category=shoes, ?price[gt]=10 — is parsed off the query string and then simply never reaches the filter engine. The call still returns 200 with an unfiltered page of results, which is easy to miss in testing.
bash
# Returns EVERY product, ignoring ?status= entirely — not an error, just ignored
curl "https://api.supero.dev/api/v1/crud/acme-corp/product?status=active" \
-H "Authorization: Bearer $TOKEN"To filter, reach for POST /list-bulk or POST /query instead — both apply filters server-side before returning results. Use this to decide between them:
| You need | Use |
|---|---|
| Simple paged listing, no field filters | GET list |
| Equality (or basic range) filters with cursor pagination, hierarchy/back-ref scoping, or a legacy 1-indexed page/page_size param | POST /list-bulk |
| A specific known set of UUIDs, still paginated | POST /list-bulk with obj_uuids |
| Nested AND/OR/NOT, regex, contains/starts_with/ends_with, between, or a keywords shorthand | POST /query |
| A total count, sum, average, distinct values, or grouped counts without pulling every row | GET/POST /api/v1/aggregate/{domain}/{type}/... (see Aggregation below) |
💡 The SDK already knows this
org.crud.query(type).filter(...) and org.crud.find(type, **filters) always route through POST /query, never through GET — so this gotcha only bites code that hand-builds a GET request with its own query-string filters.
A bare list is scoped to your token’s project
⚠️ GET list narrows to one project, silently
GET /api/v1/crud/{domain}/{type} on one of your own schema types returns only the records in the single project your token was issued for — including when you are a domain_admin. Records of that type in your other projects are absent from the response, with no error and no warning: the call returns 200 and a shorter results array. This is the most common reason a list looks unexpectedly empty. To read across the whole domain, use POST /list-bulk with an fq_name_prefix of just the domain name.
bash
# Clamped to the token's project — a domain_admin sees one project's products
curl "https://api.supero.dev/api/v1/crud/acme-corp/product" \
-H "Authorization: Bearer $TOKEN"
# Domain-wide: one fq_name_prefix segment constrains only the domain
curl -X POST "https://api.supero.dev/api/v1/crud/acme-corp/list-bulk" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "product",
"fq_name_prefix": ["acme-corp"],
"detail": true,
"limit": 200
}'json
{
"success": true,
"results": [
{"uuid": "abc-001", "name": "widget-a", "fq_name": ["acme-corp", "storefront", "northwind", "widget-a"]},
{"uuid": "abc-042", "name": "widget-z", "fq_name": ["acme-corp", "warehouse", "northwind", "widget-z"]}
],
"result_count": 2
}Widening this way requires an admin role: for other roles fq_name_prefix can still narrow scope, but a prefix broader than the token's own scope is clamped back down rather than honoured. Built-in domain-level types (project, audit_log, and similar) are already domain-wide on a bare list — the project clamp applies to the schema types you define yourself. POST /query behaves differently again: for an admin it applies no project scoping of its own, so it reads domain-wide without an explicit prefix.
POST /query — Rich Reads
POST /api/v1/crud/{domain}/query takes a required type plus any combination of a structured filter tree, a match shorthand, a keywords shorthand, or the legacy equality-only filters object — all combined with AND.
bash
curl -X POST "https://api.supero.dev/api/v1/crud/acme-corp/query" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "product",
"filter": {
"op": "and",
"conditions": [
{"field": "in_stock", "op": "eq", "value": true},
{"field": "price", "op": "between", "value": [20, 100]}
]
},
"sort": [{"field": "price", "order": "asc"}],
"fields": ["uuid", "name", "price"],
"limit": 25
}'json
{
"success": true,
"results": [
{"uuid": "abc-010", "fq_name": ["acme-corp", "default-project", "northwind", "usb-c-hub"], "name": "usb-c-hub", "price": 49.99}
],
"result_count": 1
}| Body key | Type | Notes |
|---|---|---|
| type | string | Required. Bare (product) or namespace-qualified (billing:invoice). |
| filter | object | The richest option — a nested condition tree (see below). |
| match | object | Shorthand for an AND of exact-match field:value pairs: {"status": "active", "featured": true}. |
| keywords | array or string | Shorthand for an "in" match against a keywords array field on the record (only useful if your schema has one). |
| filters | object | Legacy, equality-only: either {"field_names":[...], "field_values":[...]} or a flat {"field": "value"} dict. Every value is compared with eq — for ranges or other operators use filter instead. |
| sort | array | [{"field": "price", "order": "asc"}, ...]. A bare string field name (or a list of strings) is accepted too and sorts descending by default. |
| fields / projection | array or "a,b,c" | Limits which fields come back. uuid and fq_name are always included regardless. |
| fq_name_prefix | array | Explicit fq_name path segments to scope by. Can only narrow your token's existing scope, never widen it. |
| limit | integer | Default 50, max 1000. |
| offset | integer | Default 0. |
Anything not in that table is not part of the /query grammar. In particular sort_by, sort_order, cursor, page, parent_id, obj_uuids and back_ref_id belong to GET list or /list-bulk and are ignored here — see the Read Parameter Reference below for which endpoint takes which.
The filter condition tree
A leaf condition names a field, an operator, and a value. A logical group nests conditions under and, or, or not:
json
// Leaf
{"field": "status", "op": "eq", "value": "active"}
// Logical group (recursive — conditions can themselves be groups)
{
"op": "or",
"conditions": [
{"field": "category", "op": "eq", "value": "electronics"},
{"field": "tags", "op": "contains", "value": "clearance"}
]
}| Operator | Meaning | Value shape |
|---|---|---|
| eq / ne | equals / not equals | any scalar |
| gt / gte / lt / lte | greater / greater-or-equal / less / less-or-equal | number or ISO date string |
| in / not_in | value is / is not one of a set | array |
| contains / starts_with / ends_with | substring, prefix, suffix match | string |
| regex | regular expression match | string (validated server-side before it runs) |
| exists | field is present / absent | boolean |
| between | inclusive range | [min, max] |
| all | array field contains every given value | array |
| and / or / not | combine sub-conditions | {"conditions": [...]} |
⚠️ No pagination envelope on /query
Unlike GET list and /list-bulk, POST /query's response is only {success, results, result_count} — it never returns a pagination object, so there's no total or has_more to read, and no cursor support. Page it yourself with limit/offset, or use GET list / POST /list-bulk when you need cursor pagination or a total count.
POST /list-bulk — Bulk Reads and Pagination
POST /api/v1/crud/{domain}/list-bulk is the general-purpose read endpoint: equality (and basic range) filters, full cursor/offset pagination, hierarchy scoping, and batch UUID fetches, all in one call.
bash
curl -X POST "https://api.supero.dev/api/v1/crud/acme-corp/list-bulk" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "product",
"filters": "status==active,category==electronics",
"limit": 20,
"sort_by": "name",
"sort_order": "asc"
}'json
{
"success": true,
"results": [
{"uuid": "abc-010", "name": "usb-c-hub", "status": "active", "category": "electronics"}
],
"result_count": 1,
"pagination": {"limit": 20, "has_more": false}
}| Body key | Type | Notes |
|---|---|---|
| type | string | Required. |
| filters | string or object | Equality DSL string "field==val,field2==val2", or an object — either {"field_names":[...], "field_values":[...]}, or a flat {"field": "value"} dict whose values can also be a range object (see below). |
| fields | string or array | Comma-separated or array of field names to include. |
| limit / offset / cursor | see Read Parameter Reference below | |
| sort_by / sort_order | see Read Parameter Reference below | |
| include_total | boolean | Adds pagination.total. |
| page / page_size | integer | Legacy 1-indexed alternative to offset/limit. Paging this way works only on this endpoint — see Read Parameter Reference below. |
| parent_id / parent_uuid | string | Comma-separated parent UUID(s) to scope by hierarchy; parent_uuid is an accepted alias. |
| fq_name_prefix | array | Explicit fq_name path segments to scope by. Can only narrow your token's existing scope, never widen it. |
| obj_uuids | array | Fetch a specific known set of UUIDs, still paginated. |
| back_ref_id | string | Comma-separated UUID(s) — return objects that reference these. |
The dict form of filters supports Mongo-style range operators per field — $gte, $gt, $lte, $lt, $ne, $in, $nin — anywhere the string DSL only gives you equality:
json
{
"type": "product",
"filters": {"price": {"$gte": 20, "$lt": 100}, "category": "electronics"},
"limit": 20
}Query-string-only flags trim the response payload for app clients: exclude_fields (comma-separated field names), exclude_back_refs, exclude_refs, exclude_children (presence-only flags), and client=app (drops a fixed set of housekeeping fields like description, id_perms, and href).
Read Parameter Reference
This is the canonical list of read parameters for the three read endpoints, and the only page that enumerates them in full — anywhere else in these docs that names a read parameter either matches this table or links here. The three endpoints do not share one grammar. A parameter sent to an endpoint that does not take it is dropped rather than rejected, so the call still returns 200 — with an unfiltered, unsorted, or mis-paged result.
⚠️ The two sort grammars are the most common mix-up
POST /query sorts with sort: [{"field": ..., "order": "asc"}]. GET list and POST /list-bulk sort with the flat sort_by / sort_order pair. Neither endpoint family understands the other's spelling and neither complains: sort_by in a /query body is ignored and you get default ordering, and a sort array on GET list or /list-bulk is ignored the same way. Copy the sort form from the row for the endpoint you are actually calling.
Selecting and filtering
| Parameter | Applies to | Meaning |
|---|---|---|
| type / obj_type | /list-bulk, /query | Required in the body on both. GET list names the type in the URL path instead. |
| filter | /query | The nested condition tree with the full operator set. There is no equivalent on GET list or /list-bulk — those two cannot express or, contains, regex, between, or exists at all. |
| filters | /list-bulk, /query | Equality pairs on both. Only /list-bulk also accepts the per-field range operators $gte, $gt, $lte, $lt, $ne, $in, $nin; on /query the same nested value is compared with eq and matches nothing. |
| match | /query | Shorthand for an AND of exact-match field:value pairs. |
| keywords | /query | Shorthand for an in match against a keywords array field. |
| fields | /list-bulk, /query | Field list to return. GET list ignores it and returns whole records. |
| projection | /query | Alias of fields on /query; fields wins if you send both. uuid and fq_name always come back regardless. |
| obj_uuids | /list-bulk | Fetch a specific known set of UUIDs, still paginated. |
| back_ref_id | /list-bulk | Comma-separated UUID(s) — return objects that reference these. |
| parent_id / parent_uuid | GET list, /list-bulk | Scope to children of a given parent UUID; parent_uuid is an accepted alias. Query string on GET, body on /list-bulk. |
| parent_type | GET list | Accepted alongside parent_id and then ignored — the parent UUID alone decides the scope. |
| fq_name_prefix | /list-bulk, /query | Explicit fq_name path segments to scope by. Can only narrow your token's existing scope, never widen it. |
| tenant / project | GET list, /list-bulk | Narrow the read below your credential's own scope, never above it. Not read from a /query body — /query takes its scope from the credential alone. |
Sorting
| Parameter | Applies to | Meaning |
|---|---|---|
| sort | /query | [{"field": "price", "order": "asc"}, ...]. A bare string field name (or a list of strings) is accepted too and sorts descending by default. Ignored by GET list and /list-bulk. |
| sort_by | GET list, /list-bulk | Field to sort on. Defaults to created_at once any pagination param is present. Not validated against your schema — an unknown field name sorts on a missing key rather than erroring. Ignored by /query. |
| sort_order | GET list, /list-bulk | "asc" or "desc". Defaults to desc, and any value other than "asc" is treated as desc rather than rejected. Ignored by /query. |
ℹ️ Sorting a live connector-backed schema through /query
A sort has to be pushed down to the source, and only a single-field sort can be. Send a multi-field sort array against a live (no-copy) schema and the call returns a clear error instead of quietly dropping the extra keys — sort on one field, or switch that table to sync.
Pagination
| Parameter | Applies to | Meaning |
|---|---|---|
| limit | GET list, /list-bulk, /query | Page size. Default 50, max 1000 — a larger value is clamped silently. |
| offset | GET list, /list-bulk, /query | Skip N records. Default 0. |
| cursor | GET list, /list-bulk | Opaque cursor from a previous response's pagination.next_cursor. Mutually exclusive with offset — send both and cursor wins. A malformed cursor is ignored and you silently get page one. Not supported on /query. |
| include_total | GET list, /list-bulk | Adds pagination.total — a full matching-record count. Costs a full scan; request it on the first page only, then omit it on subsequent pages. /query has no pagination object to put a total in. |
| page | /list-bulk | Legacy 1-indexed paging, translated to offset/limit, and honoured only when you omit limit. GET list and /query ignore it. |
| page_size | /list-bulk, /query | Partner of page on /list-bulk. On /query it is read only as a fallback for limit — it sets the page size but does not page, so sending page + page_size to /query returns the first page. |
Send no pagination parameters at all and GET list / /list-bulk behave exactly like a pre-pagination client expects: every matching record comes back, and the response has no pagination key at all.
Send any pagination parameter and a pagination object appears in the response:
json
{
"limit": 20,
"has_more": true,
"offset": 0,
"total": 47,
"next_cursor": "eyJ1dWlkIjoiYWJjLTAyMCJ9"
}| Field | Present when | Meaning |
|---|---|---|
| limit | always (once pagination is active) | The page size actually used. |
| has_more | always (once pagination is active) | Whether another page exists. |
| offset | you sent offset (not cursor) | Echoes the offset used for this page. |
| total | you sent include_total: true | Full matching-record count across all pages. |
| next_cursor | has_more is true | Pass this back as cursor on the next call. |
💡 Iterating every page
Loop: send cursor from the previous response's pagination.next_cursor, stop when has_more is false (or next_cursor is absent). This works at any page depth, unlike offset which degrades on very deep pages — prefer cursor for full iteration and infinite scroll.
References and FQ-Name Resolution
Beyond field filters, you can walk a record's references, its back-references (who points at it), and resolve between a record's UUID and its fully-qualified name.
| Operation | Method | Endpoint |
|---|---|---|
| Forward references | GET | /api/v1/crud/{domain}/{type}/{uuid}/refs |
| Forward references, one type as a flat list | GET | /api/v1/crud/{domain}/{type}/{uuid}/refs/{ref_type} |
| Back-references (who points at this record) | GET | /api/v1/crud/{domain}/{type}/{uuid}/back-refs |
| Batch add / update / delete references | PUT | /api/v1/crud/{domain}/ref-update-multi |
| Resolve fq_name → uuid | POST | /api/v1/crud/{domain}/fq-name-to-uuid |
| Resolve uuid → fq_name | POST | /api/v1/crud/{domain}/uuid-to-fq-name |
/refs and /back-refs both accept optional query params (ref_type or back_ref_type to filter to one reference type, include_back_refs=true on /refs to include the reverse direction in the same call) and return a grouped-by-type shape:
json
{
"success": true,
"uuid": "9f2b1c3a-...",
"obj_type": "project",
"fq_name": ["acme-corp", "default-project"],
"refs": {
"user_account": [
{"uuid": "u1", "fq_name": ["acme-corp", "default-project", "northwind", "alice"], "attr": {"role": "admin"}}
]
},
"total_refs": 1
}ref-update-multi batches reference edits in one call. Each entry names the owning (source) record, the target reference type/uuid, and an operation:
json
{
"updates": [
{"type": "order", "uuid": "order-uuid", "ref-type": "customer", "ref-uuid": "cust-uuid", "operation": "ADD"},
{"type": "order", "uuid": "order-uuid", "ref-type": "coupon", "ref-uuid": "old-coupon-uuid", "operation": "DELETE"}
]
}fq-name-to-uuid and uuid-to-fq-name are the two directions of the same lookup — handy when your integration only has one identifier and needs the other:
javascript
// POST /api/v1/crud/acme-corp/fq-name-to-uuid
{"type": "product", "fq_name": ["acme-corp", "default-project", "northwind", "usb-c-hub"]}
// -> {"success": true, "uuid": "abc-010", "data": {...}}
// POST /api/v1/crud/acme-corp/uuid-to-fq-name
{"type": "product", "uuid": "abc-010"}
// -> {"success": true, "data": {"fq_name": ["acme-corp", "default-project", "northwind", "usb-c-hub"], ...}}Aggregation
A dedicated aggregate API answers count/sum/average/distinct/group-by questions server-side, without pulling every matching row over the wire.
| Endpoint | Method | Purpose |
|---|---|---|
| /api/v1/aggregate/{domain}/{type}/aggregate | POST | Full pipeline, or a match + group_by + metrics builder. |
| /api/v1/aggregate/{domain}/{type}/stats | GET | Record count plus per-field sum/avg/min/max. |
| /api/v1/aggregate/{domain}/{type}/distinct/{field} | GET | Distinct values of a field. |
| /api/v1/aggregate/{domain}/{type}/count-by/{field} | GET | Record counts grouped by a field's value. |
| /api/v1/aggregate/{domain}/{type}/time-series/{field} | GET | Time-bucketed counts over a timestamp field, on warehouse-connected schemas. |
bash
# Count + per-field stats
curl "https://api.supero.dev/api/v1/aggregate/acme-corp/order/stats?fields=amount&match={\"status\":\"completed\"}" \
-H "Authorization: Bearer $TOKEN"
# -> {"success": true, "data": {"count": 7, "fields": {"amount": {"sum": 625, "avg": 89.3, "min": 20, "max": 200}}}}
# Distinct values
curl "https://api.supero.dev/api/v1/aggregate/acme-corp/product/distinct/category" \
-H "Authorization: Bearer $TOKEN"
# -> {"success": true, "data": {"values": ["electronics", "clothing", "food"], "count": 3}}
# Grouped counts
curl "https://api.supero.dev/api/v1/aggregate/acme-corp/order/count-by/status?sort=-count&limit=10" \
-H "Authorization: Bearer $TOKEN"
# -> {"success": true, "data": {"groups": [{"value": "active", "count": 4}, {"value": "inactive", "count": 2}]}}All three GET endpoints accept an optional match query param — a JSON-encoded object of exact-match field:value pairs — to scope the aggregation, plus a limit (distinct: default 1000, max 10000; count-by: default 100, max 1000, plus a sort of count, -count, value, or -value).
POST /aggregate supports two payload shapes: a raw MongoDB-style pipeline, or a builder shorthand (match + group_by + metrics):
json
{
"match": {"status": "active"},
"group_by": "category",
"metrics": {"count": true, "total": {"$sum": "$amount"}}
}⚠️ Raw pipelines need a synced schema
A raw pipeline can't be pushed down to an external source, so it only runs against a normal (synced) schema — on a live, no-copy connector table it returns a clear error telling you to use stats / count-by / distinct instead, or switch that table to sync.
time-series buckets a timestamp field into minute, hour, day, or month buckets (?bucket=hour) for monitoring-style charts — it only works against a warehouse-connected (live) schema; a normal local schema has no equivalent.
Python SDK: Query Builder and Aggregates
org.crud.query() opens a chainable builder that always compiles down to POST /query (or a plain GET when you call it with no filters at all), so you get server-side filtering without hand-building the request body.
python
from supero import login
org = login(domain_name="acme-corp", email="[email protected]", password="...")
active = (
org.crud.query("product")
.filter(status="active", category="electronics")
.order_by("-price")
.limit(10)
.all()
)
first_match = org.crud.query("product").filter(status="active").first()
how_many = org.crud.query("product").filter(status="active").count()
# Shortcut for query(type).filter(**filters).all()
high_priority = org.crud.find("task", priority="high", done=False)
# Simple pagination without filters (maps to GET list)
page = org.crud.list("task", limit=20, offset=20)Aggregates are typed accessor methods, each backed by the /aggregate/... endpoints above — every one accepts optional **filters as exact-match keyword arguments:
python
total_orders = org.crud.count("order")
completed = org.crud.count("order", status="completed")
revenue = org.crud.sum("order", "amount", status="completed")
avg_price = org.crud.avg("product", "price")
categories = org.crud.distinct("product", "category")
by_category = org.crud.count_by("product", "category") # {"electronics": 3, "clothing": 2, ...}
price_stats = org.crud.stats("product", "price") # {"count": 7, "sum": 625, "avg": 89.3, "min": 20, "max": 200}💡 pip install supero
The client is synchronous — there is no async client. Catch AuthenticationError, AuthorizationError, and RateLimitError from supero for auth and rate-limit failures. Use connect(domain_name=..., api_key="ak_...") instead of login() for server-to-server calls.
Next Steps
Related reading: For the complete set, see Schema Reference.
- •CRUD Endpoints — the basic create/get/update/delete calls every schema type gets, plus how response envelopes and errors work.
- •Schema API — how fields, references, extends, and namespaces define what fields a type has and what you can filter on.
- •API Overview & Conventions — base URL, auth headers, and conventions shared across every endpoint.
- •Python SDK — the full org.crud, org.aggregate, and query-builder surface beyond what's shown here.
On this page