Pagination
Every list endpoint that can grow beyond a single page uses
cursor-based pagination. Cursors are opaque strings the
server mints; clients pass them back to fetch the next page. This
contract is shared across /v1/sessions, /v1/profiles,
/v1/agent-sessions, /v1/recipes, /v1/webhooks/{id}/deliveries
and others — this page is the canonical reference.
Offset / page-number pagination is not supported. Cursor pagination is stable under concurrent inserts (page 2 doesn’t shift just because page 1 grew); offset pagination isn’t, and we don’t want to expose you to that problem.
Request shape
| Query parameter | Required | Notes |
|---|---|---|
limit |
optional | Per-page size. Defaults + max vary per endpoint (see Limit bounds below). |
cursor |
optional | Pagination token from a prior page’s next_cursor. |
The first request omits cursor; subsequent requests pass the
prior response’s next_cursor back.
Response shape
{
"data": [
{ /* resource */ },
/* … */
],
"has_more": false,
"next_cursor": "<opaque-cursor>" | null
}
data— the page’s resources, ordered newest-first by default.has_more—truewhen at least one more page follows. This is returned for sessions, profiles, recipes, webhooks, agent-sessions, and profile-snapshots.next_cursor— opaque token. Pass on the next request to fetch the following page.nullwhen the page is the last. Some endpoints signal the end only vianext_cursor: null— treat eitherhas_more: falseor a null cursor as “stop”.
The cursor is opaque — do not try to parse it. Its internal shape can change between API versions without notice; only the “pass it back exactly as received” contract is stable.
Canonical drive-to-completion loop
TypeScript
import type { Driftstack } from '@driftstack/sdk';
async function listAllProfiles(client: Driftstack): Promise<unknown[]> {
const all: unknown[] = [];
let cursor: string | undefined;
for (;;) {
const page = await client.profiles.list({ limit: 100, cursor });
all.push(...page.data);
if (!page.next_cursor) break;
cursor = page.next_cursor;
}
return all;
}
Python
from driftstack import Driftstack
def list_all_profiles(client: Driftstack) -> list:
out, cursor = [], None
while True:
page = client.profiles.list(limit=100, cursor=cursor)
out.extend(page["data"])
if not page["next_cursor"]:
break
cursor = page["next_cursor"]
return out
Go
func ListAllProfiles(ctx context.Context, c *driftstack.Client) ([]driftstack.Profile, error) {
var out []driftstack.Profile
var cursor string
for {
page, err := c.Profiles.List(ctx, &driftstack.ListProfilesQuery{
Limit: 100,
Cursor: cursor,
})
if err != nil {
return nil, err
}
out = append(out, page.Data...)
// NextCursor is *string — nil means no more pages.
if page.NextCursor == nil || *page.NextCursor == "" {
break
}
cursor = *page.NextCursor
}
return out, nil
}
Ordering + stability
-
Default order is newest-first (
created_atdesc) on every paginated endpoint. If an endpoint ever adopts a different natural ordering, it documents that variation inline; cursor semantics are unchanged either way. -
Stability under writes: because cursors encode position relative to a specific record (not an offset), concurrent inserts during a paginated read never shift the page boundary. A record inserted between page-1 and page-2 simply ends up on page 1 (the newer side) on a future read; the current walk doesn’t re-emit it.
-
Stability under deletes: if a record is deleted between page-1 and page-2 reads, page-2 still resolves correctly (the cursor points at a position, not a specific id). The deleted record is simply absent.
Limit bounds
Per-endpoint limit ranges:
- Default:
50on most list endpoints (webhook deliveries and the public incident feedGET /v1/status/incidents, for example). The Help conversations list (GET /v1/support/conversations) defaults to20. When the page size matters, passlimitrather than relying on a default. - Maximum:
100. - Out-of-range values surface as
400problem+json. The problemtypevaries by endpoint — some return.../validation-failed, others.../bad-request, so branch on the400status rather than on one type. - The per-endpoint bound is in the
issuesextension, not indetail.detailis a fixed human sentence (“One or more fields failed validation.”);issuescarries the field-level detail, including the maximum a rejectedlimitexceeded. Readissuesif you want to show the bound.
Anti-patterns
- Don’t decode the cursor. It’s opaque; we change the encoding freely between minor versions. Clients that try to parse it break on the next bump.
- Don’t combine
cursorwith afrom/totime filter on the resumed call unless the filter is identical to the original page-1 call. Mixing them produces undefined results (the cursor encodes the original filter context; you’d be asking for a different walk). - Don’t loop without an exit condition. Every paginated read
loop must check
next_cursorfor null and break — the drive-to-completion examples above do this; copy them rather than rolling your own.