Appearance
Disparo ativo — template para contato único e campanhas em massa
Envio proativo de mensagem WhatsApp, fora do ciclo reativo de flow — com validação de opt-in, rastreamento de status e roteamento pós-resposta. Dois caminhos compartilham a mesma infraestrutura de saída:
- Disparo único —
POST /routers/:routerId/dispatch/template, um template para um contato, retorna omessageIddo registro emmessages. - Campanhas —
POST /campaigns, disparo em massa (até 10.000 destinatários) com modostemplate/freeform/hybrid, rate limiting, agendamento e um worker durável que processa a fila deCampaignRecipient.
Visão geral
O disparo ativo permite que sistemas externos (CRMs, scripts, automações) e operadores do desk enviem mensagens diretamente para um contato sem depender de uma interação iniciada pelo usuário. Ambos os caminhos garantem que o contato deu opt-in para o router em questão e publicam a saída no mesmo stream (stream:outgoing-meta) consumido pelo flow-ai-meta-api.
O disparo único aceita apenas templates aprovados pela Meta e retorna o messageId que pode ser consultado para acompanhar o status de entrega.
POST /routers/:routerId/dispatch/template
│ (autoriza: admin ou agente com canActiveDispatch; API clients recusados)
▼
dispatchTemplate()
1. valida targetFlowId / targetQueueId (pertencem ao router, se informados)
2. valida template (APPROVED, pertence ao router)
3. valida número WhatsApp (ativo, pertence ao router)
4. find-or-create contato por phoneNumber
5. valida opt-in do contato para o router ← bloqueado aqui se ausente
6. find-or-create chat aberto (contactId + routerId)
7. gera internalId = cuid()
8. publica OutgoingStreamEvent { source: "dispatch" } em stream:outgoing-meta
9. se targetFlowId/targetQueueId: grava BroadcastIntent no Redis
10. retorna { messageId: internalId }
│
▼
stream:outgoing-meta
├── group: persist (orchestrator) → cria Message { id: internalId, status: "pending" }
└── group: send (meta-api) → envia à Meta, publica StatusStreamEvent em stream:status
│
▼
stream:status → orchestrator (group status) atualiza Message { status: "sent", whatsappMessageId: wamid }
→ (webhooks Meta) → "delivered" | "read" | "failed"Pré-requisitos
Antes de chamar o endpoint de disparo único:
- Template aprovado pela Meta — o template deve estar cadastrado no sistema com
status = "APPROVED". Templates com outros status (PENDING,REJECTED,PAUSED,DISABLED) são recusados. - Opt-in do contato — o contato deve ter um
ContactOptIncomstatus = "accepted"para o router. Apenas administradores podem enviardisableOptIn: truepara bypassar (compliance Meta/LGPD). O status de opt-in é o último registro no tempo do par(contactId, routerId). - Número WhatsApp ativo — o
whatsAppNumberIddeve pertencer ao router e estar comactive = true. - Autenticação + permissão de disparo ativo — o endpoint requer
Authorization: Bearer <token>de um usuário. Além do JWT válido, o chamador precisa ser admin OU ter um registro deAgentnaquele router cujocanActiveDispatch(do próprio agente ou herdado doAgentGroup) estejatrue. API clients (tokenfai_*) não têm permissão para disparo ativo (403).
Endpoint de disparo
POST /routers/:routerId/dispatch/template
Parâmetros de rota:
| Parâmetro | Tipo | Descrição |
|---|---|---|
routerId | string | ID do router ao qual o template e o número pertencem. |
Body:
ts
{
whatsAppNumberId: string // ID do número que enviará a mensagem
phoneNumber: string // Destinatário em E.164 sem "+" (ex: "5511999998888")
templateId: string // ID do MessageTemplate com status APPROVED
variables?: string[] // Valores posicionais para os {{N}} do template
disableOptIn?: boolean // default false — bypassa opt-in (somente admin)
targetFlowId?: string // Flow para iniciar quando o contato responder
targetBlockId?: string // Bloco inicial dentro de targetFlowId (exige targetFlowId)
targetQueueId?: string // Roteia a resposta direto para esta fila do helpdesk
targetUserId?: string // Atribui o ticket a este atendente (exige targetQueueId)
}
targetBlockIdsemtargetFlowIdé rejeitado no schema da rota. QuandotargetQueueIdé informado, ele tem prioridade sobretargetFlowIdno roteamento pós-resposta (a sessão entra em atendimento humano em vez de seguir um flow).
Resposta 200:
json
{ "messageId": "clx1a2b3c4d5e6f7g8h9i0j" }O messageId é um CUID que identifica o registro em messages após a persistência pelo consumer.
Variáveis do template
Os templates da Meta possuem placeholders posicionais {{1}}, {{2}}, etc. no componente BODY. O campo variables do request é um array onde cada posição corresponde ao placeholder de mesmo índice (base-1).
Exemplo: template com body "Olá, {{1}}! Seu pedido {{2}} está pronto."
json
{
"variables": ["Maria", "PED-4821"]
}Resulta em: "Olá, Maria! Seu pedido PED-4821 está pronto."
Variáveis em outros componentes (HEADER, FOOTER, BUTTONS) não são substituídas por este endpoint na versão atual — apenas o componente BODY é populado com os valores do array
variables.
Opt-in
O sistema mantém um histórico de opt-in por par (contactId, routerId). O último registro no tempo determina o status atual.
disableOptIn | Opt-in atual | Resultado |
|---|---|---|
false (padrão) | accepted | Disparo autorizado |
false (padrão) | revoked ou ausente | 403 — disparo bloqueado |
true (somente admin) | qualquer | Disparo autorizado (bypass) |
Somente administradores podem enviar
disableOptIn: true; para não-admins o campo resulta em 403. Os valores deContactOptIn.statussãoacceptederevoked.
Registrar opt-in de um contato via flow: use a action AcceptOptInAction dentro de um bloco do builder. Para registrar via API, consulte os endpoints de opt-in em /contacts/:contactId/opt-in e /routers/:routerId/opt-ins/bulk.
Roteamento pós-resposta
Por padrão, quando o contato responde ao template disparado, a sessão segue o initialFlowId configurado no router — o fluxo padrão de boas-vindas ou atendimento.
Dois destinos alternativos são possíveis:
- Flow específico — use
targetFlowId(e, opcionalmente,targetBlockId) para cair num fluxo de pós-venda, pesquisa de satisfação ou onboarding. - Helpdesk direto — use
targetQueueId(e, opcionalmente,targetUserId) para que a resposta abra atendimento humano direto na fila/atendente, sem passar por flow.
json
{
"whatsAppNumberId": "num_xyz789",
"phoneNumber": "5511999998888",
"templateId": "tpl_def456",
"targetFlowId": "flw_satisfaction_survey",
"targetBlockId": "blk_start_survey"
}O sistema grava um BroadcastIntent no Redis (broadcast:intent:{phoneNumberId}:{phoneNumber}, TTL 7 dias). Na primeira mensagem inbound do contato, o orchestrator lê esse intent e:
- se o intent traz
targetQueueId, cria a sessão emmode: "human"e publica o handoff direto para a fila (comtargetUserId, se informado); - caso contrário, sobrescreve o
initialFlowId(e opcionalmentecurrentBlockId) e inicia o flow especificado.
Em ambos os casos a chave é apagada automaticamente após o consumo. No disparo único, o BroadcastIntent.campaignId é gravado como dispatch:{internalId} (nas campanhas ele carrega o Campaign.id real).
Se o contato não responder dentro de 7 dias, o intent expira e a sessão segue o fluxo padrão do router.
O roteamento por
BroadcastIntentsó se aplica ao canal WhatsApp — webchat/Instagram não usam essa chave.
Acompanhar status de entrega
GET /messages/:messageId
Retorna o estado atual do registro no banco:
json
{
"id": "clx1a2b3c4d5e6f7g8h9i0j",
"status": "delivered",
"type": "template",
"whatsappMessageId": "wamid.ABCD1234...",
"createdAt": "2025-05-13T14:22:00.000Z",
"statusUpdatedAt": "2025-05-13T14:22:03.000Z"
}Ciclo de vida do campo status:
| Status | Quando ocorre |
|---|---|
pending | Mensagem publicada no stream, ainda não enviada à Meta |
sent | Meta confirmou o recebimento (wamid retornado) |
delivered | Dispositivo do contato confirmou entrega (webhook Meta) |
read | Contato abriu a mensagem (webhook Meta) |
failed | Envio rejeitado pela Meta ou webhook de falha recebido |
pendingé um estado transitório. Em condições normais, a mensagem passa parasentem menos de 1 segundo.
Campanhas (disparo em massa)
Enquanto o disparo único envia um template para um contato, as campanhas disparam para até 10.000 destinatários com controle de estado durável. Cada campanha é uma Campaign + N CampaignRecipient; um worker (campaign-worker) processa a fila em lotes respeitando rate limit, janela de 24h e opt-in.
Autorização é diferente do disparo único: as rotas de campanha usam RBAC por permissão (requirePermission("campaigns", "read" | "write")), e não o gate canActiveDispatch.
Modos
| Modo | Conteúdo exigido | Alcance |
|---|---|---|
template | templateId (APPROVED) | Qualquer destinatário; fora da janela envia o template. Em janela aberta, o worker pode renderizar o template como mensagens livres (texto/botões/imagem) quando o conteúdo comporta. |
freeform | freeformContent | Todos os destinatários precisam estar com a janela de 24h aberta no create (freeform não reabre janela) — senão o create falha. |
hybrid | templateId e freeformContent | Decisão por destinatário no envio: janela aberta → freeform; fora → template. A janela é re-checada no worker (não usa o snapshot do create). |
freeformContent aceita o novo formato ContentMessage[] (mesmo shape dos blocos do builder) ou o legado { text?, mediaId?, mediaType?, filename? }.
Rotas
| Rota | Descrição |
|---|---|
POST /campaigns | Cria a campanha (status inicial scheduled ou in_progress). |
POST /campaigns/reengage | Reengaja contatos de atendimentos abandonados; targetQueueId obrigatório (sempre roteia de volta pro helpdesk). |
GET /campaigns?routerId=&status= | Lista campanhas no escopo RBAC do chamador. |
GET /campaigns/:id | Detalhe (com template + número + contadores). |
GET /campaigns/:id/recipients?status=&limit=&cursor= | Recipients paginados. |
POST /campaigns/:id/pause / /resume / /cancel | Controle de execução (cancel é terminal). |
POST /campaigns/check-window | Pré-checa a janela de 24h por telefone (usado pelo wizard da UI). |
Body do POST /campaigns (principais campos):
ts
{
routerId: string
whatsAppNumberId: string
name: string
mode: "template" | "freeform" | "hybrid"
templateId?: string // exigido em template/hybrid
freeformContent?: ContentMessage[] | { text?, mediaId?, mediaType?, filename? }
targetFlowId?: string // roteamento pós-resposta (flow)
targetBlockId?: string
targetQueueId?: string // roteamento pós-resposta (helpdesk) — tem prioridade
targetUserId?: string
scheduledAt?: string // ISO 8601 — agenda o início
rateLimitPerSec?: number // 1–80; default 10
recipients: { contactId?, phoneNumber, name?, variables? }[] // 1–10.000
}Ciclo do worker
O campaign-worker roda um polling loop (não é um consumer de stream). A cada tick:
- busca campanhas due (
in_progress, ouscheduledcomscheduledAt <= now); - promove
scheduled → in_progress; - faz claim de um lote de recipients
pending(marcandosending); - para cada recipient, aplica os gates e, se passar, publica um
OutgoingStreamEventemstream:outgoing-meta(o worker usasource: "flow", não"dispatch") e marcasent; - respeita
rateLimitPerSecentre envios; - grava
BroadcastIntentno Redis quando a campanha temtargetFlowIdoutargetQueueId; - chama
finalizeIfDone(marcacompletedquando não há mais recipients ativos).
Gates por recipient (recipient é cancelled e a campanha segue em frente):
- Atendimento humano — contato em conversa ao vivo no desk é pulado (campanha não atravessa atendimento humano).
- Opt-in — sem
ContactOptIncomstatus = "accepted"para o router, o recipient é cancelado. Campanhas não têm bypass de opt-in (ao contrário dodisableOptIndo disparo único).
Status do recipient
Cada CampaignRecipient funciona como uma fila durável. O consumer core-campaigns-status (grupo próprio sobre stream:status) cruza os eventos de status:
| Status | Origem |
|---|---|
pending | Aguardando claim pelo worker |
sending | Reivindicado no lote atual |
sent | Meta confirmou o envio — match por internalId (= recipient.id), grava wamid |
delivered / read | Webhook Meta — match por wamid |
failed | send_failed (por internalId) ou falha de entrega pós-envio (por wamid) |
cancelled | Pulado por gate (humano/opt-in) ou por pause/cancel da campanha |
Os contadores agregados vivem na própria Campaign: totalRecipients, sentCount, failedCount, cancelledCount, além de sentByTemplateCount / sentByFreeformCount (quantos dos sent saíram como template vs. mensagem livre).
Exemplos
Disparo básico
bash
curl -X POST https://api.example.com/routers/rtr_abc123/dispatch/template \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"whatsAppNumberId": "num_xyz789",
"phoneNumber": "5511999998888",
"templateId": "tpl_def456"
}'json
{ "messageId": "clx1a2b3c4d5e6f7g8h9i0j" }Disparo com variáveis
bash
curl -X POST https://api.example.com/routers/rtr_abc123/dispatch/template \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"whatsAppNumberId": "num_xyz789",
"phoneNumber": "5511999998888",
"templateId": "tpl_def456",
"variables": ["Maria", "PED-4821"]
}'Disparo com bypass de opt-in
bash
curl -X POST https://api.example.com/routers/rtr_abc123/dispatch/template \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"whatsAppNumberId": "num_xyz789",
"phoneNumber": "5511999998888",
"templateId": "tpl_def456",
"disableOptIn": true
}'Consulta de status
bash
curl https://api.example.com/messages/clx1a2b3c4d5e6f7g8h9i0j \
-H "Authorization: Bearer $TOKEN"Erros
| Código | Motivo |
|---|---|
401 | Token JWT ausente/inválido, ou usuário não encontrado |
403 | API client (fai_*) — sem permissão para disparo ativo |
403 | Usuário sem canActiveDispatch (nem admin) neste router |
403 | disableOptIn: true enviado por não-admin |
403 | Contato sem opt-in para este router (disableOptIn não foi passado) |
422 | Template não encontrado, não aprovado ou não pertence ao router |
422 | Número WhatsApp não encontrado, inativo ou não pertence ao router |
422 | targetFlowId ou targetQueueId não pertence ao router |
Arquivos relevantes
| Arquivo | Papel |
|---|---|
services/flow-ai-core/src/http/routes/dispatch.routes.ts | Rotas de disparo único; autorização (canActiveDispatch, admin, opt-in) |
services/flow-ai-core/src/services/dispatch.service.ts | Disparo único: validações, find-or-create contato/chat, publicação no stream (source: "dispatch"), gravação do BroadcastIntent |
services/flow-ai-core/src/http/routes/campaign.routes.ts | CRUD + ações de campanha (/campaigns, /reengage, /check-window) |
services/flow-ai-core/src/services/campaign.service.ts | Regras de create/pause/resume/cancel/finalizeIfDone e resolução de recipients |
services/flow-ai-core/src/consumers/campaign-worker.ts | Worker de polling que processa a fila de CampaignRecipient |
services/flow-ai-core/src/consumers/campaign-status.consumer.ts | Grupo core-campaigns-status — cruza stream:status por internalId/wamid |
services/flow-ai-core/src/services/contact-opt-in.service.ts | getCurrentStatus() — lê opt-in atual do contato por router |
services/flow-ai-orchestrator/src/consumers/persist.ts | Persiste mensagem com id = internalId ao receber o evento |
services/flow-ai-orchestrator/src/consumers/status.ts | Atualiza status e wamid conforme stream:status |
services/flow-ai-orchestrator/src/handlers/incoming.ts | Lê o BroadcastIntent na resposta do contato e roteia pra flow ou helpdesk |
packages/flow-ai-types/src/stream-events.ts | OutgoingStreamEvent (source: "flow" | "agent" | "dispatch"), OutgoingMessage |
packages/flow-ai-types/src/whatsapp.ts | BroadcastIntent (targetQueueId/targetUserId), tipos de mensagem outbound |
packages/flow-ai-database/prisma/schema.prisma | Modelos Campaign, CampaignRecipient, ContactOptIn; AgentGroup.canActiveDispatch |