Pagination: cursors, pages and what each page costs
Paging through okdata lists: one call is one page and one charge, page.next_cursor and has_more, the rules a cursor follows, a capped Python loop, expired cursors and POST batches.
A list endpoint returns one page per call, and each page is charged as its own call. To get the next page, send page.next_cursor back as cursor to the same endpoint with the same parameters. Stop when page.has_more is false, or as soon as you have enough: you never pay for pages you do not ask for.
One call, one page, one charge
Every list answer carries a page object next to data.items:
"page": { "next_cursor": "c1.k3Vq9TzR0bYw2LmXe8NdPa", "cursor_expires_at": "2026-11-13T08:00:00Z", "has_more": true, "returned": 20 }
next_cursoris an opaque string. Pass it back exactly as you got it; do not parse it, build it or store it as a number.cursor_expires_atis when that cursor stops working;returnedis how many items this page holds.has_moreistrueexactly whennext_cursoris set.falsewithnext_cursor: null(andcursor_expires_at: null) means this listing has no next page.
A page of Douyin video comments holds about 20 comments and costs $0.002, so ten pages are ten calls: $0.02. On a per-item list the charge follows what each page holds: five full pages of X account followers, 200 accounts each, cost $0.10. The billing page explains per-call and per-item prices.
There are no page numbers. The platforms' own feeds only know what comes after a point, so page 5 means fetching pages 1 to 4 first. A few list endpoints, such as related keywords, have no cursor parameter at all and answer in a single page; their parameter table on the endpoint page shows it.
Cursor rules
A cursor continues one listing, and the API checks that before any money moves:
- Same account. Any API key of the account that received the cursor may use it.
- Same endpoint, same parameters. Change the query, the id or a filter and the cursor no longer fits.
viewandfieldsmay change from page to page, since they only shape the answer. - Within its lifetime. Each paged endpoint's catalog entry says how long its cursors stay valid, as
cursorTtlSeconds(x-cursor-ttl-secondsin/openapi.json). Today that is 86,400 seconds, 24 hours, on every paged endpoint.
A cursor that does not work is refused with its own error code, charged: false and param: "cursor":
| code | HTTP | Meaning | What to do |
|---|---|---|---|
cursor_invalid |
400 | Not a cursor we issued, or issued to another account | Pass page.next_cursor unchanged |
cursor_mismatch |
400 | Sent to another endpoint or with other parameters | Resend with the parameters of the page it came from |
cursor_expired |
410 | Older than its lifetime (page.cursor_expires_at) |
Start the listing again without cursor |
cursor_unavailable |
502 | Only the source that served the previous page can continue it, and it is down right now | Retry later with the same cursor, or start again without one |
Starting again re-fetches the first pages and charges them again, and the platform may have new items at the top, so keep the ids you already have and skip repeats. A job that saves a cursor overnight to resume later will meet cursor_expired; one that pages straight through will not.
A loop with a page cap
This reads Douyin comments page by page, stops at max_pages charged pages, and restarts once if a cursor has expired:
import os
import requests
URL = "https://api.okdata.online/v1/douyin/videos/comments/list"
HEADERS = {"Authorization": f"Bearer {os.environ['OKDATA_API_KEY']}"}
def comments(video_id, max_pages=10, cursor=None):
seen, pages = set(), 0
while pages < max_pages:
params = {"id": video_id, **({"cursor": cursor} if cursor else {})}
body = requests.get(URL, headers=HEADERS, params=params, timeout=60).json()
if body["code"] == "cursor_expired":
cursor = None # refused, not charged: start the listing again
continue
if body["code"] != "ok":
raise RuntimeError(f"{body['code']}: {body.get('problems') or body['message']}")
pages += 1
for item in body["data"]["items"]:
if item["id"] not in seen:
seen.add(item["id"])
yield item
if not body["page"]["has_more"]:
return
cursor = body["page"]["next_cursor"]
for c in comments("7301234567890123456", max_pages=3):
print(c["id"], c["text"][:60])
Three pages cost at most $0.006. Each page is a new request, so if you send an Idempotency-Key, use a new one per page and reuse it only to resend that same page.
When has_more is false early
On a per-item list, a first page your balance cannot pay for in full is cut to the items it covers. warnings says so, and that page has no next cursor even though the listing goes on. Top up and start again for full pages. Later pages always need room for a full page; if the balance is short, the call is 402 insufficient_balance and nothing is charged.
Batches by POST
A few single-object endpoints also take a list of ids in one call: their catalog entry has post: true, and batch_max in /prices.json (also stated in the ids parameter) says how many fit. X account profile takes up to 100:
curl -s "https://api.okdata.online/v1/x/users/get" \
-H "Authorization: Bearer $OKDATA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ids": ["4398626122", "11348282"]}'
A batch answers with data.items in the order found and data.missing for the ids that came back with nothing, each with a reason. It has no page and no cursor. A per-item batch is charged per object found, at most $0.04; a per-call batch such as Douyin Xingtu creator profiles is charged once, $0.004, for up to 50 ids. A batch that finds nothing is free, and more ids than the endpoint takes is invalid_params before anything is held. The requests page covers the rest of the request shape.
Updated