Guides
Pagination
Cursor-based pagination — opaque, signed cursors that freeze the request they were issued for.
A search that matches thousands of rows hands them over a page at a time. The cursor is your bookmark: pass it back and the next page picks up exactly where the last one ended, even if the index changed in between.
These routes paginate with cursors:
POST /v1/searchandPOST /v1/similarPOST /v1/relationships,POST /v1/relationships/reachandPOST /v1/relationships/ask(an/askanswer of the compare or fan-out kind returns anullcursor)GET /v1/agents/runs/{run_id}/results,GET /v1/usage/historyandGET /v1/lists/{list_id}/items. Being GETs, they take the cursor as a query parameter
On the cursor routes there is no page or offset parameter — not accepted, not deprecated, never present. Sending one is a 422.
A few routes page differently. The filter-values routes take a page number, GET /v1/feedback takes an offset, and GET /v1/agents/runs takes a limit only. The nine /v1/entities/* routes and POST /v1/relationships/institutions always return a single page.
Every paginated response carries two fields that always agree:
next_cursor— an opaque string, ornullwhen there is no further page.has_more—trueexactly whennext_cursoris notnull. This invariant holds on every response, so you can loop on either.
Use cases#
A filter search defines an audience and you need every row, not the first page. Loop on has_more, resending the same body plus the cursor. You get every matching row up to the 10,000-result depth limit, in a stable order. The search is charged for the results it delivers across all its pages, so page size does not change the total, and sending a cursor you already used again costs nothing.
A user scrolls or clicks "next". Keep the original request body and the latest next_cursor in page state and fetch on demand. Pages are consistent and non-overlapping; if the user changes any filter, discarding the cursor and starting from page 1 is the designed behaviour, not a workaround.
The walk#
Resend the same request, plus the cursor from the previous page:
# Page 1
curl -X POST https://api.sproutsdata.ai/v1/search \
-H "X-API-Key: $SPROUTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "software companies in Germany", "mode": "auto", "limit": 10}'
# → { ..., "has_more": true, "next_cursor": "eyJmIjoiMDNjMjUzNzk3MjM3MTI3ZSIsIm8iOjEs..." }
# Page 2 — the SAME body, plus cursor
curl -X POST https://api.sproutsdata.ai/v1/search \
-H "X-API-Key: $SPROUTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "software companies in Germany", "mode": "auto", "limit": 10,
"cursor": "eyJmIjoiMDNjMjUzNzk3MjM3MTI3ZSIsIm8iOjEs..."}'Cursors are opaque and frozen to the request#
Cursors are signed server-side. Treat them as opaque strings: don't decode, edit, or construct them. A tampered cursor fails verification with 422 pagination.cursor_invalid.
More importantly, a cursor is a bookmark into one specific search: it carries a fingerprint of the request that produced it — every field except cursor and limit. Changing anything else mid-walk — the query, a filter, the mode, the category, the sort clause — is a clean error rather than a silently reshuffled result set:
{
"status": 422,
"code": "pagination.cursor_invalid",
"title": "Cursor does not match this request",
"detail": "The request changed between pages. A cursor is only valid for the exact search that produced it."
}Expiry#
Cursors expire 24 hours after issue:
{
"status": 422,
"code": "pagination.cursor_expired",
"title": "Cursor expired",
"detail": "This cursor is too old to be trusted. Run the search again."
}The expiry exists so a cursor cannot be replayed against an index that has moved underneath it and quietly return a mixture of two points in time. Recovery is always the same: re-run the search from page 1.
Depth limit#
You cannot page beyond 10,000 results:
{
"status": 422,
"code": "pagination.depth_exceeded",
"title": "Pagination too deep",
"detail": "Cannot page beyond 10000 results. Narrow the search instead."
}Deep pagination is how a bulk export disguises itself as browsing. If you need more than 10,000 rows of a segment, narrow the search into slices (by industry, geography, size band) and walk each slice.
has_more: false is not always "you have everything"#
The upstream engine can decline to issue a further cursor while more matches exist. When that happens, the response says so explicitly: degradation[] contains a pagination_truncated entry alongside has_more: false.