Skip to content

WhatsApp Flows declarativos

WhatsApp Flows (Meta) são formulários interativos multi-tela que rodam dentro do aplicativo WhatsApp. São fundamentalmente diferentes dos flows internos do engine:

Flows internosWhatsApp Flows
O que sãoMáquina de estados conversacionalFormulários/telas interativas da Meta
EstadoSessionState no Redis, persistenteStateless — cada submit é independente
Execuçãoflow-ai-engineflow-ai-whatsapp-flow-engine
TriggerQualquer mensagem do usuárioBotão interativo enviado pelo flow interno
RespostaMensagens de texto/mídia no chatDados do formulário enviados via webhook

Ciclo de vida: UNPUBLISHED → DRAFT → PUBLISHED

┌──────────────────────────────────────────────────────────────────┐
│  UNPUBLISHED                                                      │
│  flow criado no banco, sem metaFlowId ainda                      │
└──────────────────────┬───────────────────────────────────────────┘
                       │ POST /:id/meta/draft

┌──────────────────────────────────────────────────────────────────┐
│  DRAFT                                                            │
│  metaFlowId atribuído pela Meta                                  │
│  flow.json validado pela Meta (validationErrors)                 │
│  Webhook URI configurado em endpointUri                          │
└──────────────────────┬───────────────────────────────────────────┘
                       │ POST /:id/meta/publish

┌──────────────────────────────────────────────────────────────────┐
│  PUBLISHED                                                        │
│  Disponível para uso em mensagens interativas                    │
│  lastPublishedAt atualizado                                      │
└──────────────────────────────────────────────────────────────────┘
                       ↕ POST /:id/meta/sync
                         (consulta Meta a qualquer momento)

Draft (POST /:id/meta/draft)

Três chamadas à Meta Cloud API em sequência:

  1. POST /{waba-id}/flows → cria o flow na Meta, recebe metaFlowId (ou atualiza nome/categorias/endpoint_uri se já existe)
  2. POST /{metaFlowId}/assets → faz upload do flowDefinitionJson (Meta Flow Definition v6.0)
  3. Meta valida o JSON e retorna validation_errors[] — salvo em validationErrors no banco

Após o draft, o cache Redis wa:flow:{metaFlowId} é sincronizado com o endpointConfig.

endpointMode — quem processa o webhook

O endpoint_uri registrado na Meta depende do campo endpointMode do flow (precedência em buildEndpointUri):

endpointModeendpoint_uri registradoQuem processa
declarative (padrão){META_FLOW_PUBLIC_URL}/public/whatsapp-flows/{metaFlowId}/webhookengine declarativa dentro do flow-ai-meta-api
externalendpointUrl do próprio flowendpoint HTTP externo (fora da plataforma)

Nota: META_FLOW_ENDPOINT_URL é um override global que aponta para uma URL externa e tem precedência sobre o webhook do meta-api mesmo em modo declarative. Quando definido, a Meta posta na URL externa e a engine declarativa do meta-api não roda. Ordem em buildEndpointUri: external+endpointUrl > META_FLOW_ENDPOINT_URL > webhook do meta-api.

Modo external exige endpointUrl preenchido; modo declarative exige que o servidor tenha META_FLOW_ENDPOINT_URL ou META_FLOW_PUBLIC_URL configurado (caso contrário saveDraft lança 500). O campo flowAction do flow (navigate estático ou data_exchange dinâmico via webhook) e dataApiVersion (padrão 3.0) também vivem no WhatsAppFlow.

Publish (POST /:id/meta/publish)

Exige metaFlowId já existente. Uma única chamada à Meta:

POST /{metaFlowId}/publish

Meta ativa o flow. publishState atualizado para "PUBLISHED", lastPublishedAt gravado. Log de publicação criado em whatsapp_flow_publish_logs.

Sync (POST /:id/meta/sync)

Consulta o estado atual na Meta (sem alterar nada do lado da Meta) e reflete no banco:

GET /{metaFlowId}?fields=id,name,status,validation_errors,endpoint_uri,categories

