OSPulse

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

ParameterTypeRole
takeintegerHow many items to return in this page.
skipintegerHow 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.)

bash
# 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.

json
{
  "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
}
itemsarrayRequired

The 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.

totalEstimateintegerRequired

An 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.

takeintegerRequired

The 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.

skipintegerRequired

The 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 fieldWhat it meansWhere you will meet it
totalEstimateApproximate count of all matching rowsThe majority of collections — policies, violations, alerts, vulnerabilities, exposures, quantum findings
totalReturnedCount of items in this page onlynot a collection totalGET /api/v1/packages, watched packages, package health history
totalExact count, on small bounded collectionsSource-control connections, available repositories
hasMore + nextCursorCursor pagination — follow the cursor, ignore skipBilling invoices
(none)The response is the complete setCertificates, 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:

js
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:

UseSafe
"About 428 packages" in a UIYes
Sizing a progress bar that tolerates driftYes
Deciding whether a collection is large enough to warrant a background jobYes
Computing the number of pages to fetchNo
Asserting a record count in a testNo
Reconciling an export against a source systemNo

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:

ShapeExamples
take and skipGET /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 windowGET /api/v1/scans, GET /api/v1/scans/{scanId}/logs, GET /api/v1/packages/{packageId}/health/history
A bounded result with no offsetGET /api/v1/repositories/{repositoryId}/dependency-graph caps traversal with maxEdges; GET /api/v1/analytics/export/destinations/{id}/runs caps with limit
No pagingSmall, 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.

bash
#!/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 ))
done

Three details in those loops matter:

  1. Termination is data-driven. The loop ends when a page is shorter than the applied page size, not when a counter says it should.
  2. skip advances by the applied take. If the server clamped your page size, advancing by your requested value skips records.
  3. Filters are held constant. Changing a filter mid-iteration renumbers the offsets and invalidates every skip computed 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 changeEffect on iteration
A row is inserted before your current offsetEvery later row shifts one position later. One row is seen twice.
A row is deleted before your current offsetEvery later row shifts one position earlier. One row is missed.
A row is inserted or deleted after your current offsetAppears or disappears naturally on a later page. Harmless.
A row is updated in placeYou see whichever version was current when its page was fetched.

Mitigations, in increasing order of effort:

  • Deduplicate by identifier as you consume. A Set of 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}/logs accepts sinceUtc, 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 sizeWhen it fits
10–25Interactive UI. Fast first paint; the user rarely scrolls past page two.
50–100Background synchronisation and scripts. The best balance of round trips against response size and memory.
Large valuesRarely 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=100000 requires 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 take than you sent, you exceeded the endpoint's maximum. Adopt the echoed value; sending a larger one again wastes a round trip and may return 400.
  • Page in one process. Parallel workers each computing their own skip against a changing collection multiply the duplicate-and-miss problem rather than sharing it.

Next steps