Skip to content

Paginação ​

O /v1 usa duas formas de paginação, escolhidas pelo tamanho que a coleção pode atingir. Saber qual é qual evita o erro mais caro de integração: varrer uma coleção grande do jeito errado.

RotaFormaPor quê
GET /formsoffset (page)Tamanho de tela — uma empresa tem dezenas de formulários
GET /forms/{id}/responsescursorCresce sem teto — um formulário pode ter 500 mil respostas
GET /forms/{id}/invitescursorIdem — um lote de importação já passa de 50 mil
GET /class-groupsoffset (page)Tamanho de tela — turmas se contam em centenas
GET /enrollmentscursorUma linha por aluno por turma — a maior coleção do roster

Nas duas formas, limit vai de 1 a 100 e o padrão é 20.

Offset (GET /forms) ​

bash
curl -H "Authorization: Bearer $EDUSATI_KEY" \
  "https://api.edusati.com/v1/forms?page=2&limit=20"
json
{ "data": [ ], "meta": { "page": 2, "limit": 20, "total": 47, "totalPages": 3 } }

Dá para pular direto para a página N e o total vem sempre.

Cursor (respostas, convites e matrículas) ​

bash
# Primeira página: SEM cursor. Só aqui vem o `total`.
curl -H "Authorization: Bearer $EDUSATI_KEY" \
  "https://api.edusati.com/v1/forms/$FORM/responses?limit=100"
json
{
  "data": [ ],
  "meta": { "limit": 100, "total": 48310, "hasMore": true, "nextCursor": "MjAyNi0wNy0yOFQxMzo0…" }
}
bash
# Páginas seguintes: repasse o nextCursor como veio.
curl -H "Authorization: Bearer $EDUSATI_KEY" \
  "https://api.edusati.com/v1/forms/$FORM/responses?limit=100&cursor=MjAyNi0wNy0yOFQxMzo0…"
json
{ "data": [ ], "meta": { "limit": 100, "hasMore": true, "nextCursor": "MjAyNi0wNy0yOFQxNTox…" } }

As quatro regras ​

1. Pare em hasMore: false. Nesse ponto nextCursor vem null. Não calcule o número de páginas a partir do total: ele é o retrato do início da varredura e a coleção continua recebendo respostas enquanto você anda.

2. O cursor é opaco. Copie a string como veio. Não parseie, não monte uma, não tente adivinhar o formato — ele é interno e pode mudar. Cursor ilegível responde 400 INVALID_CURSOR.

3. ?page= não existe aqui. Quem mandar recebe 400 PAGE_NOT_SUPPORTED. A recusa é o ponto: se ignorássemos o parâmetro, você receberia a primeira página a cada chamada e o seu laço rodaria para sempre achando que está avançando.

4. O total só vem na primeira página. Contar é caro, e repetir a contagem a cada página devolveria justamente o custo que o cursor evita. Se precisar do total, guarde-o da primeira resposta.

Por que não há "ir para a página 500"

Com offset, o banco percorre e descarta tudo o que vem antes — varrer a coleção inteira custa tempo quadrático, exatamente no volume em que isso importa. O cursor é o que mantém cada página com o mesmo custo, do começo ao fim.

Para encurtar o percurso, recorte por período com from/to em vez de tentar saltar.

O laço completo ​

bash
CURSOR=''
while :; do
  PAGE=$(curl -s -H "Authorization: Bearer $EDUSATI_KEY" \
    "https://api.edusati.com/v1/forms/$FORM/responses?include=answers&limit=100$CURSOR")
  echo "$PAGE" | jq -c '.data[]'
  [ "$(echo "$PAGE" | jq -r .meta.hasMore)" = 'true' ] || break
  CURSOR="&cursor=$(echo "$PAGE" | jq -r .meta.nextCursor)"
done
js
async function* respostas(formId, { from, to } = {}) {
  let cursor = null;
  do {
    const url = new URL(`https://api.edusati.com/v1/forms/${formId}/responses`);
    url.searchParams.set('include', 'answers');
    url.searchParams.set('limit', '100');
    if (cursor) url.searchParams.set('cursor', cursor);
    if (from) url.searchParams.set('from', from);
    if (to) url.searchParams.set('to', to);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EDUSATI_KEY}` } });
    if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error?.code}`);
    const { data, meta } = await res.json();

    yield* data;
    cursor = meta.hasMore ? meta.nextCursor : null;
  } while (cursor);
}

for await (const resposta of respostas(FORM_ID)) {
  console.log(resposta.id, resposta.respondent?.externalId);
}
python
import os, requests

def respostas(form_id, **filtros):
    cursor, base = None, f"https://api.edusati.com/v1/forms/{form_id}/responses"
    headers = {"Authorization": f"Bearer {os.environ['EDUSATI_KEY']}"}
    while True:
        params = {"include": "answers", "limit": 100, **filtros}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(base, headers=headers, params=params, timeout=30)
        r.raise_for_status()
        corpo = r.json()
        yield from corpo["data"]
        if not corpo["meta"]["hasMore"]:
            return
        cursor = corpo["meta"]["nextCursor"]

Ordem e consistência ​

A ordem é createdAt decrescente, desempatada por id — do mais recente para o mais antigo, de forma estável mesmo quando duas respostas chegam no mesmo milissegundo.

Isso tem uma consequência que vale entender:

Uma resposta criada durante a sua varredura entra no topo da lista, antes do ponto em que o cursor está. Ela não aparece nessa passagem.

Isso é bom: significa que você nunca vê a mesma linha duas vezes nem pula uma que já existia quando começou. Mas significa também que varredura não serve para o incremental. O padrão correto é:

  • Varredura por cursor → carga inicial e reconciliação periódica
  • Webhooks → tudo o que acontece a partir de agora

A receita Sincronizar respostas com um CRM mostra os dois juntos.

Matrículas: o recorte é changedSince ​

GET /enrollments segue exatamente o laço acima, com um filtro a mais que muda o custo da operação:

bash
# Só o que mudou de situação desde a última varredura
"…/enrollments?status=dropped&changedSince=2026-08-01T00:00:00Z&limit=100"

Guarde o horário em que a varredura começou e use-o como changedSince da próxima: o recorte é sobre statusChangedAt, inclusivo na borda. Vale a mesma regra de sempre — o webhook enrollment.dropped é o incremental, e a varredura é a carga inicial e a rede de segurança de quem ficou fora do ar.

Cada linha traz o aluno (person) e a turma (class) embutidos, pelas chaves que você reconhece — externalId e code —, então não é preciso um segundo GET só para traduzir ids.

Filtro por período ​

Respostas aceitam from e to no formato YYYY-MM-DD, em UTC, inclusivos nas duas bordas:

bash
"…/responses?from=2026-07-01&to=2026-07-31"

Ambos são opcionais. Recortar por período é a forma de reduzir uma varredura grande — e a maneira certa de reprocessar um intervalo específico depois de um incidente.

Documentação da API pública do Edusati