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
sortparameter.The order is not the
updated_atfield 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 whoseupdated_atis older than one you saw earlier. That is expected and is what guarantees nothing is skipped — see Delivery guarantees below. Sort byupdated_atyourself 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 byid) and the processing-time note above does not apply to it. - Every list response carries apaginationobject:{ "next_cursor": "eyJ1IjoiMjAyNi0wOC0wMVQxNjoyMjo0MVoi...", "page_size": 100 } -
next_cursoris opaque. Do not parse, construct or modify it — its internal format can change without notice.page_sizeis informational and equals the length of the returned array. limitcontrols the page size: default 100, minimum 1, maximum 500. Alimitoutside that range is a400— 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
limitand still carry a cursor. Do not treat a short page as the end of the stream; onlynext_cursor: nullmeans 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_positionis 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 isupdated_at.- It is
nullon 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
- Call the list endpoint without a
cursor. - Process the page, then call again with
cursorset to the response'snext_cursor. - Repeat until
next_cursorisnull— that is the end-of-stream signal, meaning you have seen everything up to now. - 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
nullagain, 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_positionalongside 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
nullcursor 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.