Tema
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.
| Rota | Forma | Por quê |
|---|---|---|
GET /forms | offset (page) | Tamanho de tela — uma empresa tem dezenas de formulários |
GET /forms/{id}/responses | cursor | Cresce sem teto — um formulário pode ter 500 mil respostas |
GET /forms/{id}/invites | cursor | Idem — um lote de importação já passa de 50 mil |
GET /class-groups | offset (page) | Tamanho de tela — turmas se contam em centenas |
GET /enrollments | cursor | Uma 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)"
donejs
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.