Cursor Pagination

Forward cursor pagination (§1.2) - opaque cursors, limit bounds, end-of-list marker, and stable ordering.

5 min read
paginationcursorlimit

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 (default 25). Values outside the range return 400 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 - false signals end of list; nextCursor will be null.

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 /:id if 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.

Related