Axuz Developers

Paginação

Como paginar as listas da API da Axuz com cursores keyset (created_at e id), os quatro formatos que existem hoje, quais rotas paginam e loops prontos em Node e Python.

As listas grandes da API usam paginação keyset: em vez de “pule N itens” (OFFSET), você diz “continue depois deste item”. O ponto de partida é quase sempre o par (created_at, id) do último item da página. Isso evita perder ou repetir itens quando algo é criado enquanto você pagina, e o custo no servidor não cresce com a profundidade.

Regras que valem para todos os formatos

  • O cursor é opaco. Pegue o valor devolvido e mande de volta tal e qual. Não monte, não edite, não interprete: o conteúdo interno (hoje, algo como timestamp|uuid, em alguns casos codificado em base64) pode mudar.
  • Codifique na URL. Cursores contêm |, : e +. Use URLSearchParams (Node) ou urllib.parse.urlencode (Python); nunca concatene na mão.
  • Fim da lista: pare quando a página vier vazia ou quando o indicador de “próxima página” estiver ausente, null ou vazio. Cada formato abaixo diz qual.
  • limit é um pedido, não uma garantia. Cada rota tem um padrão e um teto. Em várias rotas um valor fora da faixa não é limitado ao teto: cai no padrão, sem erro. Releia o tamanho da página devolvida em vez de assumir o que pediu.
  • Cursor inválido tem comportamento diferente por serviço: algumas rotas respondem 400 (cursor inválido), outras ignoram o cursor e devolvem a primeira página. Esse segundo caso, num loop sem proteção, vira loop infinito (veja o código abaixo).
  • Lista nula: o servidor normaliza listas vazias para [], mas trate items: null (ou ausente) como lista vazia no cliente. É barato e protege contra rotas e serviços que ainda não normalizam.

Formato A: cursor e next_cursor (string)

O formato mais comum nas rotas novas. Parâmetros: limit e cursor. Resposta: {"items": [...], "next_cursor": "..."}.

Rotas que usam esse formato:

next_cursor é uma string quando há mais páginas e fica vazio, null ou ausente na última. Na busca de checklist ele é null no fim; em outras rotas, "". Teste “falsy”, não um valor específico.

Node

// node 20+, ESM. Percorre qualquer rota do formato A.
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} em ${path}`);
    const body = await res.json();
    const items = body.items ?? []; // null ou ausente = vazio
    yield* items;
    const next = body.next_cursor;
    if (!next || items.length === 0) return; // fim da lista
    if (seen.has(next)) throw new Error('cursor repetido: o servidor ignorou o cursor?');
    seen.add(next);
    cursor = next;
  }
  throw new Error('maxPages excedido');
}

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):
    """Gera todos os itens de uma rota do formato A."""
    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 ou ausente = vazio
        yield from items
        nxt = body.get("next_cursor")
        if not nxt or not items:
            return  # fim da lista
        if nxt in seen:
            raise RuntimeError("cursor repetido: o servidor ignorou o cursor?")
        seen.add(nxt)
        cursor = nxt
    raise RuntimeError("max_pages excedido")


for item in paginate("/me/calendar/upcoming", limit=100):
    print(item["id"])

Formato B: after_ts e after_id, com next_cursor objeto

Usado pelas listagens de arquivos: GET/teams/{teamId}/files, GET /teams/{teamId}/drive/files e GET /me/files.

Parâmetros: limit (1 a 500), after_ts (RFC 3339, com nanossegundos) e after_id. A resposta traz um objeto em next_cursor, com os dois valores a reenviar:

{
  "items": [ { "id": "…", "created_at": "2026-10-22T12:00:00.123456789Z" } ],
  "next_cursor": { "after_ts": "2026-10-22T12:00:00.123456789Z", "after_id": "…" }
}

Três armadilhas desse formato, todas confirmadas no código:

  1. Sem limit válido não há paginação. Se você omitir limit, ou mandar um valor fora de 1 a 500, a rota devolve todos os arquivos de uma vez (comportamento de compatibilidade), sem next_cursor.
  2. next_cursor só aparece quando a página veio cheia (items.length == limit). Se o total for um múltiplo exato de limit, a última página cheia ainda traz next_cursor, e a requisição seguinte volta vazia. Isso é normal: pare quando a página vier vazia.
  3. Mande after_ts e after_id juntos, vindos do mesmo next_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;
}

Formato C: before, sem cursor devolvido

Algumas listas ordenadas do mais novo para o mais antigo usam o timestamp do último item como cursor, e não devolvem next_cursor. Quem calcula o próximo before é o cliente:

RotaParâmetrosbefore compara com
GET/teams/{teamId}/timelines/{timelineId}/postslimit (padrão 50), before (RFC 3339)created_at do post
GET/teams/{teamId}/feeds/{feedId}/postslimit (padrão 50), before (RFC 3339), include_draftspublished_at, ou created_at se nulo
GET/me/noteslimit (padrão 50, máx. 200), before (RFC 3339)updated_at da nota

Resposta: apenas {"items": [...]}. Para a próxima página, envie before igual ao timestamp do último item recebido. Pare quando vier menos itens que o limit (ou nenhum).

Cuidados: before é comparação estrita, então itens com exatamente o mesmo timestamp na borda da página podem ser pulados; peça páginas maiores se isso importar. Em /me/notes, as notas fixadas e a ordem manual vêm antes da ordenação por updated_at, e before filtra só a parte não fixada; para a lista completa e estável, use limit=200. Um before que não é data válida dá 400 invalid before nas rotas de equipe, e em /me/notes é ignorado (devolve a primeira página).

Formato D: after_name e after_id (membros)

GET/teams/{teamId}/members pagina por (name, user_id): limit (padrão 100), after_name, after_id. A resposta tem next_cursor como objeto com after_name e after_id; na última página o objeto vem sem campos ({}). Pare quando next_cursor.after_id não existir.

Auditoria: cursor ou offset

GET/teams/{teamId}/admin/audit aceita cursor (keyset, o caminho recomendado) ou offset (apenas para saltos curtos; é a única rota documentada aqui que faz OFFSET). next_cursor só aparece com a página cheia.

O que não pagina

Muitas listas devolvem tudo de uma vez, com ou sem teto fixo, e não têm parâmetros de página. Alguns exemplos: GET/me/api-tokens (até 200, mais recentes primeiro, sem cursor), GET /me/summary (até 200 equipes) e as listas de apoio (colunas de um quadro, papéis, etiquetas). Se a rota não lista limit nem cursor na referência, assuma que ela não pagina e que o tamanho da resposta é limitado pelo servidor. Na dúvida, leia a descrição da operação: este ainda é um ponto em que a especificação gerada nem sempre declara todos os parâmetros como parâmetros formais (por exemplo, before só aparece no texto da descrição de algumas rotas acima).

Atualizado em