Skip to content

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 únicoPOST /routers/:routerId/dispatch/template, um template para um contato, retorna o messageId do registro em messages.
  • CampanhasPOST /campaigns, disparo em massa (até 10.000 destinatários) com modos template / freeform / hybrid, rate limiting, agendamento e um worker durável que processa a fila de CampaignRecipient.

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:

  1. 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.
  2. Opt-in do contato — o contato deve ter um ContactOptIn com status = "accepted" para o router. Apenas administradores podem enviar disableOptIn: true para bypassar (compliance Meta/LGPD). O status de opt-in é o último registro no tempo do par (contactId, routerId).
  3. Número WhatsApp ativo — o whatsAppNumberId deve pertencer ao router e estar com active = true.
  4. 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 de Agent naquele router cujo canActiveDispatch (do próprio agente ou herdado do AgentGroup) esteja true. API clients (token fai_*) não têm permissão para disparo ativo (403).

Endpoint de disparo

POST /routers/:routerId/dispatch/template

Parâmetros de rota:

ParâmetroTipoDescrição
routerIdstringID 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)
}

targetBlockId sem targetFlowId é rejeitado no schema da rota. Quando targetQueueId é informado, ele tem prioridade sobre targetFlowId no 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.

disableOptInOpt-in atualResultado
false (padrão)acceptedDisparo autorizado
false (padrão)revoked ou ausente403 — disparo bloqueado
true (somente admin)qualquerDisparo autorizado (bypass)

Somente administradores podem enviar disableOptIn: true; para não-admins o campo resulta em 403. Os valores de ContactOptIn.status são accepted e revoked.

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 em mode: "human" e publica o handoff direto para a fila (com targetUserId, se informado);
  • caso contrário, sobrescreve o initialFlowId (e opcionalmente currentBlockId) 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 BroadcastIntent só 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:

StatusQuando ocorre
pendingMensagem publicada no stream, ainda não enviada à Meta
sentMeta confirmou o recebimento (wamid retornado)
deliveredDispositivo do contato confirmou entrega (webhook Meta)
readContato abriu a mensagem (webhook Meta)
failedEnvio rejeitado pela Meta ou webhook de falha recebido

pending é um estado transitório. Em condições normais, a mensagem passa para sent em 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

ModoConteúdo exigidoAlcance
templatetemplateId (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.
freeformfreeformContentTodos os destinatários precisam estar com a janela de 24h aberta no create (freeform não reabre janela) — senão o create falha.
hybridtemplateId e freeformContentDecisã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

RotaDescrição
POST /campaignsCria a campanha (status inicial scheduled ou in_progress).
POST /campaigns/reengageReengaja 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/:idDetalhe (com template + número + contadores).
GET /campaigns/:id/recipients?status=&limit=&cursor=Recipients paginados.
POST /campaigns/:id/pause / /resume / /cancelControle de execução (cancel é terminal).
POST /campaigns/check-windowPré-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:

  1. busca campanhas due (in_progress, ou scheduled com scheduledAt <= now);
  2. promove scheduled → in_progress;
  3. faz claim de um lote de recipients pending (marcando sending);
  4. para cada recipient, aplica os gates e, se passar, publica um OutgoingStreamEvent em stream:outgoing-meta (o worker usa source: "flow", não "dispatch") e marca sent;
  5. respeita rateLimitPerSec entre envios;
  6. grava BroadcastIntent no Redis quando a campanha tem targetFlowId ou targetQueueId;
  7. chama finalizeIfDone (marca completed quando 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 ContactOptIn com status = "accepted" para o router, o recipient é cancelado. Campanhas não têm bypass de opt-in (ao contrário do disableOptIn do 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:

StatusOrigem
pendingAguardando claim pelo worker
sendingReivindicado no lote atual
sentMeta confirmou o envio — match por internalId (= recipient.id), grava wamid
delivered / readWebhook Meta — match por wamid
failedsend_failed (por internalId) ou falha de entrega pós-envio (por wamid)
cancelledPulado 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ódigoMotivo
401Token JWT ausente/inválido, ou usuário não encontrado
403API client (fai_*) — sem permissão para disparo ativo
403Usuário sem canActiveDispatch (nem admin) neste router
403disableOptIn: true enviado por não-admin
403Contato sem opt-in para este router (disableOptIn não foi passado)
422Template não encontrado, não aprovado ou não pertence ao router
422Número WhatsApp não encontrado, inativo ou não pertence ao router
422targetFlowId ou targetQueueId não pertence ao router

Arquivos relevantes

ArquivoPapel
services/flow-ai-core/src/http/routes/dispatch.routes.tsRotas de disparo único; autorização (canActiveDispatch, admin, opt-in)
services/flow-ai-core/src/services/dispatch.service.tsDisparo ú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.tsCRUD + ações de campanha (/campaigns, /reengage, /check-window)
services/flow-ai-core/src/services/campaign.service.tsRegras de create/pause/resume/cancel/finalizeIfDone e resolução de recipients
services/flow-ai-core/src/consumers/campaign-worker.tsWorker de polling que processa a fila de CampaignRecipient
services/flow-ai-core/src/consumers/campaign-status.consumer.tsGrupo core-campaigns-status — cruza stream:status por internalId/wamid
services/flow-ai-core/src/services/contact-opt-in.service.tsgetCurrentStatus() — lê opt-in atual do contato por router
services/flow-ai-orchestrator/src/consumers/persist.tsPersiste mensagem com id = internalId ao receber o evento
services/flow-ai-orchestrator/src/consumers/status.tsAtualiza status e wamid conforme stream:status
services/flow-ai-orchestrator/src/handlers/incoming.tsLê o BroadcastIntent na resposta do contato e roteia pra flow ou helpdesk
packages/flow-ai-types/src/stream-events.tsOutgoingStreamEvent (source: "flow" | "agent" | "dispatch"), OutgoingMessage
packages/flow-ai-types/src/whatsapp.tsBroadcastIntent (targetQueueId/targetUserId), tipos de mensagem outbound
packages/flow-ai-database/prisma/schema.prismaModelos Campaign, CampaignRecipient, ContactOptIn; AgentGroup.canActiveDispatch

flow-ai — plataforma proprietária de atendimento via WhatsApp