Atualiza publishState, validationErrors e lastSyncedAt; e, quando a Meta os devolve, também endpointUri e categories. Mapeamento de status Meta → local:

Status MetapublishState local
DRAFT"DRAFT"
PUBLISHED"PUBLISHED"
DEPRECATED"DEPRECATED"
BLOCKED"BLOCKED"
(qualquer outro / ausente)"UNPUBLISHED"

O estado "ERROR" não vem do sync — é gravado apenas quando o draft ou o publish falham (validation errors da Meta ou exceção durante a chamada).


endpointConfig — o engine declarativo

O campo endpointConfig do WhatsAppFlow é um JSON que instrui o flow-ai-whatsapp-flow-engine sobre como processar cada webhook do formulário sem código customizado. É uma micro-linguagem declarativa com seis tipos de passo: validate, http, branch, set, map e tool.

typescript
type FlowEndpointConfig = {
  enabled:        boolean
  baseUrl?:       string
  defaultHeaders?: Record<string, string>
  env?:           Record<string, string | number | boolean>  // variáveis interpoláveis
  actions:        Record<string, FlowEndpointAction>
  //               └─ chave: "init" | "{screen}.{action}" | "{action}"
  __layout__?:    Record<string, { x: number; y: number }>   // posições no builder — engine ignora
  deferredJobs?:  FlowEndpointDeferredJob[]                   // jobs pós-request — engine ignora (ver "Jobs deferidos")
}

type FlowEndpointAction = {
  description?: string
  steps:       FlowEndpointStep[]           // validate | http | branch | set | map | tool
  response?:   FlowEndpointResponseConfig   // resposta padrão após todos os steps
  error?:      FlowEndpointErrorConfig
}

Nota: __layout__ e deferredJobs só existem para UI/pós-processamento. O engine (flow-ai-whatsapp-flow-engine) os ignora — quem lê deferredJobs é o flow-ai-core (ver a seção "Jobs deferidos").

Passo validate

typescript
{
  type: "validate",
  rules: [
    { field: "email",    type: "required" },
    { field: "cpf",      type: "cpf" },      // valida CPF brasileiro
    { field: "birthday", type: "min_age", value: 18 },      // value = anos
    { field: "phone",    type: "regex",   value: "^\\d{10,11}$" }  // value = pattern
  ]
}

FlowValidateRule tem apenas os campos field, type (required/cpf/min_age/regex), value? e message?. Para min_age o value é o número de anos; para regex o value é o pattern (não há campo pattern).

Se qualquer regra falhar, o engine retorna error_messages imediatamente — sem executar passos subsequentes. O formulário exibe os erros ao usuário.

Passo http

typescript
{
  type:      "http",
  method:    "POST",
  url:       "{{env.API_BASE}}/submit",
  headers:   { "Authorization": "Bearer {{env.API_TOKEN}}" },
  body:      { "data": "{{data}}", "user": "{{data.name}}" },
  resultKey: "submitResult",            // armazena resposta no contexto
  conditions: [
    {
      // se submitResult.status === "error" → transiciona para tela de erro
      field:     "submitResult.status",
      operator:  "equals",
      value:     "error",
      nextScreen: "error_screen"
    }
  ]
}

{{data}} é o payload completo do formulário. {{data.campo}} acessa campos individuais. {{env.X}} acessa variáveis declaradas em config.env. {{resultKey.campo}} acessa respostas de passos HTTP anteriores.

conditions — como uma condição casa

Cada FlowEndpointCondition é avaliada em ordem; na primeira que casa, o engine decide a saída. O shape não tem action/screen — usa três campos mutuamente exclusivos (precedência de cima para baixo):

CampoEfeito quando a condição casa
error: { field, message }Permanece na tela atual e devolve error_messages: { [field]: message } — erro inline no campo (mesma semântica do validate). Tem precedência sobre os demais.
nextScreen: stringTransiciona para essa tela na resposta.
stopExecution: trueEncerra a action e monta a resposta padrão.

Operadores suportados (FlowEndpointConditionOperator): exists, equals, not_equals, truthy, falsy, contains, non_empty, empty.

