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. true means the cap bit and rows were dropped. A paginated endpoint says the same thing with has_next.
  • cached and cached_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

Ask a question

Try it on your own network.

50 subscribers for 7 days. You pay nothing.