PredictorSDK
Guides

Pagination

Traverse large result sets with cursor-based pagination.

List endpoints that can return many results use cursor-based pagination. The cursor is opaque — you don't need to parse it; just pass the next_cursor from one response back as cursor on the next request.

Endpoints that paginate

  • GET /v1/matching-markets/sports — the list, with its league, scheduled_date and participant filters. Default limit=25, max 100.
  • GET /v1/markets — all requests are paginated, including ?provider= and ?category= filters. Default limit=25, max 100.
  • GET /v1/polymarket/wallet/positions — default first-page limit=50, max 100. Later pages keep the page size carried by the cursor.
  • GET /v1/crypto-prices/binance — per-second Binance prices, newest first. Default and max limit=100. With no start_time, end_time or cursor it answers the single latest price and no next page. Until 2026-10-09 it answered {prices, pagination_key, total} and took pagination_key; it now answers this envelope and takes cursor, and pagination_key is a 400 that names cursor.

GET /v1/matching-markets/sports/lookup does not paginate — the response is bounded by at most 100 unique IDs across event_id and source_id, and it has no pagination block, limit or cursor.

Response shape

Every paginated response carries its items in data, in page order, and a pagination block that is always the same:

{
  "pagination": {
    "limit": 25,
    "total": 143,
    "has_more": true,
    "next_cursor": "eyJzIjoiYWJjMTIzZWYi..."
  }
}
FieldDescription
limitPage size used for this response. Wallet positions cursors preserve the first page's size; other endpoints echo the limit query param (or the default). Crypto prices report 1 when you send no time range.
totalTotal matching items across all pages, or null when the route cannot count them: wallet positions and crypto prices, whose upstreams give no total. 0 only ever means an empty result. Use has_more and next_cursor whatever total says.
has_moretrue when more pages exist beyond this one.
next_cursorPass back via the cursor query param for the next page. Absent when has_more is false.

Walking every page

The SDKs walk the cursor for you. A call to one of the four paginated operations returns a page (TypeScript and Go) or a pager (Python); iterating it visits every item on every page, sends each next cursor, and repeats the filters that issued it. With cURL you follow next_cursor yourself.

# Page 1
curl -H "Authorization: Bearer $API_KEY" \
  "https://api.predictorsdk.com/v1/matching-markets/sports?limit=25"

# Page 2 (use next_cursor from page 1's response)
curl -H "Authorization: Bearer $API_KEY" \
  "https://api.predictorsdk.com/v1/matching-markets/sports?limit=25&cursor=eyJzIjoi..."

Iterating walks every page, so to read only the first, use page.data (TypeScript), pager.items (Python) or page.Results (Go). A manual cursor is still accepted, to resume a walk you saved; send it with the same filters.

Important rules

Keep request shape constant across pages

The cursor is scoped to the request shape it was issued with. If you change include_settled, provider, category, or any other supported filter mid-traversal, you'll get a 400:

{
  "code": "invalid_cursor",
  "message": "pagination cursor does not match the current filters; a cursor continues the provider and category values that issued it, on /v1/markets only",
  "status_code": 400,
  "param": "cursor"
}

On matching-markets the message also quotes the cursor and names the parameters it binds (include_settled, include_submarkets, player_prop_match, league, scheduled_date and participant).

Drop the cursor and start fresh from page 1 whenever filters change.

Wallet positions cursors preserve the page size

For GET /v1/polymarket/wallet/positions, choose limit on the first page. When following next_cursor, omit limit or keep it the same: a different valid limit does not change the cursor's page size, and pagination.limit reports the size actually used. Keep the same resolved wallet address.

The migration to Polymarket Data API v2 invalidates cursors issued by the previous positions implementation. Restart from the first page if a saved positions cursor returns 400; those older cursors cannot be continued.

Crypto price cursors carry their range

For GET /v1/crypto-prices/binance, next_cursor carries the start_time of the request that issued it, so you can follow it with only currency, limit and cursor: the next page stays inside the range. Sending a different start_time with a cursor is a 400, and end_time is ignored when a cursor is sent. Pages run newest first, each the limit newest seconds at or before the end of the last one, and walking every page returns every second of the range exactly once.

curl -H "Authorization: Bearer $API_KEY" \
  "https://api.predictorsdk.com/v1/crypto-prices/binance?currency=btcusdt&start_time=1775680500000&end_time=1775680559999&limit=30"

# next page: cursor only, from the previous pagination.next_cursor
curl -H "Authorization: Bearer $API_KEY" \
  "https://api.predictorsdk.com/v1/crypto-prices/binance?currency=btcusdt&limit=30&cursor=eyJpZCI6..."

Market cursors bind to an immutable snapshot

GET /v1/markets binds the first page to an immutable catalog manifest. A provider refresh can publish concurrently without changing what that cursor sees, so continuation returns the remaining records from the same snapshot and never advances past records that were not delivered.

Replaced snapshots normally remain available for up to 8 hours. Under storage pressure, Valkey may evict a retained snapshot earlier; active snapshots are protected. In either case, the API rejects an unfinished old cursor with:

{
  "code": "invalid_cursor",
  "message": "pagination cursor is stale; restart from the first page",
  "status_code": 400,
  "param": "cursor"
}

Restart from page 1 whenever a retained cursor goes stale.

snapshot.observed_at tells you how old the page is

Because the endpoint reads a stored snapshot rather than calling the venues, the rows are as old as the last ingestion crawl — not as old as the request. Every GET /v1/markets response carries that age:

{
  "data": [ ],
  "snapshot": { "observed_at": "2026-08-25T13:41:22.108Z" },
  "pagination": { "limit": 25, "total": 1393200, "has_more": true, "next_cursor": "..." }
}

It sits beside data rather than inside pagination because it describes the data, not the page. Three things worth knowing:

  • It is a floor. Providers are crawled on independent schedules, so the catalog is a merge of snapshots of different ages, and this is the OLDEST of them. Every row is at least this fresh; most are fresher. The value is when that crawl began, not when it finished or was published, so it holds for the first rows the crawl read too: a crawl takes up to about a minute (Kalshi's, the longest, about 45 seconds).
  • ?provider= makes it exact. Under a provider filter the value is that provider's own crawl start, the same way pagination.total counts only the filtered set. That is the cheapest way to get a tight bound for the rows you actually care about.
  • It does not move during a traversal. The cursor is bound to one immutable snapshot, so every page of one traversal reports the identical value even while newer snapshots publish behind you. Start a fresh first page to see a newer one.

null means the bound snapshot carries no read time; treat it as unknown age rather than as fresh.

Cursors are ephemeral

Treat cursors as short-lived session state — finish traversal promptly, never assume the full 8-hour grace is guaranteed under storage pressure, and don't share them between clients. Start a new first page when you want the latest provider snapshots.

Default behavior

  • No limit → limit=25 for matching-markets and markets; wallet positions use 50 on the first page and the cursor's page size on later pages; crypto prices use 100.
  • No cursor → first page.
  • limit > 100 → 400 with code: "invalid_parameter" and param: "limit".
  • Malformed cursor → 400 with code: "invalid_cursor".
  • Cursor with stale scope → 400 with code: "invalid_cursor"; the message says the cursor does not match the current filters.
  • Unavailable retained /v1/markets snapshot (expired or evicted under pressure) → 400 with code: "invalid_cursor" and the message pagination cursor is stale; restart from the first page.

On this page