Pagination
How to paginate Axuz API lists with keyset cursors (created_at and id), the four formats that exist today, which routes paginate and ready-made loops in Node and Python.
The API’s large lists use keyset pagination: instead of “skip N items” (OFFSET), you say “continue after this item”. The starting point is almost always the (created_at, id) pair of the last item on the page. This avoids missing or repeating items when something is created while you paginate, and the server cost does not grow with depth.
Rules that hold for every format
- The cursor is opaque. Take the returned value and send it back as is. Do not build it, edit it or interpret it: its inner content (today something like
timestamp|uuid, in some cases base64-encoded) may change. - URL-encode it. Cursors contain
|,:and+. UseURLSearchParams(Node) orurllib.parse.urlencode(Python); never concatenate by hand. - End of list: stop when the page comes back empty or when the “next page” indicator is absent,
nullor empty. Each format below says which. limitis a request, not a guarantee. Each route has a default and a ceiling. On several routes an out-of-range value is not clamped to the ceiling: it falls back to the default, with no error. Read the size of the page you got back instead of assuming what you asked for.- An invalid cursor behaves differently per service: some routes answer
400(cursor inválido), others ignore the cursor and return the first page. That second case, in an unguarded loop, becomes an infinite loop (see the code below). - Null lists: the server normalises empty lists to
[], but treatitems: null(or absent) as an empty list on the client. It is cheap and protects you against routes and services that do not normalise yet.
Format A: cursor and next_cursor (string)
The most common format on newer routes. Parameters: limit and cursor. Response: {"items": [...], "next_cursor": "..."}.
Routes that use this format:
- GET
/me/calendar/upcoming(calendar service) - GET
/teams/{teamId}/forms/{formId}/submissionsandGET /teams/{teamId}/submissions(forms service) - GET
/teams/{teamId}/tasks/checklist-items/search, which also takesq(at least 2 characters; with fewer it returnsitems: []without querying the database)
next_cursor is a string when there are more pages and is empty, null or absent on the last one. In checklist search it is null at the end; on other routes, "". Test for “falsy”, not for a specific value.
Node
// node 20+, ESM. Walks any format A route.
const API = process.env.AXUZ_API ?? 'https://api-forms.axuz.net';
export async function* paginate(path, params = {}, { limit = 50, maxPages = 10_000 } = {}) {
let cursor = '';
const seen = new Set();
for (let page = 0; page < maxPages; page++) {
const qs = new URLSearchParams({ ...params, limit: String(limit) });
if (cursor) qs.set('cursor', cursor);
const res = await fetch(`${API}${path}?${qs}`, {
headers: { Authorization: `Bearer ${process.env.AXUZ_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status} on ${path}`);
const body = await res.json();
const items = body.items ?? []; // null or absent = empty
yield* items;
const next = body.next_cursor;
if (!next || items.length === 0) return; // end of list
if (seen.has(next)) throw new Error('repeated cursor: did the server ignore the cursor?');
seen.add(next);
cursor = next;
}
throw new Error('maxPages exceeded');
}
for await (const item of paginate('/me/calendar/upcoming', {}, { limit: 100 })) {
console.log(item.id);
}
Python
import json
import os
import urllib.parse
import urllib.request
API = os.environ.get("AXUZ_API", "https://api-calendar.axuz.net")
def paginate(path, params=None, limit=50, max_pages=10_000):
"""Yield every item of a format A route."""
cursor = ""
seen = set()
for _ in range(max_pages):
query = dict(params or {}, limit=limit)
if cursor:
query["cursor"] = cursor
req = urllib.request.Request(
f"{API}{path}?{urllib.parse.urlencode(query)}",
headers={"Authorization": "Bearer " + os.environ["AXUZ_TOKEN"]},
)
with urllib.request.urlopen(req, timeout=30) as res:
body = json.load(res)
items = body.get("items") or [] # None or absent = empty
yield from items
nxt = body.get("next_cursor")
if not nxt or not items:
return # end of list
if nxt in seen:
raise RuntimeError("repeated cursor: did the server ignore the cursor?")
seen.add(nxt)
cursor = nxt
raise RuntimeError("max_pages exceeded")
for item in paginate("/me/calendar/upcoming", limit=100):
print(item["id"])
Format B: after_ts and after_id, with an object next_cursor
Used by file listings: GET/teams/{teamId}/files, GET /teams/{teamId}/drive/files and GET /me/files.
Parameters: limit (1 to 500), after_ts (RFC 3339, with nanoseconds) and after_id. The response carries an object in next_cursor, with the two values to send back:
{
"items": [ { "id": "…", "created_at": "2026-10-22T12:00:00.123456789Z" } ],
"next_cursor": { "after_ts": "2026-10-22T12:00:00.123456789Z", "after_id": "…" }
}
Three traps in this format, all confirmed in the code:
- Without a valid
limitthere is no pagination. If you omitlimit, or send a value outside 1 to 500, the route returns all files at once (a compatibility behaviour), with nonext_cursor. next_cursoronly appears when the page came back full (items.length == limit). If the total is an exact multiple oflimit, the last full page still carriesnext_cursorand the next request comes back empty. That is normal: stop when a page is empty.- Send
after_tsandafter_idtogether, taken from the samenext_cursor.
let after = null;
for (;;) {
const qs = new URLSearchParams({ limit: '200' });
if (after) { qs.set('after_ts', after.after_ts); qs.set('after_id', after.after_id); }
const res = await fetch(`https://files-api.axuz.net/teams/${teamId}/files?${qs}`, { headers });
const body = await res.json();
const items = body.items ?? [];
for (const f of items) console.log(f.id);
if (!body.next_cursor || items.length === 0) break;
after = body.next_cursor;
}
Format C: before, with no cursor returned
Some lists ordered from newest to oldest use the timestamp of the last item as the cursor, and do not return next_cursor. The client computes the next before:
| Route | Parameters | before compares with |
|---|---|---|
GET/teams/{teamId}/timelines/{timelineId}/posts | limit (default 50), before (RFC 3339) | the post’s created_at |
GET/teams/{teamId}/feeds/{feedId}/posts | limit (default 50), before (RFC 3339), include_drafts | published_at, or created_at if null |
GET/me/notes | limit (default 50, max 200), before (RFC 3339) | the note’s updated_at |
Response: just {"items": [...]}. For the next page, send before equal to the timestamp of the last item received. Stop when fewer items than limit come back (or none).
Cautions: before is a strict comparison, so items with exactly the same timestamp at a page boundary may be skipped; ask for larger pages if that matters. In /me/notes, pinned notes and manual order come before the updated_at ordering, and before filters only the unpinned part; for a complete, stable list use limit=200. A before that is not a valid date gives 400 invalid before on team routes, and on /me/notes it is ignored (the first page is returned).
Format D: after_name and after_id (members)
GET/teams/{teamId}/members paginates by (name, user_id): limit (default 100), after_name, after_id. The response has next_cursor as an object with after_name and after_id; on the last page the object comes back with no fields ({}). Stop when next_cursor.after_id is missing.
Audit: cursor or offset
GET/teams/{teamId}/admin/audit accepts cursor (keyset, the recommended path) or offset (for short jumps only; it is the one route documented here that does OFFSET). next_cursor only appears with a full page.
What does not paginate
Many lists return everything at once, with or without a fixed ceiling, and have no page parameters. A few examples: GET/me/api-tokens (up to 200, newest first, no cursor), GET /me/summary (up to 200 teams) and supporting lists (a board’s columns, roles, labels). If the route lists neither limit nor cursor in the reference, assume it does not paginate and that the response size is bounded by the server. When in doubt, read the operation’s description: this is still a place where the generated specification does not always declare every parameter as a formal parameter (for example, before only appears in the description text of some routes above).