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.
- NOVO: o contato existe, mas ainda não houve troca.
- ENGAJADO: está conversando ou tirando dúvida. Esperando: interesse em comprar.
- NEGOCIANDO: já viu a oferta. Esperando: sim, objeção ou silêncio.
- CHECKOUT_ENVIADO: o link está na mão dele. Agora você espera evento da plataforma de pagamento, não mensagem.
- AGUARDANDO_PIX: gerou o PIX e não pagou. Esperando: aprovação ou expiração.
- RECUPERANDO: saiu do caminho feliz (recusa, abandono, silêncio) e está numa régua com número máximo de toques.
- PAGO: venda fechada. Daqui só sai pós-venda, nunca pitch.
- PERDIDO: a régua esgotou. Não é lixo, é "dormindo": se ele voltar, a conversa acorda.
- OPT_OUT: pediu pra parar. O bot nunca sai daqui por iniciativa própria.
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.
- 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. - Webhook da plataforma de pagamento. Normaliza na borda. Na Hotmart, aprovação chega como
PURCHASE_APPROVEDe abandono comoPURCHASE_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. - 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:
- 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. - 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. - 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):
- ENGAJADO: 2 horas sem resposta.
- NEGOCIANDO: 1 hora.
- CHECKOUT_ENVIADO: 30 a 45 minutos, porque quem abriu o checkout e parou geralmente travou em alguma coisa.
- AGUARDANDO_PIX: um pouco antes da validade do PIX que a sua plataforma define, pra dar tempo de pagar o mesmo código.
- RECUPERANDO: dois ou três toques no máximo, contando em
toques_regua; no último, a ação emiteregua_esgotada.
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
- Estado demais. "INTERESSADO_MORNO", "VIU_VIDEO_2". Se não muda o que você espera, é atributo, não estado.
- Bot atropelando humano. Você entra na conversa pelo celular pra fechar uma venda grande e o bot manda o lembrete no meio. Mensagem que sai do seu próprio número chega no webhook marcada como sua (o
fromMedo payload no Baileys/Evolution). Use isso pra preencherbot_pausado_atecom algumas horas pra frente. Repara que noprocessara pausa segura só o envio do bot: o estado continua andando, e pagamento aprovado durante o atendimento humano ainda move pra PAGO e entrega o acesso. - Webhook de pagamento que nunca chega. O lead pagou, o evento se perdeu, o timeout vence e a máquina manda "vi que você não finalizou" pra quem pagou. Antes de qualquer ação de recuperação, reconsulte o status da compra na API da plataforma. E monitore a entrega em si: webhook que não dispara em silêncio é causa clássica de estado travado.
- Deixar o modelo de linguagem escrever o estado. IA pra entender a mensagem, ótimo. IA decidindo "esse lead está em PAGO", problema. A IA propõe o evento, a tabela decide, e só evento vindo da plataforma de pagamento move pra PAGO.
- Esquecer de deduplicar. Com retry de até 7 dias do lado da Meta, o mesmo evento chega mais de uma vez. Sem
jaProcessado, um retry em RECUPERANDO vira toque a mais da régua. - OPT_OUT que não é respeitado em todo lugar. Se outro sistema seu (disparo de lista, grupo) não consulta o mesmo estado, o lead pede pra parar aqui e recebe mensagem de lá. Denúncia de usuário que pediu pra sair é o jeito mais rápido de perder o número.
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).