Tema
Erros
Erros usam o mesmo envelope das respostas de sucesso, com success: false:
json
{
"success": false,
"error": {
"code": "DUPLICATE_INVITE",
"message": "This contact already has an invite for this form",
"details": null
}
}Programe contra o code
error.code é estável e faz parte do contrato. error.message é texto para humano e pode mudar a qualquer momento — inclusive de idioma. Nunca compare mensagens.
error.details aparece quando há contexto útil a mais: os erros por campo numa validação, o consumo atual numa quota estourada.
Autenticação
| Código | HTTP | O que fazer |
|---|---|---|
UNAUTHORIZED | 401 | Chave ausente, inválida, revogada ou vencida. Não repita — verifique a configuração |
FORBIDDEN | 403 | A chave é válida mas não tem o escopo da rota. Confira GET /v1/me |
Ver Autenticação para distinguir os casos de 401.
Requisição
| Código | HTTP | O que fazer |
|---|---|---|
VALIDATION_ERROR | 400 | Corpo inválido. details traz os erros por campo |
INVALID_CURSOR | 400 | O cursor não é legível. Recomece a varredura sem cursor |
PAGE_NOT_SUPPORTED | 400 | Você mandou ?page= numa rota de cursor. Ver Paginação |
NOT_FOUND | 404 | O recurso não existe, é de outra empresa ou foi excluído |
PAYLOAD_TOO_LARGE | 413 | Corpo grande demais |
NOT_FOUND não distingue "não existe" de "não é seu"
Recurso inexistente, de outra empresa e excluído dão a mesma resposta. Isso é deliberado — distinguir permitiria descobrir ids válidos de terceiros por tentativa e erro.
Se você tem certeza de que o id existe, verifique se a chave pertence à empresa certa (GET /v1/me).
Relatórios
| Código | HTTP | O que fazer |
|---|---|---|
ANONYMITY_SUPPRESSED | 409 | A instituição definiu um piso de anonimato e esta pesquisa tem menos possíveis autores (pessoas convidadas ou alunos da turma) do que ele. Não há o que repetir: os números só existem num recorte maior |
A promessa de anonimato é da instituição para quem responde, e o piso é o que a sustenta: com poucos possíveis autores, um resultado agregado ainda aponta para quem respondeu. details traz o piso vigente (floor) e a base medida (authors).
Convites
| Código | HTTP | O que fazer |
|---|---|---|
DUPLICATE_INVITE | 409 | Esse contato já tem convite neste formulário. Busque o existente em GET /forms/{id}/invites e reuse a url |
ALREADY_RESPONDED | 409 | O convite já virou resposta; revogar não teria efeito |
QUOTA_EXCEEDED | 403 | A quota mensal de envio da empresa acabou. details traz o consumo |
CHANNEL_NOT_CONFIGURED | 403 | O canal WhatsApp não está configurado para esta empresa |
TEMPLATE_REQUIRED | 400 | O envio por WhatsApp Cloud API exige um template aprovado |
DUPLICATE_INVITE é o mais comum numa integração nova, e quase sempre é comportamento esperado, não bug: a regra é um contato, um convite por formulário, para a mesma pessoa não receber dois links e as duas respostas contarem.
js
// Padrão idempotente: cria, e se já existe, recupera
async function garantirConvite(formId, contato) {
const res = await api(`/forms/${formId}/invites`, { method: 'POST', body: contato });
if (res.status === 409) {
const { data } = await api(`/forms/${formId}/invites?limit=100`).then((r) => r.json());
return data.find((i) => i.email === contato.email || i.phone === contato.phone);
}
return (await res.json()).data;
}Limite de requisições
| Código | HTTP | O que fazer |
|---|---|---|
RATE_LIMITED | 429 | Espere o que diz o header retry-after (segundos) e repita |
É o único erro em que repetir é a resposta certa — desde que você respeite o retry-after.
Servidor
| Código | HTTP | O que fazer |
|---|---|---|
INTERNAL_ERROR | 500 | Falha nossa. Repita com backoff; se persistir, fale com o suporte |
Como tratar
Um esqueleto que cobre os casos acima:
js
async function chamar(caminho, opts = {}, tentativa = 0) {
const res = await fetch(`https://api.edusati.com/v1${caminho}`, {
...opts,
headers: {
Authorization: `Bearer ${process.env.EDUSATI_KEY}`,
'content-type': 'application/json',
...opts.headers,
},
});
if (res.ok) return (await res.json()).data;
const { error } = await res.json();
switch (error.code) {
// Repetir resolve
case 'RATE_LIMITED': {
const espera = Number(res.headers.get('retry-after') ?? 60);
await new Promise((r) => setTimeout(r, espera * 1000));
return chamar(caminho, opts, tentativa); // 429 não conta como tentativa
}
case 'INTERNAL_ERROR':
if (tentativa >= 3) throw new Error('Edusati indisponível');
await new Promise((r) => setTimeout(r, 2 ** tentativa * 1000));
return chamar(caminho, opts, tentativa + 1);
// Repetir nunca resolve — pare e avise alguém
case 'UNAUTHORIZED':
case 'FORBIDDEN':
case 'VALIDATION_ERROR':
case 'NOT_FOUND':
throw new Error(`${error.code}: ${error.message}`);
// Esperado no fluxo — trate onde faz sentido
default:
throw Object.assign(new Error(error.message), { code: error.code, details: error.details });
}
}A distinção que mais importa: 401 e 403 não são transitórios. Uma integração que repete indefinidamente com chave revogada só gasta o seu limite e esconde o problema real.
Sucesso que não é sucesso
Dois casos respondem 200 sem ter feito o que você esperava. Vale checar explicitamente:
Envio de convites — queued: 0 com skipped[] cheio significa que ninguém recebeu:
js
const { queued, skipped } = await enviar(...);
if (queued === 0) console.warn('Nada enviado:', skipped.map((s) => s.reason));Estatísticas — truncated: true significa que a varredura bateu no teto e os números são parciais. Recorte o período com from/to para obter um resultado completo.