# 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.

Canonical page: https://okdata.online/docs/pagination/

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`:

```json
"page": { "next_cursor": "c1.k3Vq9TzR0bYw2LmXe8NdPa", "cursor_expires_at": "2026-11-13T08:00:00Z", "has_more": true, "returned": 20 }
```

- `next_cursor` is 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_at` is when that cursor stops working; `returned` is how many items this page holds.
- `has_more` is `true` exactly when `next_cursor` is set. `false` with `next_cursor: null` (and `cursor_expires_at: null`) means this listing has no next page.

A page of [Douyin video comments](/apis/douyin/videos/comments/list/) 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](/apis/x/users/followers/list/), 200 accounts each, cost $0.10. The [billing page](/docs/billing/) 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. `view` and `fields` may 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-seconds` in `/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:

```python
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](/apis/x/users/get/) takes up to 100:

```bash
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](/apis/douyin/xingtu/creators/get/) 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](/docs/requests/) covers the rest of the request shape.

Updated 2026-11-12
