S
supero.docs
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}/...        # aggregation
Every 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 needUse
Simple paged listing, no field filtersGET list
Equality (or basic range) filters with cursor pagination, hierarchy/back-ref scoping, or a legacy 1-indexed page/page_size paramPOST /list-bulk
A specific known set of UUIDs, still paginatedPOST /list-bulk with obj_uuids
Nested AND/OR/NOT, regex, contains/starts_with/ends_with, between, or a keywords shorthandPOST /query
A total count, sum, average, distinct values, or grouped counts without pulling every rowGET/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 keyTypeNotes
typestringRequired. Bare (product) or namespace-qualified (billing:invoice).
filterobjectThe richest option — a nested condition tree (see below).
matchobjectShorthand for an AND of exact-match field:value pairs: {"status": "active", "featured": true}.
keywordsarray or stringShorthand for an "in" match against a keywords array field on the record (only useful if your schema has one).
filtersobjectLegacy, 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.
sortarray[{"field": "price", "order": "asc"}, ...]. A bare string field name (or a list of strings) is accepted too and sorts descending by default.
fields / projectionarray or "a,b,c"Limits which fields come back. uuid and fq_name are always included regardless.
fq_name_prefixarrayExplicit fq_name path segments to scope by. Can only narrow your token's existing scope, never widen it.
limitintegerDefault 50, max 1000.
offsetintegerDefault 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"}
  ]
}
OperatorMeaningValue shape
eq / neequals / not equalsany scalar
gt / gte / lt / ltegreater / greater-or-equal / less / less-or-equalnumber or ISO date string
in / not_invalue is / is not one of a setarray
contains / starts_with / ends_withsubstring, prefix, suffix matchstring
regexregular expression matchstring (validated server-side before it runs)
existsfield is present / absentboolean
betweeninclusive range[min, max]
allarray field contains every given valuearray
and / or / notcombine 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 keyTypeNotes
typestringRequired.
filtersstring or objectEquality 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).
fieldsstring or arrayComma-separated or array of field names to include.
limit / offset / cursorsee Read Parameter Reference below
sort_by / sort_ordersee Read Parameter Reference below
include_totalbooleanAdds pagination.total.
page / page_sizeintegerLegacy 1-indexed alternative to offset/limit. Paging this way works only on this endpoint — see Read Parameter Reference below.
parent_id / parent_uuidstringComma-separated parent UUID(s) to scope by hierarchy; parent_uuid is an accepted alias.
fq_name_prefixarrayExplicit fq_name path segments to scope by. Can only narrow your token's existing scope, never widen it.
obj_uuidsarrayFetch a specific known set of UUIDs, still paginated.
back_ref_idstringComma-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

ParameterApplies toMeaning
type / obj_type/list-bulk, /queryRequired in the body on both. GET list names the type in the URL path instead.
filter/queryThe 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, /queryEquality 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/queryShorthand for an AND of exact-match field:value pairs.
keywords/queryShorthand for an in match against a keywords array field.
fields/list-bulk, /queryField list to return. GET list ignores it and returns whole records.
projection/queryAlias of fields on /query; fields wins if you send both. uuid and fq_name always come back regardless.
obj_uuids/list-bulkFetch a specific known set of UUIDs, still paginated.
back_ref_id/list-bulkComma-separated UUID(s) — return objects that reference these.
parent_id / parent_uuidGET list, /list-bulkScope to children of a given parent UUID; parent_uuid is an accepted alias. Query string on GET, body on /list-bulk.
parent_typeGET listAccepted alongside parent_id and then ignored — the parent UUID alone decides the scope.
fq_name_prefix/list-bulk, /queryExplicit fq_name path segments to scope by. Can only narrow your token's existing scope, never widen it.
tenant / projectGET list, /list-bulkNarrow 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

ParameterApplies toMeaning
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_byGET list, /list-bulkField 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_orderGET 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

ParameterApplies toMeaning
limitGET list, /list-bulk, /queryPage size. Default 50, max 1000 — a larger value is clamped silently.
offsetGET list, /list-bulk, /querySkip N records. Default 0.
cursorGET list, /list-bulkOpaque 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_totalGET list, /list-bulkAdds 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-bulkLegacy 1-indexed paging, translated to offset/limit, and honoured only when you omit limit. GET list and /query ignore it.
page_size/list-bulk, /queryPartner 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"
}
FieldPresent whenMeaning
limitalways (once pagination is active)The page size actually used.
has_morealways (once pagination is active)Whether another page exists.
offsetyou sent offset (not cursor)Echoes the offset used for this page.
totalyou sent include_total: trueFull matching-record count across all pages.
next_cursorhas_more is truePass 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.
OperationMethodEndpoint
Forward referencesGET/api/v1/crud/{domain}/{type}/{uuid}/refs
Forward references, one type as a flat listGET/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 referencesPUT/api/v1/crud/{domain}/ref-update-multi
Resolve fq_name → uuidPOST/api/v1/crud/{domain}/fq-name-to-uuid
Resolve uuid → fq_namePOST/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.
EndpointMethodPurpose
/api/v1/aggregate/{domain}/{type}/aggregatePOSTFull pipeline, or a match + group_by + metrics builder.
/api/v1/aggregate/{domain}/{type}/statsGETRecord count plus per-field sum/avg/min/max.
/api/v1/aggregate/{domain}/{type}/distinct/{field}GETDistinct values of a field.
/api/v1/aggregate/{domain}/{type}/count-by/{field}GETRecord counts grouped by a field's value.
/api/v1/aggregate/{domain}/{type}/time-series/{field}GETTime-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.