Voltar pro blog json-p.org

PilarInfra de WhatsApp que não cai Publicado23 de setembro de 2026 AutorElton

Máquina de estados de venda no WhatsApp: como modelar

Como modelar a máquina de estados de vendas no WhatsApp: estado por conversa, timeout de silêncio, reentrada e concorrência sem resposta duplicada.

Você monta o bot de venda no WhatsApp, testa com o seu número, funciona lindo. Aí sobe tráfego. No segundo dia chega o print: o cliente que já pagou mandou "obrigado, chegou aqui" e recebeu de volta a oferta com "aproveita que hoje tem desconto". Outro mandou "oi" e "quero o combo" com dois segundos de diferença e ganhou duas boas-vindas seguidas. Um terceiro sumiu no meio do checkout e nunca mais ouviu falar de você, porque o fluxo simplesmente parou de andar.

Nenhum desses casos é bug de API. A mensagem chegou, o webhook disparou, o envio saiu. O problema é que o bot responde à mensagem, não à conversa. Ele olha o texto que acabou de entrar, casa com uma palavra-chave e reage, sem saber em que ponto da venda aquela pessoa está.

Quem já rodou isso com volume conhece o custo: lead quente que esfria por resposta fora de contexto, cliente pago irritado com pitch repetido e, o pior, mensagem duplicada, que é exatamente a cara de robô que você queria esconder. Dá pra resolver na raiz, e a raiz tem um nome chato de faculdade: máquina de estados.

A resposta curta: o que é uma máquina de estados de vendas no WhatsApp

É guardar, pra cada conversa, um único campo dizendo em que etapa da venda ela está (novo, engajado, negociando, checkout enviado, pago, recuperando, perdido) e só deixar eventos específicos mudarem esse campo. Toda mensagem que chega, todo webhook de pagamento e todo "o lead ficou em silêncio" viram eventos. O bot para de perguntar "o que essa mensagem diz?" e passa a perguntar "estando nesse estado, o que esse evento significa e pra onde ele leva?". Isso te dá três coisas de uma vez: resposta no contexto certo, um ponto único pra travar concorrência e um lugar pra pendurar timeout.

Por que o estado mora na conversa, não na mensagem

O jeito "natural" de montar bot é uma pilha de regras: se a mensagem tem "preço", manda a oferta; se tem "pix", manda a chave; se é a primeira vez, manda boas-vindas. Cada mensagem é julgada sozinha. Funciona na demo porque na demo você segue o roteiro.

Na vida real a mesma frase muda de sentido conforme o ponto da venda. "Quanto fica?" de quem acabou de chegar é pedido de oferta. "Quanto fica?" de quem está com o link do checkout aberto é dúvida de parcelamento. "Quanto fica?" de quem já pagou provavelmente é sobre o upsell que você citou na entrega. Regra por mensagem não tem como separar isso. Estado tem.

E tem um problema mais feio: loop. Se do outro lado existe outro automatizador (lojista com resposta automática ligada, por exemplo), a sua boas-vindas dispara a resposta dele, que chega pra você como mensagem nova, que dispara a sua boas-vindas de novo. Com estado, a segunda mensagem cai numa conversa que já está em ENGAJADO, onde "mensagem recebida" não dispara boas-vindas. O loop morre na segunda volta.

Os estados, e por que poucos

A regra que eu uso pra decidir se algo merece virar estado: um estado é o que você está esperando que aconteça. Se dois estados esperam exatamente as mesmas coisas, eles são um só. Isso segura a tentação de criar 30 estados que ninguém consegue manter.

Desenhado em texto, o caminho principal fica assim:

NOVO
  | msg
  v
ENGAJADO
  | pediu_preco
  v
NEGOCIANDO
  | quer_comprar
  v
CHECKOUT_ENVIADO --pix_gerado--> AGUARDANDO_PIX
  |                                  |
  | recusado / abandonou / timeout   | timeout
  v                                  |
RECUPERANDO <------------------------+
  |  \
  |   +--msg--> NEGOCIANDO
  | esgotou
  v
PERDIDO --msg--> ENGAJADO

de qualquer estado:  pagamento_aprovado --> PAGO
                     pediu_parar        --> OPT_OUT

