Ask ten platforms for "the next page" and you get ten answers. One wants a page number. One wants an offset and a limit. One hands back a continuation token that you must pass on untouched. If your code has to know which is which, every new source is a new pagination bug.
Onekey gives every list that pages the same shape, whatever the source does underneath. This post covers what that shape is, what the cursor is made of, and the edge cases it does not hide.
The shape#
A list endpoint that pages adds one key to the normal response:
"pagination": {
"next_cursor": "eyJiIjoiN2UyNmQ3MDlmZTYyIiwicCI6IjIiLCJzIjoiZ2l0aHViLnNlYXJjaF9yZXBvcyIsInYiOjF9.bETXE7_kZD70QhRemchMkA",
"has_more": true,
"page_size": 10
}That block is a real one, recorded on 2026-09-30 from GET /v1/github/search_repos?q=stars:>100000. Three fields, always the same:
| Field | Meaning |
|---|---|
next_cursor | Send it back as the cursor query parameter, with the same other parameters. null when has_more is false. |
has_more | Whether asking for another page makes sense. |
page_size | Results per page. Fixed for the endpoint; not a parameter. |
Endpoints that do not page have no pagination key at all, so the presence of the key is the signal. Nothing else about your loop changes between GitHub, Hacker News or Bluesky.
What is inside a cursor#
You should treat a cursor as opaque, and the docs say so. But it helps to know why it is safe to pass around. It is a small JSON payload and a signature, joined by a dot. The payload carries a version, the endpoint id, the position (a page number, an offset or the source's own token, depending on the endpoint) and a short digest of the rest of your query. The signature is an HMAC-SHA256 of that payload under a server-side secret.
Two things follow from that:
- A cursor cannot be forged or edited. Change one character and the signature no longer matches.
- A cursor cannot be moved. Because the digest covers your other parameters, a cursor from one search does not work on another search, or on another endpoint.
Either mistake is refused with 422 INVALID_CURSOR, before any charge:
curl -s "https://api.onekey.dev/v1/github/search_repos?q=stars:>100000&cursor=tampered" \
-H "x-api-key: $ONEKEY_KEY"{
"success": false,
"data": null,
"error": { "code": "invalid_cursor", "type": "INVALID_CURSOR", "message": "The cursor is invalid, expired, or from another query" }
}This is the behaviour you want from a paging API. A quiet wrong answer, such as page 3 of a different query, is worse than a loud refusal.
Walking a list#
The whole loop is: send the first request with no cursor, read pagination, and send next_cursor back until has_more is false. Always cap the number of pages.
import os, requests
API = "https://api.onekey.dev"
def all_pages(path, params, max_pages=3):
cursor = None
for _ in range(max_pages):
q = {**params, **({"cursor": cursor} if cursor else {})}
body = requests.get(API + path, params=q, headers={"x-api-key": os.environ["ONEKEY_KEY"]}, timeout=30).json()
if not body["success"]:
raise RuntimeError(body["error"]["type"])
yield from body["data"]
page = body.get("pagination")
if not page or not page["has_more"]:
break
cursor = page["next_cursor"]
repos = list(all_pages("/v1/github/search_repos", {"q": "stars:>100000"}, max_pages=2))
print(len(repos), repos[0]["entity"]["name"])Two pages of that endpoint return 20 repositories, and the /data snapshot of the most-starred repositories is built with exactly this walk.
Why not just expose page numbers#
Page numbers look friendlier, and for a few sources they are exactly what runs underneath. But exposing them would promise things the platform cannot keep. A feed that changes while you read it shifts every item down a slot, so page 3 repeats the last row of page 2. A source that pages by continuation token has no page 3 to jump to at all. A cursor makes only the promise that holds everywhere: this is where to continue from. It also leaves room to change the mechanism later, because your code never depended on it.
The edge cases it does not hide#
A uniform shape is not the same as uniform sources, so a few things are worth knowing.
has_more: true is honest, not clairvoyant. Some sources page by number and never say whether another page exists. For those, a full page reports has_more: true, and the next page can come back empty with has_more: false. That empty page is a normal 200. Sources that hand over a continuation token only say true when they handed one over.
A different query is a different list. Change q and keep the cursor and you get INVALID_CURSOR. Start again from the first page.
Cache hits page the same way. A page served from cache carries the same pagination block as a fresh one, so your loop cannot tell and does not need to.
The page size is not yours to choose. It is whatever the source gives cleanly, reported in page_size. Endpoints differ (10, 20 or 30 in the current catalog), so read it rather than assuming.
What a page costs#
Each page is one call at the endpoint's price, whether it holds page_size results, fewer, or none. A page served from cache is free like any other cache hit, and an invalid cursor is rejected before any charge. Walking five pages of a 5-credit list is 25 credits at most, which is why the loops above take a max_pages.
Send an Idempotency-Key if you might retry a page after a timeout; a repeat within 24 hours is replayed free. The full reference is on the pagination page, and the term is in the glossary.