O error inline atende o padrão "consultar API externa e, se inválido, mostrar o erro no próprio campo" (ex.: validar CPF numa base externa) sem trocar de tela.

Passo branch

Ramifica sem chamar HTTP — avalia conditions contra o contexto e pode nextScreen/stopExecution a partir do input do usuário (ex.: escolher a próxima tela conforme um search_type).

typescript
{ type: "branch", conditions: [ /* FlowEndpointCondition[] */ ] }

Passo set

Escreve/compõe campos escalares em data via interpolação (replica o "adicionar valores ao form" que um endpoint externo faria). Quando o template é exatamente um único token {{caminho}}, resolve para o valor cru (array/objeto/número), não string.

typescript
{
  type: "set",
  values: { full_address: "{{addr.logradouro}}, {{data.numberAddress}}" }
}

Passo map

Mapeia um array do contexto para um campo de data, renomeando os campos de cada item via {{item.x}}.

typescript
{
  type: "map",
  from: "addr.condominios",           // array no contexto
  to:   "cond_items",                 // campo destino em data
  item: { id: "{{item.id}}", title: "{{item.condominio}}" }
}

Passo tool

Invoca uma Tool/Ferramenta publicada (reuso DRY de uma integração já cadastrada — ex.: consulta externa, validação de CPF). No flow-ai-meta-api o toolExecutor injetado (executeToolViaCore) delega para o endpoint interno do flow-ai-core (POST /internal/tools/execute, timeout de 8s) — o meta-api permanece "burro", sem executeTool nem banco. A saída (string; JSON é parseado) vira ctx[resultKey].

typescript
{
  type: "tool",
  publishedToolId: "tool_abc",
  inputMappings: { cpf: "{{data.cpf}}" },
  resultKey: "lookup",
  conditions: [ /* opcional */ ]
}

Nota: só tools "puras" (sem dependência de contato/sessão) fazem sentido aqui — a execução usa uma sessão stub. Sem FLOW_AI_CORE_INTERNAL_URL configurado no meta-api, o passo tool falha de forma tratável.

Resolução de action

O engine resolve a action key combinando screen + action do webhook:

"{screen}.{action}"   → procura exatamente essa chave em config.actions
"{action}"            → fallback se a chave composta não existir

A action que a Meta envia ao abrir o formulário (antes de o usuário preencher qualquer campo) costuma ser usada para carregar dados iniciais — ex.: preencher dropdowns com dados de uma API. O engine não trata nenhuma chave de forma especial: o lookup em config.actions é uma comparação de string case-sensitive, então a chave configurada deve casar exatamente com o action recebido da Meta. O único curto-circuito fora do engine é o ping (health check), tratado direto na rota.


Pipeline do webhook

Cada submissão do formulário chega como webhook cifrado no flow-ai-meta-api:

Meta → POST /public/whatsapp-flows/{metaFlowId}/webhook
  (body: { encrypted_aes_key, encrypted_flow_data, initial_vector })

1. Resolve wa:flow:{metaFlowId}     → endpointConfig, whatsAppFlowId, phoneNumberId
   └─ ausente → 404 (metaFlowId desconhecido)

2. Validação HMAC-SHA256
   ├─ Carrega wa:webhook:{phoneNumberId} → appSecret (ausente → 403)
   ├─ Header: x-hub-signature-256
   └─ Comparação timing-safe com o appSecret (inválido → 401)

3. Carrega wa:encryption:{phoneNumberId} → RSA private key + passphrase
   └─ ausente → 421 (chaves ainda não subiram)

4. Decifra a requisição (RSA-OAEP-SHA256 + AES-128-GCM)
   ├─ RSA-OAEP → decifra encrypted_aes_key → aes_key (16 bytes)
   └─ AES-128-GCM → decifra encrypted_flow_data → payload JSON

5. Ping health check (curto-circuito)
   └─ action === "ping" → { data: { status: "active" } }

