Skip to content

Pagination & deltas

List endpoints are designed for polling: each response page ends with a cursor, and re-using the last cursor you received later returns only what changed since. One mechanism serves both the initial backfill and the ongoing delta feed.

The contract

  • Items are returned in a stable, monotonic order, oldest change first. This order is what makes the cursor a reliable resume point, and it is the only ordering the API offers — list endpoints take no sort parameter.

    The order is not the updated_at field you see on the resource. An item is placed in the stream by when the platform finished processing it, which can be slightly after the change itself. In practice that means a page can contain an item whose updated_at is older than one you saw earlier. That is expected and is what guarantees nothing is skipped — see Delivery guarantees below. Sort by updated_at yourself if you need it.

    The exception is DNS blocks: a block is an immutable event, so that stream is simply ordered by blocked_at (ties broken by id) and the processing-time note above does not apply to it. - Every list response carries a pagination object:

    {
      "next_cursor": "eyJ1IjoiMjAyNi0wOC0wMVQxNjoyMjo0MVoi...",
      "page_size": 100
    }
    
  • next_cursor is opaque. Do not parse, construct or modify it — its internal format can change without notice. page_size is informational and equals the length of the returned array.

  • limit controls the page size: default 100, minimum 1, maximum 500. A limit outside that range is a 400 — it is never silently clamped.

Delivery guarantees

  • Delivery is at-least-once: an item that changes while you are paging can appear again on a later page. De-duplicate on the resource id — the last occurrence wins. Nothing is ever silently dropped from the stream.
  • A page can be shorter than your limit and still carry a cursor. Do not treat a short page as the end of the stream; only next_cursor: null means that. The platform briefly withholds very recent changes so they cannot be skipped, so a short page usually means "more is on its way".

Two clocks: updated_at and stream_position

Every list row carries both, and they answer different questions. Using the wrong one is the most common mistake in a SIEM integration, and it fails quietly rather than loudly.

Field Answers Use it for
updated_at When did the resource itself last change? Reporting, ageing, SLA clocks
stream_position When did this version reach you? Alerting, "what is new to me"

stream_position is the stream's ordering key — the same position the cursor encodes. It is non-decreasing across a cursor walk, so it is the field to compare against your own high-water mark.

Why you cannot use updated_at for that. A finding first seen in January and untouched since still has a January updated_at when it arrives on your very first poll today. Point a detection rule with a 30-minute lookback at updated_at and it matches nothing at all: every row is historical by that clock, however recently it arrived. Point it at stream_position and it sees exactly what the poll delivered.

The two also move independently. A resource can be re-delivered at a later stream_position with its updated_at unchanged — that is at-least-once delivery working as designed, and stream_position is what lets you tell a re-delivery from a change.

Two caveats:

  • stream_position is not a business timestamp. It moves whenever the platform re-projects the row, including for reasons you did not cause. Never show it to an end user as "last updated"; that is updated_at.
  • It is null on single-resource reads (GET /v1/assets/{id}, GET /v1/threats/{id}). Those return a resource, not a position in a stream.

Ties are normal: many rows can share one stream_position when the platform projects them in a single batch. Order within a tie is stable but not meaningful — keep de-duplicating on id.

Removals

A removal is a change like any other, so the stream delivers it like any other.

When an asset is removed, GET /v1/assets sends it to you once more with:

{
  "id": "a17c...",
  "deleted": true,
  "deleted_at": "2026-09-07T09:00:00Z"
}

That row is a tombstone. Drop the asset from your copy when you receive one. It arrives at its own position in the stream, so it does not disturb the cursor or the at-least-once guarantee for anything else.

A tombstone also ends that asset's threats. Every threat belongs to exactly one asset, so when you receive an asset tombstone, drop that asset's threats too. GET /v1/threats does not report them separately — no status change, no resolution, they simply stop appearing.

Two details worth handling:

  • A removed asset can come back. If it is restored you receive it again with deleted: false, and it is live data once more.
  • You may receive a tombstone for an asset you never saw, if it was created and removed between two of your polls. Ignore it.

GET /v1/assets/{id}/related is the one exception: it lists an asset's current neighbours on demand and never emits tombstones. Track removals on GET /v1/assets.

Fetching everything, then staying current

  1. Call the list endpoint without a cursor.
  2. Process the page, then call again with cursor set to the response's next_cursor.
  3. Repeat until next_cursor is null — that is the end-of-stream signal, meaning you have seen everything up to now.
  4. Keep the last non-null cursor. To pick up changes later, poll again with it — you receive only items created or updated since. When that poll returns null again, store the newest non-null cursor it produced and repeat on your schedule.
GET /v1/threats?limit=500                → items, next_cursor: "A"
GET /v1/threats?limit=500&cursor=A       → items, next_cursor: "B"
GET /v1/threats?limit=500&cursor=B       → [],    next_cursor: null   # caught up — keep B
... an hour later ...
GET /v1/threats?limit=500&cursor=B       → whatever changed since

A cursor does not expire. A corrupted, truncated or hand-built cursor answers 400 with type request/invalid-cursor. Only pass a cursor back to the endpoint that issued it — cross-endpoint behaviour is undefined and not part of the contract.

Practical guidance

  • Persist the cursor with your ingestion state, exactly as received.
  • Index or store stream_position alongside each record. It is what lets you answer "what arrived since my last poll" without re-deriving it from your own ingest time.
  • An empty page with a null cursor is the normal steady state between polls, not an error.
  • Poll frequency is yours to choose within your rate limits; the delta contract makes frequent polling cheap because unchanged items are not resent.