Tema
Catálogo de eventos
Treze tipos, agrupados em quatro famílias. Todos chegam no mesmo envelope — o que muda é type e data.
| Tipo | Quando dispara |
|---|---|
response.created | Uma resposta foi finalizada |
invite.sent | A mensagem do convite saiu |
invite.delivered | O provedor confirmou a entrega |
invite.opened | O convidado abriu o link |
invite.started | O convidado começou a preencher |
invite.responded | O convite virou resposta |
invite.failed | Bounce, supressão ou falha de envio |
invite.revoked | O convite foi invalidado |
form.goal_reached | Um formulário atingiu a meta configurada |
enrollment.dropped | Uma matrícula passou para evadida |
treatment.opened | Uma resposta virou caso a tratar |
treatment.overdue | A etapa de um caso passou do prazo |
treatment.closed | O caso foi encerrado |
ping | Teste manual pelo painel |
response.created
Uma resposta foi finalizada — pelo link público ou por convite.
Rascunho não gera evento
Se o formulário permite "salvar e continuar", o progresso parcial não dispara nada. Só a finalização vira response.created.
json
{
"id": "8f4c1e2a-…",
"type": "response.created",
"version": 1,
"occurredAt": "2026-07-28T13:45:12.001Z",
"companyId": "c0ffee00-…",
"data": {
"formId": "f1a2b3c4-…",
"formName": "Pesquisa de satisfação — Julho",
"formSlug": "satisfacao-julho",
"responseId": "r1a2b3c4-…",
"inviteId": null,
"completedAt": "2026-07-28T13:45:11.980Z",
"respondent": {
"name": "Ana Prado",
"email": "ana@exemplo.com",
"phone": null,
"externalId": "MAT-4471"
},
"answers": {
"q_nps": 9,
"q_comentario": "Atendimento muito rápido",
"q_turno": "opt_manha"
},
"dims": {
"course": "ADM",
"shift": "Noite"
},
"orgUnitId": "u1a2b3c4-…",
"orgPath": "/r1a2b3c4-…/u1a2b3c4-…/",
"orgSource": "invite"
}
}| Campo | Tipo | Observação |
|---|---|---|
formId, formName, formSlug | string | Identificação do formulário |
responseId | string | Use em GET /v1/responses/{id} se precisar do registro completo |
inviteId | string | null | null = veio pelo link público, sem convite |
completedAt | string | null | ISO 8601 UTC |
respondent | objeto | null | Ver abaixo |
answers | objeto | Chave = id da pergunta |
dims | objeto | Campos de recorte carimbados; chave = chave do campo. Ver abaixo |
orgUnitId | string | null | Unidade em que a resposta aconteceu |
orgPath | string | null | Caminho da unidade (/<id>/<id>/) — agrupa por subárvore com um startsWith |
orgSource | string | null | Procedência: invite, link, class, answer ou none |
dims traz os campos de recorte que o formulário declara (curso, turma, unidade…), já resolvidos: você recorta por eles sem reler o convite nem interpretar respostas. Os valores são sempre string, na forma canônica em que foram gravados (número como "3", data como "2026-08-29"), então a comparação é a mesma em qualquer lugar. Formulário que não declara campo nenhum manda {}. As mesmas chaves valem como filtro na listagem de respostas: GET /v1/forms/{id}/responses?dim.course=<valor>.
Campo de cadastro guarda o id
Curso, docente e unidade são entidades cadastradas, e o valor que chega é o id delas — não o nome. É o que faz renomear um curso corrigir todo o histórico de uma vez, em vez de deixar o nome antigo congelado nas respostas antigas.
Ao enviar (criar convite com dims), você pode mandar o id ou o código que já usa no seu sistema: nós resolvemos. Ao receber, vem sempre o id.
respondent tem name, email, phone e externalId, todos opcionais. No fluxo por convite ele é montado no servidor a partir do convite — o respondente não consegue forjá-lo. No link público, vem dos parâmetros de prefill, quando houver.
answers segue o formato descrito em Interpretando as respostas. Pergunta oculta por condição ou em seção pulada simplesmente não aparece.
Eventos de convite
Os oito eventos invite.* compartilham o mesmo formato de data:
json
{
"id": "3b7d9e1f-…",
"type": "invite.delivered",
"version": 1,
"occurredAt": "2026-07-28T10:02:41.520Z",
"companyId": "c0ffee00-…",
"data": {
"formId": "f1a2b3c4-…",
"formName": "Pesquisa de satisfação — Julho",
"inviteId": "i1a2b3c4-…",
"status": "delivered",
"channel": "email",
"batchId": "b1a2c3d4-…",
"respondent": {
"name": "Ana Prado",
"email": "ana@exemplo.com",
"phone": null,
"externalId": "MAT-4471"
},
"failReason": null
}
}| Campo | Tipo | Observação |
|---|---|---|
formId, formName | string | formName pode vir null |
inviteId | string | Correlacione com o convite que você criou |
status | string | Estágio para o qual o convite acabou de mudar |
channel | string | null | email ou whatsapp |
batchId | string | null | Lote do envio; null se criado individualmente |
respondent | objeto | null | Mesmos campos, incluindo o seu externalId |
failReason | string | null | Só em invite.failed |
Use o externalId
Se você informou externalId ao criar o convite, ele volta em todo evento. É a forma mais direta de amarrar o evento ao registro do seu lado sem manter uma tabela de-para.
invite.sent
A mensagem saiu para o convidado — entregue ao provedor de e-mail ou WhatsApp.
Ainda não significa que chegou na caixa de entrada. É invite.delivered que diz isso.
invite.delivered
O provedor confirmou a entrega ao destinatário.
No e-mail depende do webhook de status do provedor; no WhatsApp, da confirmação da plataforma. Nem todo envio produz um delivered — a ausência dele não implica falha.
invite.opened
O convidado abriu o link pessoal.
Vem da resolução do próprio link (/i/:token), não de pixel de rastreamento. Por isso é bem mais confiável que "abriu o e-mail": não é afetado por bloqueio de imagens nem por pré-carregamento de caixa de entrada.
invite.started
Primeira interação real com o formulário — o convidado respondeu alguma coisa.
É o sinal de "engajou mas talvez não termine". Combinado com a ausência de invite.responded, identifica quem abandonou no meio.
invite.responded
O convite virou resposta.
Chega junto com um response.created do mesmo preenchimento. Os dois se complementam:
invite.respondedfecha o ciclo do convite (quem, de qual lote, por qual canal)response.createdtraz o conteúdo (as respostas)
Se você só quer o conteúdo, assine apenas response.created — o inviteId está lá.
invite.failed
Bounce, endereço na lista de supressão, ou falha no envio. O motivo vem em data.failReason:
failReason | Significado | O que fazer |
|---|---|---|
bounced | O endereço rejeitou permanentemente | Corrija o cadastro — reenviar não resolve |
suppressed | O destinatário está na lista de supressão (bounce anterior ou opt-out) | Respeite; não force |
send_failed | Falha técnica no envio | Pode tentar de novo mais tarde |
Bounce e supressão são definitivos
Reenviar para um endereço que deu bounce prejudica a reputação de entrega de todos os envios da empresa. Trate bounced e suppressed como pedido para parar.
invite.revoked
O convite foi invalidado, no painel ou por POST /v1/invites/{id}/revoke. O link para de funcionar imediatamente.
form.goal_reached
Um formulário atingiu a meta de respostas configurada numa regra de alerta.
json
{
"id": "5c8e2f0a-…",
"type": "form.goal_reached",
"version": 1,
"occurredAt": "2026-07-30T18:20:05.310Z",
"companyId": "c0ffee00-…",
"data": {
"formId": "f1a2b3c4-…",
"formName": "Pesquisa de satisfação — Julho",
"responseCount": 500,
"goal": 500,
"alertRuleId": "a1b2c3d4-…"
}
}| Campo | Tipo | Observação |
|---|---|---|
responseCount | number | Contagem no momento da travessia |
goal | number | Meta configurada na regra |
alertRuleId | string | null | Qual regra disparou |
Dispara na travessia, uma vez só
O evento sai quando a contagem cruza a meta, não a cada resposta acima dela. Rearmar exige resetar a regra no painel.
enrollment.dropped
Uma matrícula passou para evadida — pelo painel ou pelo envio de roster (POST /v1/enrollments/import).
json
{
"id": "9d3b1c77-…",
"type": "enrollment.dropped",
"version": 1,
"occurredAt": "2026-08-14T11:02:44.120Z",
"companyId": "c0ffee00-…",
"data": {
"enrollmentId": "e1a2b3c4-…",
"classId": "cl1a2b3c-…",
"classCode": "ADM-2026-1",
"className": "Administração — Noite",
"personId": "p1a2b3c4-…",
"externalId": "2026001",
"name": "João Souza",
"email": "joao@exemplo.com",
"phone": null,
"status": "dropped",
"previousStatus": "enrolled",
"courseItemId": "ci1a2b3c-…",
"orgUnitId": "ou1a2b3c-…",
"changedAt": "2026-08-14T11:02:44.100Z"
}
}| Campo | Tipo | Observação |
|---|---|---|
classCode | string | O código da turma no seu sistema |
externalId | string | null | A matrícula do aluno no seu sistema |
previousStatus | string | null | De onde a matrícula veio (enrolled, completed) |
changedAt | string | Quando a situação mudou (ISO 8601 UTC) |
Só a mudança dispara
Reenviar a mesma situação no roster não gera evento. O payload já traz turma, curso e unidade — você não precisa de uma segunda chamada para saber de onde a evasão veio.
Se o seu servidor ficou fora do ar
GET /v1/enrollments?status=dropped&changedSince=… responde a mesma pergunta por varredura, paginada por cursor. O webhook continua sendo o caminho normal; a varredura é a rede de segurança.
treatment.opened
Uma resposta satisfez a condição que a instituição configurou na pesquisa e virou um caso — com responsável, etapa e prazo.
json
{
"id": "5f2e1a90-…",
"type": "treatment.opened",
"version": 1,
"occurredAt": "2026-08-31T13:20:11.004Z",
"companyId": "c0ffee00-…",
"data": {
"treatmentId": "t1a2b3c4-…",
"number": 341,
"formId": "f1a2b3c4-…",
"formName": "Satisfação — Agosto",
"responseId": "r1a2b3c4-…",
"status": "open",
"stepName": "Tratativa e anotação",
"stepIndex": 0,
"dueAt": "2026-09-07T23:59:59.999Z",
"orgUnitId": "ou1a2b3c-…",
"orgPath": "/ou0a1b2c/ou1a2b3c/",
"at": "2026-08-31T13:20:11.000Z"
}
}| Campo | Tipo | Observação |
|---|---|---|
number | integer | O número do caso dentro da instituição — é por ele que ele é citado |
stepName | string | null | A etapa em que o caso está |
dueAt | string | null | Vencimento da etapa, contado em dias úteis (seg–sex) |
orgPath | string | null | Unidade da resposta, herdada no caso |
Um caso por resposta
A entrega é at-least-once, mas a abertura não é: reprocessar o mesmo fato não cria um segundo caso. O payload não traz as respostas — use o responseId com o response.created correspondente se precisar do conteúdo.
treatment.overdue
A etapa corrente de um caso passou do prazo. dueAt traz o vencimento ultrapassado.
json
{
"id": "7c9d0e11-…",
"type": "treatment.overdue",
"version": 1,
"occurredAt": "2026-09-08T09:00:03.552Z",
"companyId": "c0ffee00-…",
"data": {
"treatmentId": "t1a2b3c4-…",
"number": 341,
"formId": "f1a2b3c4-…",
"formName": "Satisfação — Agosto",
"responseId": "r1a2b3c4-…",
"status": "open",
"stepName": "Tratativa e anotação",
"stepIndex": 0,
"dueAt": "2026-09-07T23:59:59.999Z",
"orgUnitId": "ou1a2b3c-…",
"orgPath": "/ou0a1b2c/ou1a2b3c/",
"at": "2026-09-08T09:00:03.000Z"
}
}Uma vez por etapa
O vencimento sai uma vez por etapa. Se o caso avançar e a etapa seguinte também estourar, chega um novo evento — com stepIndex diferente.
treatment.closed
O caso foi encerrado: cumpriu a última etapa do fluxo, ou foi fechado antes (e aí reason traz o motivo registrado). O campo outcome diz como ele terminou.
json
{
"id": "8e0f1a22-…",
"type": "treatment.closed",
"version": 1,
"occurredAt": "2026-09-10T16:45:00.180Z",
"companyId": "c0ffee00-…",
"data": {
"treatmentId": "t1a2b3c4-…",
"number": 341,
"formId": "f1a2b3c4-…",
"formName": "Satisfação — Agosto",
"responseId": "r1a2b3c4-…",
"status": "closed",
"stepName": "Resposta final e encerramento",
"stepIndex": 3,
"dueAt": null,
"orgUnitId": "ou1a2b3c-…",
"orgPath": "/ou0a1b2c/ou1a2b3c/",
"at": "2026-09-10T16:45:00.000Z",
"outcome": "resolved",
"reason": null
}
}outcome | O que significa |
|---|---|
resolved | tratado — a demanda foi resolvida (é o valor de quem cumpriu o fluxo inteiro) |
unfounded | improcedente: a reclamação não se sustentou |
no-response | sem retorno de quem reclamou (informação insuficiente para tratar) |
duplicate | o mesmo caso já estava em tratativa |
other | o que não coube acima — vem com reason preenchido |
Casos encerrados antes da coluna existir chegam com outcome: null; trate-o como "sem desfecho registrado", nunca como resolved.
responseId pode ser nulo
Quando a verificação de eficácia de um caso aponta que a ação corretiva não funcionou, a instituição pode abrir um caso novo encadeado ao anterior. Esse caso trata a mesma queixa e chega com responseId: null — a resposta original está no caso anterior. Se o seu consumidor usa responseId como chave, trate o nulo antes de indexar.
A resposta enviada não vem no evento
Quando o fluxo tem uma etapa de resposta ao respondente, o texto enviado fica no caso — não no data. Ele carrega dado pessoal e a redação do atendimento, e nenhum consumidor externo precisa dele para reagir ao encerramento.
Reabertura não emite evento
Um caso encerrado pode ser reaberto no painel. A reabertura fica no histórico do caso, mas não gera um tipo novo — se você acompanha o estado, releia o caso em vez de assumir que closed é final.
ping
Disparado pelo botão Testar em Integrações › Webhooks. Não nasce de nenhum fato do domínio — serve para validar URL, assinatura e conectividade.
json
{
"id": "0a1b2c3d-…",
"type": "ping",
"version": 1,
"occurredAt": "2026-07-28T09:00:00.000Z",
"companyId": "c0ffee00-…",
"data": { "message": "ping" }
}Seu receptor deve respondê-lo com 2xx como qualquer outro. Se você assinou *, garanta que o ping não quebre o roteamento por tipo.
Tipos novos
O catálogo cresce. Se você assinou *, ignore com elegância o que não conhece — responda 200, não 400:
js
const handlers = {
'response.created': salvarResposta,
'invite.failed': marcarFalha,
};
const handler = handlers[evento.type];
if (!handler) return res.sendStatus(200); // não é erro do remetente
await handler(evento.data);O mesmo vale para campos novos dentro de data: eles aparecem sem aviso e sem bumpar a version. Ver compatibilidade.