Eventos: quem tem permissão de mover o lead

Só três fontes mexem no estado. Nada mais.

  1. Mensagem do lead. Mensagem crua não é evento: você classifica antes (msg_recebida, pediu_preco, quer_comprar, pediu_parar). Pode ser regra simples ou modelo de linguagem, tanto faz. O classificador propõe um evento; quem decide se aquilo muda alguma coisa é a tabela.
  2. Webhook da plataforma de pagamento. Normaliza na borda. Na Hotmart, aprovação chega como PURCHASE_APPROVED e abandono como PURCHASE_OUT_OF_SHOPPING_CART; outras plataformas usam outros nomes. Um tradutor pequeno converte tudo pro seu vocabulário (pagamento_aprovado, checkout_abandonado, pagamento_recusado, pix_gerado) e a máquina nunca sabe de qual plataforma veio.
  3. O relógio. O silêncio também é evento (timeout). Sem ele, o lead que parou de responder fica pra sempre em NEGOCIANDO.

Com isso a tabela de transições cabe numa tela:

// estado atual -> evento -> { próximo estado, ação }
const TRANSICOES = {
  NOVO: {
    msg_recebida:        { vai: 'ENGAJADO',         faz: 'boas_vindas' },
  },
  ENGAJADO: {
    msg_recebida:        { vai: 'ENGAJADO',         faz: 'responder_duvida' },
    pediu_preco:         { vai: 'NEGOCIANDO',       faz: 'mandar_oferta' },
    timeout:             { vai: 'RECUPERANDO',      faz: 'retomar_conversa' },
  },
  NEGOCIANDO: {
    msg_recebida:        { vai: 'NEGOCIANDO',       faz: 'tratar_objecao' },
    quer_comprar:        { vai: 'CHECKOUT_ENVIADO', faz: 'mandar_link' },
    timeout:             { vai: 'RECUPERANDO',      faz: 'retomar_oferta' },
  },
  CHECKOUT_ENVIADO: {
    msg_recebida:        { vai: 'CHECKOUT_ENVIADO', faz: 'ajudar_no_checkout' },
    pix_gerado:          { vai: 'AGUARDANDO_PIX',   faz: null },
    pagamento_recusado:  { vai: 'RECUPERANDO',      faz: 'ajuda_cartao' },
    checkout_abandonado: { vai: 'RECUPERANDO',      faz: 'retomar_checkout' },
    timeout:             { vai: 'RECUPERANDO',      faz: 'retomar_checkout' },
  },
  AGUARDANDO_PIX: {
    msg_recebida:        { vai: 'AGUARDANDO_PIX',   faz: 'reenviar_pix' },
    timeout:             { vai: 'RECUPERANDO',      faz: 'lembrete_pix' },
  },
  RECUPERANDO: {
    msg_recebida:        { vai: 'NEGOCIANDO',       faz: 'retomar_de_onde_parou' },
    timeout:             { vai: 'RECUPERANDO',      faz: 'proximo_toque' },
    regua_esgotada:      { vai: 'PERDIDO',          faz: null },
  },
  PERDIDO: {
    msg_recebida:        { vai: 'ENGAJADO',         faz: 'boas_vindas_de_volta' },
  },
  PAGO: {
    msg_recebida:        { vai: 'PAGO',             faz: 'pos_venda' },
  },
  OPT_OUT: {},
};

// valem de qualquer estado e têm prioridade sobre a tabela
const GLOBAIS = {
  pagamento_aprovado:    { vai: 'PAGO',    faz: 'entregar_acesso' },
  pediu_parar:           { vai: 'OPT_OUT', faz: 'confirmar_saida' },
};

Dois detalhes fazem mais trabalho do que parece. Primeiro, pagamento_aprovado é global: o lead pode pagar pelo link de três dias atrás estando em PERDIDO. Pagamento ganha de tudo.

Segundo, PAGO só aceita msg_recebida. Se um checkout_abandonado atrasado chega depois do aprovado (e chega, webhook não tem ordem garantida), ele não encontra transição, vira registro de "sem transição" e morre ali. Nenhum "esqueceu alguma coisa?" pra quem já pagou, sem uma linha de lógica de timestamp.

Processar um evento sem pisar no próprio pé

