Cursor Pagination
Forward cursor pagination (§1.2) - opaque cursors, limit bounds, end-of-list marker, and stable ordering.
Every list endpoint uses forward cursor pagination. Instead of page numbers (which break when items are inserted or deleted), the server returns an opaque nextCursor that encodes the exact continuation point.
Request Parameters
limit- maximum items to return. Integer, 1 to 100 (default25). Values outside the range return400 VALIDATION(v2 envelope - see errors).cursor- opaque string returned by the previous response. Omit on the first request.
Response Shape
{
"data": [ /* up to `limit` items */ ],
"nextCursor": "eyJpZCI6ImtfN2Y4YSIsIl90cyI6MTcyMzAwMDAwMH0",
"hasMore": true
}nextCursor- pass unchanged to the next request. Do not parse, decode, or mutate it. It is base64url of server-internal state and the format may change.hasMore-falsesignals end of list;nextCursorwill benull.
Ordering
All paginated endpoints order by creation time (newest first) and break ties by document ID. Items inserted during iteration appear at the head and are not observed by an in-progress scan. Deletions are silently skipped.
Full Example
curl
# Page 1
curl "https://<your-deployment>.convex.site/api/v1/runs?limit=50" \
-H "Authorization: Bearer cc_live_..."
# Page 2 - use the cursor from page 1
curl "https://<your-deployment>.convex.site/api/v1/runs?limit=50&cursor=eyJpZCI6..." \
-H "Authorization: Bearer cc_live_..."TypeScript
async function* paginate<T>(path: string, key: string) {
let cursor: string | null = null;
do {
const url = new URL(`https://<your-deployment>.convex.site${path}`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { "Authorization": `Bearer ${key}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json() as {
data: T[];
nextCursor: string | null;
hasMore: boolean;
};
for (const item of body.data) yield item;
cursor = body.hasMore ? body.nextCursor : null;
} while (cursor);
}
// Usage
for await (const run of paginate<{ id: string }>("/api/v1/runs", key)) {
console.log(run.id);
}Python
import os, requests
def paginate(path: str, key: str, limit: int = 100):
cursor = None
base = "https://<your-deployment>.convex.site"
while True:
params = {"limit": limit}
if cursor:
params["cursor"] = cursor
r = requests.get(
f"{base}{path}",
headers={"Authorization": f"Bearer {key}"},
params=params,
timeout=30,
)
r.raise_for_status()
body = r.json()
for item in body["data"]:
yield item
if not body.get("hasMore"):
return
cursor = body["nextCursor"]
for run in paginate("/api/v1/runs", os.environ["CC_KEY"]):
print(run["id"])End-of-List Marker
The scan terminates when hasMore === false. An empty data array with hasMore: false means the collection is empty (or all remaining items were filtered out). Never loop until nextCursor === null without checking the hasMore flag.
Anti-Patterns
- Do not decode cursors to extract IDs - use
GET /:idif you need a specific record. - Do not cache cursors across processes or across API key revocations.
- Do not mutate the cursor string; pass it verbatim.