6. Engine declarativo
   └─ FlowEndpointEngine.execute(request, endpointConfig)
      ├─ Resolve action key (screen.action ou action)
      ├─ Roda passos validate → erros retornam imediatamente
      ├─ Roda passos http → interpolação de placeholders + chamada HTTP
      └─ Monta resposta final

7. Cifra a resposta (AES-128-GCM)
   ├─ IV flipado bit a bit (requisito da Meta: ~byte & 0xff)
   └─ Resposta cifrada retornada como text/plain base64

8. Loga em stream:wa-flow-logs
   └─ WhatsAppFlowWebhookLogEvent → consumer persiste em whatsapp_flow_webhook_logs

A dupla criptografia (RSA outer + AES inner) é um protocolo imposto pela Meta para garantir que só o detentor da chave privada possa processar o payload — mesmo que a HTTPS seja comprometida.


Cache Redis

Chave: wa:flow:{metaFlowId}
Sem TTL (invalidado manualmente no CRUD)
typescript
type WhatsAppFlowCacheConfig = {
  metaFlowId:     string
  whatsAppFlowId: string   // ID interno no banco
  routerId:       string
  phoneNumberId:  string
  endpointConfig: FlowEndpointConfig
}

O cache é populado pelo flow-ai-core ao salvar draft ou ao publicar, e deletado quando o endpointConfig é removido ou o flow é deletado. O flow-ai-meta-api nunca toca o banco — lê tudo do Redis.

O warm-up no boot do flow-ai-core também popula os caches de todos os WhatsApp Flows que já têm metaFlowId (qualquer publishState), respeitando a mesma regra do syncWhatsAppFlowCache — escreve quando há endpointConfig, remove caso contrário:

typescript
await makeWhatsAppFlowService().warmupCache()   // wa:flow:* para todo flow com metaFlowId

Logs

Logs de publicação (whatsapp_flow_publish_logs)

Registra cada tentativa de draft ou publish:

typescript
{
  whatsAppFlowId:     string
  metaFlowId?:        string
  status:             "success" | "error"
  flowDefinitionJson: Json    // o que foi enviado à Meta
  validationErrors?:  Json    // erros de validação retornados
  errorMessage?:      string
  publishedBy?:       string  // userId
  createdAt:          DateTime
}

Logs de webhook (whatsapp_flow_webhook_logs)

Registra cada execução do engine declarativo:

typescript
{
  whatsAppFlowId: string
  action:         string    // ex: "data_exchange", "INIT"
  screen?:        string    // tela ativa no formulário
  requestData?:   Json      // dados do formulário (decifrados)
  responseData?:  Json      // resposta enviada de volta
  durationMs?:    number    // tempo total de execução
  success:        boolean
  errorMessage?:  string
  flowToken?:     string    // token da sessão do Flow — correlaciona eventos da mesma jornada
  createdAt:      DateTime
}

Os logs de webhook são escritos via stream (stream:wa-flow-logs) pelo flow-ai-meta-api e persistidos pelo consumer core-wa-flow-logs no flow-ai-core. Isso evita que a latência de escrita no banco aumente o tempo de resposta do webhook — a Meta tem timeout de 10s para respostas de webhook.

Nota: o mesmo stream:wa-flow-logs tem dois grupos de consumo no flow-ai-core: core-wa-flow-logs (persiste os logs, acima) e core-wa-flow-deferred (agenda jobs deferidos, abaixo). São independentes — cada grupo lê o stream por conta própria.


Jobs deferidos

Um job deferido executa uma Tool publicada fora do request do webhook, com retry/backoff — para casos em que o dado só existe minutos depois (ex.: a O.S. do IXC, criada de forma assíncrona pelo sistema externo).

A configuração vive em endpointConfig.deferredJobs[] (FlowEndpointDeferredJob):

typescript
type FlowEndpointDeferredJob = {
  id:              string   // estável dentro do flow (ex: "ixc-os")
  screen:          string   // request.screen que dispara o job
  requireKeys?:    string[] // chaves que precisam estar não-vazias no responseData
  dedupeKey?:      string   // campo do responseData usado para deduplicar
  publishedToolId: string
  inputMappings:   Record<string, string>   // templates {{data.x}} sobre o responseData
  retry?:          { maxAttempts?: number; baseDelaySec?: number }
}