A função que aplica a tabela é curta, mas a ordem das coisas dentro dela é o que separa bot confiável de bot que manda mensagem duas vezes:

async function processar(evento) {
  // evento = { id, contato, tipo, ts, dados }
  await comLock(evento.contato, async () => {
    if (await jaProcessado(evento.id)) return;         // retry do webhook, ignora

    const c = await carregarOuCriar(evento.contato);    // nasce em NOVO
    const t = GLOBAIS[evento.tipo] ?? TRANSICOES[c.estado]?.[evento.tipo];
    if (!t) return registrar(c, evento, 'sem_transicao');

    // humano na conversa segura o bot, mas nunca a entrega nem o opt-out
    const pausado = c.bot_pausado_ate > agora() && !GLOBAIS[evento.tipo];

    await transacao(async (db) => {
      await db.atualizarConversa(c.contato, {
        estado:   t.vai,
        prazo_em: PRAZO[t.vai] ? agora() + PRAZO[t.vai] + jitter() : null,
      });
      await db.logTransicao(c.contato, c.estado, evento.tipo, t.vai, evento.id);
      if (t.faz && !pausado) await db.outbox(c.contato, t.faz, evento.id);  // envio sai depois
      await db.marcarProcessado(evento.id);
    });
  });
}

O ponto crítico: salvar o estado e a ação pendente na mesma transação, e só enviar depois, por outro worker que lê a outbox. Se você envia primeiro e salva depois, basta o processo cair no meio: a mensagem saiu, o estado não mudou, o webhook volta (a Cloud API da Meta retenta entregas sem 200 por até 7 dias, e a documentação avisa que isso gera notificação duplicada) e o lead recebe tudo de novo. Com a outbox, envio que falha é retentado só no envio, e processamento repetido é barrado pelo jaProcessado. Nenhum caminho manda em dobro.

O logTransicao não é enfeite. É uma tabela só de inserção (de, evento, para, quando), e é ela que você abre quando o cliente reclama "o robô me mandou uma coisa nada a ver".

Concorrência: duas mensagens, um estado

O caso do "oi" seguido de "quero o combo" em 1,5 segundo. São dois webhooks separados. Se o seu worker (ou o seu n8n) processa em paralelo, as duas execuções leem NOVO ao mesmo tempo, as duas decidem mandar boas-vindas, e lá vão duas. Três jeitos de resolver, do mais simples pro mais robusto:

  1. Lock por conversa. No Redis, SET lock:5541999999999 <token> NX PX 15000. Quem pega o lock processa; quem não pega espera e tenta de novo (descartar é perder a mensagem). Libere conferindo o token, senão uma execução lenta solta o lock de outra.
  2. Versão otimista no banco. Sem lock externo, você confia no próprio update:
    UPDATE conversa
       SET estado = 'ENGAJADO', versao = versao + 1, prazo_em = now() + interval '2 hours'
     WHERE contato = '5541999999999' AND versao = 7;
    -- 0 linhas afetadas: alguém mudou antes de você. Recarrega e reavalia.
  3. Fila particionada por contato. Todo evento do mesmo telefone passa pela mesma fila, um de cada vez; contatos diferentes seguem em paralelo. É o que escala melhor quando o volume passa de algumas centenas de conversas simultâneas.

Por cima de qualquer um dos três vale um debounce. Gente digita em rajada: "oi", "tudo bem?", "vi o anúncio", "quanto é?". Esperar uns 3 ou 4 segundos depois da última mensagem e processar tudo como um evento só deixa a resposta muito mais humana, ao custo de um atraso que quase ninguém percebe.

Sobre ordem: a documentação de webhooks da Meta não promete ordem de entrega, e provedores que revendem a API avisam que a ordem de chegada pode não refletir a ordem real. Pro que a tabela não resolve sozinha, compare pelo timestamp do evento de origem, nunca pela hora em que o webhook chegou no seu servidor.

Timeout: o silêncio também é evento

O erro mais comum de quem monta isso em ferramenta visual: pendurar um "espera 2 dias" no meio do fluxo. Timer em memória morre no restart, e fluxo parado esperando é difícil de cancelar quando o lead responde antes, então sai o "e aí, pensou?" pra quem respondeu há uma hora.

