Pagination
Collection endpoints return a page of results, not the whole collection. OSPulse uses offset
pagination: you ask for a window with take and skip, and you get back the items plus the
counters needed to request the next window.
The model
| Parameter | Type | Role |
|---|---|---|
take | integer | How many items to return in this page. |
skip | integer | How many items to step over before the page begins. |
Page n (zero-indexed) is skip = n × take. There is no Link header — for almost every
collection, your position is entirely described by the two numbers you send. (Billing
invoices are the one exception and use a cursor; see
not every collection uses the same counter.)
# First page of 50
curl "https://api.ospulse.app/api/v1/packages?take=50&skip=0" \
-H "Authorization: Bearer $OSPULSE_TOKEN"
# Second page of 50
curl "https://api.ospulse.app/api/v1/packages?take=50&skip=50" \
-H "Authorization: Bearer $OSPULSE_TOKEN"Both parameters are optional. Omit them and the endpoint applies its own default page size —
so a request with no parameters returns a page, never the entire collection. Always read
the take and skip echoed in the response rather than assuming your values were used.
The response envelope
Paged endpoints return an object, never a bare array. Adding fields to an object is backwards-compatible; changing the top-level type of a response is not.
{
"items": [
{
"violationId": "7c2a9e13-4b8f-4a02-9d5e-1f6b3c8a0d27",
"policyId": "b41f8c2e-91a7-4d63-8f2a-0c7e5d914b33",
"severity": "critical",
"status": "open",
"detectedAtUtc": "2026-07-28T09:20:11Z"
}
],
"totalEstimate": 428,
"take": 50,
"skip": 0
}itemsarrayRequiredThe page of results. Ordered by the endpoint's own default ordering. Its length is at most
take, and may be shorter — on the final page, or where post-query filtering removed rows.
An empty array is a valid response, not an error.
totalEstimateintegerRequiredAn approximate count of the matching rows across the whole collection, for the same filters. Suitable for sizing a progress bar or a "roughly N results" label. Not exact — see below.
takeintegerRequiredThe page size actually applied. May differ from what you sent if you omitted it or exceeded
the endpoint's accepted maximum. Use this value, not your own, when computing the next
skip.
skipintegerRequiredThe offset actually applied. Echoed back so a response can be interpreted without reference to the request that produced it.
Not every collection uses the same counter
This is the detail that most often breaks an integration. The envelope above is the most common shape, but it is not universal — the counter field varies by endpoint, and a few collections carry no counter at all.
| Counter field | What it means | Where you will meet it |
|---|---|---|
totalEstimate | Approximate count of all matching rows | The majority of collections — policies, violations, alerts, vulnerabilities, exposures, quantum findings |
totalReturned | Count of items in this page only — not a collection total | GET /api/v1/packages, watched packages, package health history |
total | Exact count, on small bounded collections | Source-control connections, available repositories |
hasMore + nextCursor | Cursor pagination — follow the cursor, ignore skip | Billing invoices |
| (none) | The response is the complete set | Certificates, TLS observations, feature flags, admin queues |
totalReturned is not a total
totalReturned counts the rows in the page you are holding. On a request for 50 items it
will be 50 on every full page — including the first — so using it to compute a page count
produces an infinite loop or a single truncated page. When an endpoint returns
totalReturned, page until you receive fewer items than you asked for, and stop there.
The safest iteration strategy works for every shape above, because it never depends on a counter at all:
async function* paginate(path, take = 100) {
let skip = 0
for (;;) {
const page = await get(`${path}?take=${take}&skip=${skip}`)
const items = page.items ?? []
yield* items
// A short page is the end of the collection, whatever the counter says.
if (items.length < take) return
// Honour the server's echoed values where present.
skip = (page.skip ?? skip) + (page.take ?? take)
}
}If you are unsure which shape a given endpoint returns, check it in the interactive reference — the response schema is generated directly from the API contract.
Why totalEstimate is an estimate
The name is literal. totalEstimate is produced without taking a consistent snapshot of the
collection, for three reasons:
- Cost. An exact count over a large, filtered, multi-tenant table is a full scan. Running one on every page request would make paging slower than the query it accompanies.
- Concurrency. Scans, imports, and webhook ingest write continuously. A count taken microseconds before the page query can already be stale by the time the response is serialised.
- Statistics. For large collections the count may be derived from planner statistics rather than an exhaustive count, which trades a small error for a large speed-up.
Do not do arithmetic on totalEstimate
Never compute the number of pages as totalEstimate / take and loop that many times. The
value can move up or down between requests, so the loop may stop early and silently drop
records, or run past the end and issue pointless requests. Terminate on a short or empty
page instead.
Safe uses of totalEstimate:
| Use | Safe |
|---|---|
| "About 428 packages" in a UI | Yes |
| Sizing a progress bar that tolerates drift | Yes |
| Deciding whether a collection is large enough to warrant a background job | Yes |
| Computing the number of pages to fetch | No |
| Asserting a record count in a test | No |
| Reconciling an export against a source system | No |
For an exact count, request the data and count what you received, or use a purpose-built
endpoint — GET /api/v1/tenants/current/usage reports tenant usage counters, and the
dashboard and analytics endpoints return computed aggregates rather than page metadata.
Which endpoints page
Support varies by endpoint, and the reference is authoritative. The common shapes are:
| Shape | Examples |
|---|---|
take and skip | GET /api/v1/packages, GET /api/v1/vulnerability-exposures, GET /api/v1/quantum/tls-observations, GET /api/v1/repositories/{repositoryId}/quantum/findings |
take only — a capped most-recent window | GET /api/v1/scans, GET /api/v1/scans/{scanId}/logs, GET /api/v1/packages/{packageId}/health/history |
| A bounded result with no offset | GET /api/v1/repositories/{repositoryId}/dependency-graph caps traversal with maxEdges; GET /api/v1/analytics/export/destinations/{id}/runs caps with limit |
| No paging | Small, naturally bounded collections such as GET /api/v1/api-tokens and GET /api/v1/integrations |
Check the interactive reference for the endpoint you are calling
before assuming skip is accepted. Sending an unsupported parameter is ignored, which fails
silently — you receive page one repeatedly.
Iterating a full collection
The correct loop increments skip by the take the server reports, and stops when a page
comes back shorter than the requested size.
#!/usr/bin/env bash
set -euo pipefail
TAKE=100
SKIP=0
while : ; do
page=$(curl -sS --fail-with-body \
"https://api.ospulse.app/api/v1/packages?take=${TAKE}&skip=${SKIP}" \
-H "Authorization: Bearer ${OSPULSE_TOKEN}")
count=$(printf '%s' "$page" | jq '.items | length')
printf '%s' "$page" | jq -c '.items[]'
# Short page means the collection is exhausted.
[ "$count" -lt "$TAKE" ] && break
SKIP=$(( SKIP + TAKE ))
doneThree details in those loops matter:
- Termination is data-driven. The loop ends when a page is shorter than the applied page size, not when a counter says it should.
skipadvances by the appliedtake. If the server clamped your page size, advancing by your requested value skips records.- Filters are held constant. Changing a filter mid-iteration renumbers the offsets and
invalidates every
skipcomputed so far.
Stability while data changes
Offset pagination reads a moving collection. Between page 1 and page 2, another process may have inserted or deleted a row — and OSPulse writes continuously as scans complete and webhooks arrive. Two consequences follow directly from how offsets work:
| Concurrent change | Effect on iteration |
|---|---|
| A row is inserted before your current offset | Every later row shifts one position later. One row is seen twice. |
| A row is deleted before your current offset | Every later row shifts one position earlier. One row is missed. |
| A row is inserted or deleted after your current offset | Appears or disappears naturally on a later page. Harmless. |
| A row is updated in place | You see whichever version was current when its page was fetched. |
Mitigations, in increasing order of effort:
- Deduplicate by identifier as you consume. A
Setof seen ids costs almost nothing and removes the duplicate case entirely. - Iterate quickly. A loop that finishes in seconds has a far smaller exposure window than one that pauses between pages to do expensive work. Collect first, process afterwards.
- Constrain by a time window where the endpoint supports it.
GET /api/v1/scans/{scanId}/logsacceptssinceUtc, which turns the read into an append-only tail rather than an offset walk. - Accept approximation for reporting. For a dashboard, a missed or duplicated row on a
428-row collection is noise. For an export feeding a downstream system of record, use a
purpose-built export endpoint —
POST /api/v1/tenants/current/data-export,GET /api/v1/audit-log/export, or the warehouse export destinations — which produce a consistent snapshot rather than a page walk.
Ordering is endpoint-defined
The API does not expose a general sort parameter. Each endpoint applies its own default
ordering, documented in the reference. Do not depend on the ordering of items beyond what
the endpoint states, and do not assume it is stable enough to reconstruct global order by
concatenating pages.
Choosing a page size
| Page size | When it fits |
|---|---|
| 10–25 | Interactive UI. Fast first paint; the user rarely scrolls past page two. |
| 50–100 | Background synchronisation and scripts. The best balance of round trips against response size and memory. |
| Large values | Rarely worth it. Response size and server work grow with the page, latency grows with it, and a failed request costs more to retry. |
Practical guidance:
- Deep offsets get slower.
skip=100000requires the database to walk and discard those rows. If you routinely page that deep, narrow the query with filters instead of paging further. - Do not tune per endpoint by guesswork. Start at 100 for batch work and only change it if you measure a reason to.
- Respect the clamp. If the response echoes a smaller
takethan you sent, you exceeded the endpoint's maximum. Adopt the echoed value; sending a larger one again wastes a round trip and may return400. - Page in one process. Parallel workers each computing their own
skipagainst a changing collection multiply the duplicate-and-miss problem rather than sharing it.
