Docs
Pagination and filtering
page, page_size, sort, order, q and filter[key] on every list endpoint, plus the depth cap that silently clamps a page number instead of erroring.
Every list endpoint in the X-Radius API reads the same six query parameters and returns the same envelope. Learn them once and they work on subscribers, sessions, cards, invoices, managers and the rest.
The parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
page |
integer | 1 |
One-indexed. Values below 1 are ignored, not rejected. |
page_size |
integer | 50 |
Clamped to a maximum of 200. |
sort |
string | per endpoint | Each endpoint has its own allow-list of sortable columns; an unknown value falls back to that endpoint's default. |
order |
enum | desc |
Only asc and desc are honoured; anything else is ignored. |
q |
string | — | Free-text search. Which columns it covers is per endpoint and documented on each one. |
filter[key] |
string | — | Structured narrowing, bracket syntax. Keys are per endpoint. |
curl -s "https://acme.example.com/api/v1/users?page=2&page_size=100&sort=created_at&order=desc&filter[enabled]=true&q=ahmed" \
-H "Authorization: Bearer $XRADIUS_TOKEN"
Out-of-bounds values are clamped rather than refused. A page_size of 9999
becomes 200; a garbage order becomes desc. That is deliberate, so a client
typo cannot turn into a server-side blow-up — but it means a silently wrong
parameter looks exactly like a correct one, so read the meta block back
rather than assuming your request was taken verbatim.
Filters are per endpoint, not global
There is no central filter registry. The shared parser keeps every
filter[key] it finds as an opaque string and hands the bag to the handler,
which validates its own keys against its own list. Two consequences:
- A filter key an endpoint does not know is ignored silently. It does not error, and it does not narrow anything. If a filter appears to do nothing, check its spelling against that endpoint's documented keys first.
- A filter key that is known but carries an unparseable value usually does
return
400 ERR_VALIDATION— dates and numeric ranges are validated. So "ignored" and "rejected" both happen, and which one you get depends on whether the key was recognised.
Server-injected scoping is ANDed on top of whatever you send. A manager without
tenant-wide visibility sees only their own subtree, and a
filter[parent_id] pointing outside it narrows within the subtree rather than
widening past it. You cannot reach another reseller's rows by crafting a
filter.
The meta block
{
"data": [ { "id": 4711, "username": "ahmed" } ],
"meta": {
"page": 2,
"page_size": 100,
"total": 812,
"has_next": true
}
}
page and page_size are echoed as the server resolved them, after
clamping — compare them with what you sent. total is the row count for the
whole filtered set, not the page. has_next is computed server-side; deriving
it yourself from total and page_size is the classic off-by-one at the last
page boundary, so use the field.
Three optional fields appear on a minority of endpoints:
aggregates— numeric rollups over the whole filtered set rather than the page: invoice sums, report totals, ledger opening and closing balances.truncated— only on the few un-paginated endpoints that carry a hard row cap.truemeans the cap bit and rows were dropped. A paginated endpoint says the same thing withhas_next.cachedandcached_at— set by exactly one endpoint, the design-template store, which serves a local mirror when its upstream catalogue is unreachable.
A few endpoints return everything in one page by design: NAS devices, quota
buckets, roles, webhook endpoints. Those either omit meta entirely or report
page_size equal to total with has_next false. Do not write a paging loop
against an endpoint that has no meta.
The depth cap, and why deep paging goes quiet
This is the trap worth reading twice.
Paging is offset-based, and Postgres answers OFFSET n by producing and
discarding n rows. A request for page=2000000000 is therefore a full
sequential scan of the tenant's table, occupying a connection and the shared
buffer pool for minutes. One client can do that in a loop.
So the offset is capped at 1,000,000 rows, and a page beyond the cap is
clamped, not rejected. There is no 400, no warning field, and no
details.reason. You asked for page 40,000 at 50 per page, you received page
20,000, and the response looks entirely normal.
The meta block is where this becomes visible: meta.page echoes the clamped
value. So:
requested page=40000, page_size=50 → meta.page = 20000
If meta.page is not the page you asked for, you hit the cap. That is the only
signal, and a client that never reads meta.page will happily re-fetch the
same page forever while its cursor counts upward.
The clamp scales with your page size, because the bound is expressed in rows,
not pages: at page_size=50 the deepest reachable page is 20,000; at
page_size=200 it is 5,000.
The tail of a list stays reachable by flipping order. If you genuinely
need the far end of a set larger than a million rows, sort the other way and
read from the other end. If you need the whole set, do not page it at all — use
an export job, which streams server-side with
no offset arithmetic.
When not to page
Paging is for showing people a screen of rows. For extracting a whole table,
POST /api/v1/exports queues an asynchronous job that streams the rows into a
CSV or XLSX file and reports progress, and GET /api/v1/exports/{id}/download
fetches the artifact when it is ready. It respects the same authority scope
your list view does, and it never touches the offset cap.
A paging loop over hundreds of thousands of rows will be slower, will be rate-limited, and will hit the cap. The export path exists precisely so you do not have to write one.
Last updated