Tema
Disparar convites de um sistema externo
Você tem a lista de quem deve responder no seu sistema (matrículas, clientes, pacientes) e quer que o Edusati envie o convite — ou quer só o link pessoal para distribuir por conta própria.
O modelo
Um convite é um link pessoal amarrado a um contato. Criar e enviar são passos separados:
POST /forms/{id}/invites → cria, devolve a `url`. Não envia nada.
POST /forms/{id}/invites/send → envia por e-mail ou WhatsApp. Consome quota.Se você já tem seu próprio canal (o app do aluno, uma mensagem no seu sistema), use só o primeiro passo e distribua a url. Você economiza a quota de mensagens e mantém a comunicação na sua identidade visual.
Criar convites
js
const BASE = 'https://api.edusati.com/v1';
const headers = {
Authorization: `Bearer ${process.env.EDUSATI_KEY}`,
'content-type': 'application/json',
};
async function criarConvite(formId, contato) {
const res = await fetch(`${BASE}/forms/${formId}/invites`, {
method: 'POST',
headers,
body: JSON.stringify({
name: contato.nome,
email: contato.email,
externalId: contato.matricula, // volta em toda resposta e evento
expiresAt: contato.prazo, // opcional
}),
});
const corpo = await res.json();
if (res.status === 409) {
return { jaExistia: true }; // DUPLICATE_INVITE — ver abaixo
}
if (!res.ok) {
throw new Error(`${corpo.error.code}: ${corpo.error.message}`);
}
return { convite: corpo.data };
}Sempre informe o externalId
É o seu identificador (matrícula, id do cliente). Ele volta em respondent.externalId nas respostas e em todos os eventos de webhook — é o que amarra o convite ao registro do seu lado sem manter uma tabela de-para.
Um contato, um convite por formulário
Criar o mesmo e-mail (ou telefone) duas vezes no mesmo formulário responde 409 DUPLICATE_INVITE. Isso é regra, não bug: a mesma pessoa com dois links responderia duas vezes e as duas contariam.
Para tornar sua rotina reexecutável sem medo, recupere o existente:
js
async function garantirConvite(formId, contato) {
const r = await criarConvite(formId, contato);
if (!r.jaExistia) return r.convite;
// Busca o que já existe. Em lista grande, vale manter o id do seu lado
// desde a primeira criação em vez de procurar.
for await (const convite of varrerConvites(formId)) {
if (convite.email === contato.email || convite.externalId === contato.matricula) {
return convite;
}
}
throw new Error('Convite duplicado, mas não encontrado');
}O mais eficiente é guardar o id e a url do convite no seu banco na primeira criação. Aí a reexecução nem chega a chamar a API para quem já tem convite.
Em lote
Não há endpoint de criação em lote. Crie em série, com um pouco de concorrência e respeitando o limite de requisições:
js
async function criarEmLote(formId, contatos, concorrencia = 5) {
const fila = [...contatos];
const criados = [];
async function worker() {
while (fila.length) {
const contato = fila.shift();
try {
criados.push(await garantirConvite(formId, contato));
} catch (err) {
console.error(`Falhou ${contato.matricula}:`, err.message);
}
}
}
await Promise.all(Array.from({ length: concorrencia }, worker));
return criados;
}Com o padrão de 60 requisições por minuto, uma concorrência baixa é suficiente e evita o 429. Se você precisa de mais vazão, peça ao administrador da conta para elevar o rateLimitPerMin da chave.
Enviar
js
async function enviar(formId, inviteIds, { canal = 'email', agendarPara } = {}) {
const res = await fetch(`${BASE}/forms/${formId}/invites/send`, {
method: 'POST',
headers,
body: JSON.stringify({
channel: canal,
inviteIds,
...(agendarPara ? { scheduleAt: agendarPara } : {}),
}),
});
const corpo = await res.json();
if (!res.ok) throw new Error(`${corpo.error.code}: ${corpo.error.message}`);
const { batchId, queued, skipped } = corpo.data;
if (skipped.length) {
console.warn(`${skipped.length} convites não foram enviados:`);
for (const s of skipped) console.warn(` ${s.id}: ${s.reason}`);
}
return { batchId, queued, skipped };
}Sempre percorra skipped[]
A resposta 200 com queued: 0 é sucesso — e não enviou nada. Convite sem e-mail, já respondido, expirado, revogado ou com endereço suprimido é pulado silenciosamente, porque envio parcial é o comportamento esperado, não um erro.
Uma integração que só checa o código HTTP acha que enviou 5.000 convites quando enviou zero.
reason | Significado |
|---|---|
no_email / no_phone | O convite não tem o contato do canal escolhido |
revoked | Convite invalidado |
responded | Já respondeu — não faz sentido reenviar |
expired | Passou do expiresAt |
suppressed | Destinatário na lista de supressão (bounce anterior ou opt-out) |
missing_var | O template usa uma variável que este convite não tem preenchida |
Agendar
scheduleAt (ISO 8601 UTC) agenda o disparo. Data no passado é ignorada e o envio sai imediatamente.
js
await enviar(formId, ids, { agendarPara: '2026-08-05T12:00:00.000Z' });Selecionar sem listar ids
Além de inviteIds, dá para selecionar por batchId (o lote de um envio anterior) ou por filter.statuses. Útil para reenviar só para quem não respondeu:
js
// Segunda tentativa: só quem recebeu e não respondeu
await fetch(`${BASE}/forms/${formId}/invites/send`, {
method: 'POST',
headers,
body: JSON.stringify({
channel: 'email',
filter: { statuses: ['sent', 'delivered', 'opened'] },
}),
});Sem inviteIds, batchId nem filter, todos entram
A ausência dos três seleciona todos os convites do formulário. Num formulário grande, isso é um envio em massa acidental que consome quota e chega a quem já respondeu. Seja sempre explícito.
Acompanhar o resultado
O 200 do envio significa "enfileirado". O que aconteceu de verdade chega por webhooks:
js
const acoes = {
'invite.sent': (d) => marcar(d.respondent?.externalId, 'enviado'),
'invite.delivered': (d) => marcar(d.respondent?.externalId, 'entregue'),
'invite.opened': (d) => marcar(d.respondent?.externalId, 'aberto'),
'invite.responded': (d) => marcar(d.respondent?.externalId, 'respondido'),
'invite.failed': (d) => registrarFalha(d.respondent?.externalId, d.failReason),
};
const acao = acoes[evento.type];
if (acao) await acao(evento.data);Sem webhook, a alternativa é consultar GET /forms/{id}/invites periodicamente e olhar o effectiveStatus de cada um — funciona, mas gasta requisição e chega atrasado.
effectiveStatus vs status
status é o valor gravado. effectiveStatus é o que você deve exibir: ele reflete a expiração já vencida, que o status gravado ainda não mostra.
O filtro ?status= da listagem age sobre o valor gravado.
Quota de envio
Cada envio consome a quota mensal de mensagens da empresa. Esgotada, o envio responde 403 QUOTA_EXCEEDED com o consumo em error.details.
Criar convites não consome quota — só enviar. Se a quota é apertada, o padrão de criar os convites pela API e distribuir os links pelo seu próprio canal evita o custo inteiro.
Revogar
js
await fetch(`${BASE}/invites/${inviteId}/revoke`, { method: 'POST', headers });O link para de funcionar imediatamente. Envios pendentes daquele convite são cancelados (com devolução da quota reservada) e o rascunho salvo é apagado.
Convite já respondido responde 409 ALREADY_RESPONDED — revogar não teria efeito sobre uma resposta que já existe.