Every high-volume listing in the API is cursor-paginated. Offsets are not offered, and the reason is worth reading before you build anything on top of this.

Offsets are not slow, they are wrong

LIMIT 50 OFFSET 50 re-runs the whole ordering and counts forward. If rows are inserted before the cursor position between two requests, everything shifts by one and the client silently skips a row. Findings are written constantly — every scan produces some. For a person browsing a backlog that is an annoyance they will probably never notice. For a machine enumerating findings to decide whether a release ships, it is a clean result reported by a gate that never saw the row that mattered. No error is raised, because from the database’s point of view nothing went wrong. Keyset pagination asks a different question — “give me what comes after this exact position” — which no concurrent insert can perturb.

Reading a page

Pass next_cursor back to fetch the following page:
next_cursor is absent on the final page, and has_more is false. Loop until has_more is false rather than until a page comes back short — a page may be short for reasons that have nothing to do with being last.

Two orders, two guarantees, both declared

The default is severity, because the default caller is the dashboard and a person works a backlog worst-first. Machines should enumerate with order=recent. It is the order whose keys cannot change underneath a traversal, which is exactly what makes the traversal exhaustive. severity is not broken; it is honest about what it is. Severity is re-rated when advisory data improves, and last-seen changes on every scan. A finding that moves mid-traversal moves relative to your cursor, so it can land on a page you have already read or on one you have already passed. That is fine for browsing and unsuitable for anything that must be complete. Pretending both orders are equivalent is how a client comes to trust a traversal it should not, which is why every response reports page.stable. Assert it rather than assume it:
An unknown order value is refused rather than defaulted. Falling back would hand a caller who asked for the exhaustive order the browsable one, and it would traverse happily while missing rows — a failure the caller has no way to detect.

What a cursor is

A cursor is a position, not a credential and not a query.
It is opaque, so clients cannot construct one; signed, so clients cannot alter one; and bound to four things, so a cursor issued in one context is meaningless in another. The tenant binding is the one that gets left out, on the reasoning that the signature already prevents forgery. It does — and forgery is not the threat. The threat is a genuine cursor arriving somewhere it was never meant to be. Changing order or any filter parameter invalidates the cursor you are holding. Keep them identical for the whole traversal:

Cursors expire after 24 hours

This is not a security control — the signature is. It exists because a traversal resumed a day later silently misses everything created since, and a client that believes it enumerated a set while holding a stale position is wrong in the direction that matters for a security product. Failing loudly beats paginating through a set that no longer exists. An expired cursor, a forged one, one for the wrong tenant and one for a different filter set all produce the same error. Telling a caller their cursor was valid but for the wrong tenant would confirm it was issued to somebody, which is a disclosure a paging error has no business making.

Limits and the effective page size

limit accepts 1–200 and defaults to 50. A larger value is clamped, and the effective value is reported in page.limit. Read the echoed value rather than the one you sent. Clamping silently inside the query would tell a client that its request for 100,000 rows succeeded, and a client paginating on its own assumption would skip almost everything. A cursor longer than 4096 characters is rejected before any work is done.

total

total is returned alongside every page. It is a count of the whole filtered set, not of the page. Treat it as advisory. It is computed independently of the page you are holding, so under concurrent writes it can disagree with what a full traversal yields — and it is recorded internally as the part of a listing that will become expensive before the rows do.

Which listings are cursor-paginated

GET /assets, GET /assets/{id}/findings, GET /scans, GET /findings and GET /findings/{id}/events take limit and cursor; the finding listings also take order. The remaining listings — schedules, policies, policy versions, verdicts, webhook deliveries — are bounded by limit rather than paginated, because the answer is always near the top and the collections do not grow without bound. See Endpoints.

One consequence, named

The asset listing is ordered by creation, not by “most recently scanned first”. The latter cannot be paginated safely: last_scanned_at changes on every scan, so a row moves mid-traversal. It is also its own usability problem — a list that reorders itself while somebody is reading it.