A saída é o campo prazo_em na própria linha da conversa. Toda transição regrava o prazo conforme o estado novo, então uma mensagem do lead cancela o timeout anterior sem você fazer nada. Um varredor roda a cada minuto:

SELECT contato, prazo_em
  FROM conversa
 WHERE prazo_em <= now()
 ORDER BY prazo_em
 LIMIT 50;

O varredor não manda mensagem nenhuma: só emite um evento timeout pra mesma função processar, com id derivado do prazo (timeout:5541999999999:<prazo_em>). Se ele rodar duas vezes em cima da mesma linha, o id é igual e o jaProcessado barra. PAGO, PERDIDO e OPT_OUT ficam com prazo_em nulo e nunca aparecem na consulta.

Prazos que eu usaria pra começar, e ajustaria olhando os seus próprios dados (não é benchmark de mercado):

Uma restrição de plataforma muda esse desenho. Na Cloud API oficial, mensagem livre só dentro de 24 horas contadas da última mensagem do lead; fora disso, só template aprovado. Se a régua passa de 24h do último contato dele, os toques seguintes precisam ser template, e a ação tem que saber disso. Na Evolution API/Baileys essa regra não existe, mas o risco muda de lugar: vira risco de denúncia e de ban. Se essa escolha ainda está aberta pra você, a comparação Evolution API vs Cloud API trata exatamente desse trade-off.

E o jitter() não está lá à toa: sem ele, os 300 leads que entraram pela mesma campanha às 20h vencem o prazo no mesmo minuto e o varredor manda 300 mensagens de uma vez. Rajada sincronizada é o tipo de padrão que ajuda o WhatsApp a detectar disparo. Espalhe alguns minutos aleatórios em cada prazo.

Reentrada: o lead que volta depois de 9 dias

O lead abandonou o checkout, a régua esgotou, ele foi pra PERDIDO. Nove dias depois ele manda "ainda tem aquela condição?". Duas coisas erradas que eu vejo muito: tratar ele como NOVO (boas-vindas genérica pra quem já conversou 20 minutos com você grita "robô") ou ignorar porque "perdido é perdido".

Na tabela acima, PERDIDO com msg_recebida vai pra ENGAJADO com a ação boas_vindas_de_volta. E aqui o log de transições paga o investimento: a ação consulta de onde ele saiu (checkout abandonado, cartão recusado, silêncio na oferta) e responde a partir dali. "Oi de novo! Da última vez o cartão deu problema, quer que eu mande o link com PIX?" converte muito mais do que começar do zero.

Cliente que já comprou é outro caso: PAGO mais mensagem é pós-venda e fica em PAGO. Se você vende mais de um produto, separe contato de ciclo de venda: o contato é permanente, o estado mora no ciclo (contato mais oferta), e interesse em oferta nova abre ciclo novo sem apagar o anterior.

É esse tipo de coisa que uma ferramenta feita especificamente pra funil de WhatsApp resolve pronto (o Whatspix, que eu mantenho, é uma delas). Mas nada aqui depende de ferramenta: dá pra montar com um Postgres, um worker e o webhook da sua API.

O que dá errado

E quando não vale a pena: se você fala com 10 leads por dia, atenção humana e etiqueta no WhatsApp Business resolvem. Se o canal é grupo de ofertas (um pra muitos), não existe conversa pra ter estado. E se todo o atendimento é humano num CRM, o estado já mora no funil do CRM; duplicar num bot só cria duas verdades brigando.

Fechando

Bot que responde mensagem é fácil de fazer e difícil de confiar. Bot que responde conversa exige um campo a mais, uma tabela de transições e três cuidados (lock, outbox, prazo na linha), e em troca para de mandar oferta pra quem já pagou, de responder em dobro e de esquecer quem sumiu no checkout. É só decidir, antes do primeiro fluxo, que o lead está sempre em algum lugar da venda e que o bot precisa saber qual.

Fontes primárias consultadas em 23/09/2026: webhooks da WhatsApp Cloud API (retentativa por até 7 dias e notificações duplicadas), regras da janela de atendimento de 24 horas da WhatsApp Business Platform, webhook de compra da Hotmart e documentação de status de mensagem do AWS End User Messaging Social (ordem de chegada dos eventos).