Skip to content

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.

reasonSignificado
no_email / no_phoneO convite não tem o contato do canal escolhido
revokedConvite invalidado
respondedJá respondeu — não faz sentido reenviar
expiredPassou do expiresAt
suppressedDestinatário na lista de supressão (bounce anterior ou opt-out)
missing_varO 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.

Documentação da API pública do Edusati