Requires findings:read.

Use order=recent, and assert it

Why the order matters

severity is the backlog view a person reads. recent is the one a machine enumerates with. Pretending they are equivalent is how a client trusts a traversal it should not — and for a gate deciding whether a release ships, a missed row is a clean result reported by something that never saw the finding that mattered. page.stable reports which of the two you got, on every response. Assert it; that is what it is for. An unknown order is refused rather than defaulted, because falling back would hand a caller who asked for the exhaustive order the browsable one, and it would traverse happily while missing rows. Offsets are not offered at all. LIMIT 50 OFFSET 50 re-runs the ordering and counts forward, so rows inserted before the cursor position between two requests shift everything and the client silently skips some. Findings are written constantly.

What a cursor is bound to

Opaque, signed, and bound to the tenant, the order and the filter set. Any of those changing invalidates it — a position in one sequence is meaningless in another. In particular:
Do not change status, severity, kind or asset_id mid-traversal. Resuming across a filter change would return neither the old set nor the new one, with nothing in the response to say so, which is why the cursor is rejected instead.
Cursors expire after 24 hours. That is not a security control: a traversal resumed a day later silently misses everything created since, and failing loudly beats paginating through a set that no longer exists. If you need to resume a long job, start it again rather than storing a cursor overnight.

Limits and cost

  • limit is clamped to 200. page.limit reports the value actually used, so read it back rather than assuming your request was honoured.
  • A cursor over 4096 characters is rejected before any work.
  • has_more comes from rows already read — each page is fetched with n+1 and trimmed — so it costs nothing extra.
  • total is a separate indexed aggregate. It is returned today, and it is the half of a listing that will get expensive first; do not call the endpoint in a loop purely to read it.

Gotchas

  • A cursor from another tenant is refused even though it is genuinely signed. The signature prevents forgery; forgery is not the threat. A cursor reaching the wrong tenant through a shared log, a copied URL or a support ticket is.
  • Filters are comma-separated: status=open,triaged, severity=critical,high.
  • 400 on a listing usually means a stale, re-scoped or expired cursor. Restart the traversal.
  • The asset listing is in creation order, not “most recently scanned first”. last_scanned_at changes on every scan, so a row would move mid-traversal.

See also