Scheduler (core-wa-flow-deferred, whatsapp-flow-deferred.scheduler.ts) — lê stream:wa-flow-logs e, para cada evento com success === true eaction === "data_exchange", procura deferredJobs cujo screen casa e cujas requireKeys estão não-vazias no responseData. Cria um WhatsAppFlowDeferredJob com os inputs resolvidos. O jobKey ({whatsAppFlowId}:{job.id}:{dedupe|entryId}) é único — redelivery do stream ou submits repetidos não duplicam o job (violação P2002 é ignorada). O endpointConfig é lido do banco com cache em memória (TTL 30s).

Worker (whatsapp-flow-deferred.worker.ts) — polling a cada 5s; faz claim transacional (updateMany com guarda de status, seguro com múltiplas réplicas) e executa a tool in-process (executeTool, sessão pura, timeout de 35s):

ResultadoAção
retorno com erro: string (ou exceção)retryável — backoff linear baseDelaySec * attempts
esgotou maxAttempts (padrão 10)status = failed, grava lastError
sucessostatus = completed, grava result

O modelo WhatsAppFlowDeferredJob (status: pending/in_progress/completed/failed; attempts, maxAttempts, baseDelaySec, nextAttemptAt, lastError, result) é persistido em whatsapp_flow_deferred_jobs.


Preview / simulação

Duas rotas do flow-ai-core ajudam a validar sem passar pela Meta: GET /:id/payload-preview (retorna flowLaunchPayload + flowDefinitionJson sanitizado) e POST /:id/simulate (roda o engine declarativo sem cripto, com o payload em claro — mesma semântica do webhook do meta-api).


Arquivos relevantes

ArquivoPapel
packages/flow-ai-database/prisma/schema.prismaModelos WhatsAppFlow, WhatsAppFlowPublishLog, WhatsAppFlowWebhookLog, WhatsAppFlowDeferredJob
packages/flow-ai-types/src/whatsapp.tsFlowEndpointConfig, FlowEndpointAction, FlowEndpointStep, FlowEndpointCondition, FlowEndpointDeferredJob, tipos de webhook (reexportados via src/index.ts)
packages/flow-ai-whatsapp-flow-engine/src/engine.tsEngine declarativo — resolve actions, roda passos (validate/http/branch/set/map/tool), interpola
packages/flow-ai-whatsapp-flow-engine/src/conditions.tsmatchesCondition — avalia FlowEndpointCondition
packages/flow-ai-whatsapp-flow-engine/src/validations.tsrunValidations — regras required/cpf/min_age/regex
packages/flow-ai-whatsapp-flow-engine/src/crypto.tsRSA-OAEP-SHA256 + AES-128-GCM decrypt/encrypt
packages/flow-ai-redis/src/cache.tsgetWaFlow, setWaFlow, deleteWaFlow, getWaEncryption
services/flow-ai-core/src/services/whatsapp-flow-meta.service.tsDraft, publish, sync com a Meta Cloud API; buildEndpointUri (declarative vs external)
services/flow-ai-core/src/services/whatsapp-flow-cache.tssyncWhatsAppFlowCache()
services/flow-ai-core/src/http/routes/whatsapp-flow.routes.tsEndpoints REST + logs paginados + /simulate + /payload-preview
services/flow-ai-meta-api/src/http/routes/meta-flows.tsHandler do webhook — decrypt → engine → encrypt → log; executeToolViaCore
services/flow-ai-core/src/consumers/whatsapp-flow-log.consumer.tsPersiste logs de webhook do stream:wa-flow-logs (grupo core-wa-flow-logs)
services/flow-ai-core/src/consumers/whatsapp-flow-deferred.scheduler.tsAgenda WhatsAppFlowDeferredJob a partir do stream:wa-flow-logs (grupo core-wa-flow-deferred)
services/flow-ai-core/src/consumers/whatsapp-flow-deferred.worker.tsExecuta jobs deferidos com retry/backoff

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