O PIX foi gerado às 22h40 e ninguém pagou. Você tem um fluxo no n8n que devia mandar o lembrete 15 minutos depois. De manhã você abre o painel de execuções e vê aquilo: a execução ainda está pendurada esperando, o lembrete nunca saiu, e o cliente já dormiu, acordou e esqueceu que ia comprar.
Ou a versão inversa, que é pior. O envio deu erro, tinha um retry ligado em algum canto do fluxo, e o cara recebeu "seu PIX está expirando" quatro vezes seguidas às 3h da manhã. Ele não comprou. Ele bloqueou seu número.
Notificar é fácil: chegou o evento, dispara a mensagem, acabou. Recuperar é outra coisa, porque tem tempo no meio. Entre o evento e a mensagem existem 15 minutos, 4 horas, 20 horas. Nesse intervalo o mundo muda: o cara pode ter pago, pode ter pedido reembolso, sua instância de WhatsApp pode ter caído, a API pode ter recusado o envio. Um fluxo que não sabe lidar com esse intervalo não é automação, é roleta.
Aqui vai o desenho da orquestração: onde o agendamento mora, como fazer retry sem mandar mensagem repetida, e como você descobre que o fluxo parou antes de perder três dias de venda.
A resposta curta: no n8n a automação de recuperação vive na tabela, não no fluxo
A regra é simples e resolve uns 80% dos problemas: o n8n executa, o banco lembra. Quando o evento de venda travada chega, você não deixa a execução esperando dentro do n8n. Você grava as tarefas da régua já com a hora de cada toque numa tabela, e um segundo workflow, num Schedule Trigger de 1 minuto, pega o que venceu e envia.
Isso é a continuação direta do desenho de receptor burro mais processador que já expliquei aqui: o receptor só grava, o processador só lê e age. A diferença é que na recuperação a fila tem hora marcada, tem estado que precisa ser revalidado antes de mandar, e tem retry.
E os dois pontos que quase ninguém monta direito:
- O retry não pode morar no node. O
Retry On Faildo n8n trava em 5 tentativas com no máximo 5000 ms de espera entre elas. Isso dá 25 segundos de cobertura. A sua instância de WhatsApp fica fora 20 minutos. - O Error Trigger não avisa quando o fluxo para de rodar. Ele dispara quando uma execução automática dá erro. Se o workflow foi desativado ou o Schedule Trigger simplesmente não disparou, não existe execução, não existe erro, e não existe alerta. É um ponto cego estrutural.
Por que recuperação não é notificação (e isso muda o desenho)
Notificação é "aconteceu agora, avise agora". Recuperação comercial é "aconteceu agora, aja daqui a X, se e somente se o estado ainda for o mesmo". Essas duas condições extras (tempo futuro e revalidação) são o que quebra o fluxo ingênuo.
O fluxo ingênuo é: Webhook, node Wait de 4 horas, node de envio. Funciona no teste e sangra em produção por três motivos.
Primeiro, visibilidade. Com o Wait, se alguém te perguntar "quantos leads estão esperando o segundo toque agora?", você não tem resposta. Não dá pra fazer SELECT numa execução pendurada. Com tabela, é uma query.
Segundo, cancelamento. O cara pagou às 3h da espera. Você precisa matar os toques 2 e 3 daquele lead. Com Wait, você teria que caçar execução por execução no painel. Com tabela, é um UPDATE de uma linha.
Terceiro, o custo de manter execução aberta. A documentação do n8n diz que espera abaixo de 65 segundos não descarrega os dados de execução pro banco; acima disso, a execução é gravada e recarregada quando a condição de retomada bate. Ou seja, uma régua de 20 horas vira execução pendurada dependendo de sobreviver a restart, atualização de versão e edição do workflow. Dá pra fazer? Dá. É onde eu quero que more o dinheiro do meu cliente? Não.
O Wait continua ótimo pro que ele é bom: pausa curta entre dois envios do mesmo lote, respeitar um Retry-After, espaçar chamada de API dentro de um loop. Régua de horas, não.
A tabela de tarefas: a régua inteira gravada de uma vez
A tabela de eventos do post irmão guardava "o que chegou". Essa aqui guarda "o que eu prometi fazer, e quando":
create table recuperacao_tarefa (
tarefa_id text primary key,
contato text not null,
motivo text not null, -- pix_pendente | cartao_recusado | carrinho
toque int not null, -- 1, 2, 3
status text not null default 'agendada',
agendada_para timestamptz not null,
tentativas int not null default 0,
proxima_em timestamptz,
ultimo_erro text,
referencia jsonb not null, -- transacao, valor, link de checkout
criada_em timestamptz not null default now(),
atualizada_em timestamptz not null default now()
);
create index on recuperacao_tarefa (status, agendada_para);
Os estados possíveis de status: agendada, enviando, enviada, falha, cancelada, morta. Cada um faz o item aparecer ou sumir da query do executor, e é isso que dá controle.
Quando o evento chega (compra com status de espera de pagamento e payment_type PIX, cartão recusado, ou o webhook de abandono de carrinho; a lista completa está no post de eventos do webhook da Hotmart), o receptor grava a régua inteira num insert só:
insert into recuperacao_tarefa
(tarefa_id, contato, motivo, toque, agendada_para, referencia)
values
('pix:' || $1 || ':1', $2, 'pix_pendente', 1, now() + interval '15 minutes', $3),
('pix:' || $1 || ':2', $2, 'pix_pendente', 2, now() + interval '4 hours', $3),
('pix:' || $1 || ':3', $2, 'pix_pendente', 3, now() + interval '20 hours', $3)
on conflict (tarefa_id) do nothing;
Repare no tarefa_id: ele é derivado do id da transação mais o número do toque, não é um UUID aleatório. Com isso, se o mesmo webhook chegar duas vezes (e vai chegar), o on conflict do nothing come a duplicata sem você precisar de lógica nenhuma. É a mesma ideia da chave de idempotência em webhook, aplicada na fila em vez de na entrada.
E quando o pagamento entra, o cancelamento é uma linha:
update recuperacao_tarefa
set status = 'cancelada', atualizada_em = now()
where referencia->>'transacao' = $1
and status in ('agendada', 'falha');
Gravar a régua inteira na hora do evento, em vez de "agendar o próximo depois que o anterior sair", é o que torna esse cancelamento trivial. Vale a redundância.
O executor: reservar, revalidar, enviar
Schedule Trigger de 1 minuto, e a primeira coisa que ele faz é reservar um lote. A reserva usa for update skip locked, pelo mesmo motivo do post irmão (dois workers não podem pegar a mesma linha), mas agora com as condições de tempo:
update recuperacao_tarefa
set status = 'enviando',
tentativas = tentativas + 1,
atualizada_em = now()
where tarefa_id in (
select tarefa_id
from recuperacao_tarefa
where status in ('agendada', 'falha')
and agendada_para <= now()
and (proxima_em is null or proxima_em <= now())
order by agendada_para
limit 20
for update skip locked
)
returning *;
Duas colunas de tempo, dois papéis diferentes: agendada_para é a régua comercial (o toque 2 é daqui a 4 horas), proxima_em é o backoff técnico (o envio falhou, tenta de novo daqui a 8 minutos). Misturar as duas numa coluna só parece economia e vira bug: você não consegue distinguir "ainda não é hora" de "falhou e está esperando".
Depois de reservar, o passo que separa recuperação de disparo: revalidar o estado antes de mandar. Um node HTTP consultando o status da transação na plataforma, ou um SELECT na sua própria tabela de pedidos, e um IF. Se o status virou pago, cancelado ou reembolsado, a tarefa vira cancelada e a mensagem não sai.
Parece paranoia por 30 segundos de latência. Não é. É a diferença entre uma automação que ajuda e uma que manda "vi que seu PIX expirou" pra quem pagou há duas horas. Esse é o erro que mais custa reputação, porque o cliente conclui, com razão, que ninguém está olhando.
Retry com backoff: por que o do node não serve
O n8n tem retry por node: aba Settings, Retry On Fail, Max Tries, Wait Between Tries (ms). Só que os campos têm teto duro. Max Tries não passa de 5 e a espera não passa de 5000 ms; se você digitar mais, o campo volta pro limite (tem issue aberta no repositório do n8n exatamente sobre isso, e a explicação na comunidade é que a espera longa seguraria a worker thread).
Faça a conta: 5 tentativas vezes 5 segundos é uma janela de 25 segundos. Isso cobre um soluço de rede. Não cobre instância da Evolution API que caiu e voltou em 20 minutos, não cobre rate limit da Cloud API, não cobre restart de container. Pra recuperação de venda, retry de node é quase decoração.
O retry útil mora na fila. No node de envio, você põe On Error em "Continue (using error output)", e a saída de erro cai num Code node que calcula o backoff:
// Code node: calcula quando tentar de novo
const base = 60; // 60 s
const teto = 3600; // 1 h
const n = $json.tentativas; // já veio incrementado da reserva
let espera = Math.min(base * Math.pow(2, n - 1), teto);
// jitter de ±20% pra não voltar tudo no mesmo segundo
espera = Math.round(espera * (0.8 + Math.random() * 0.4));
return [{ json: { ...$json, espera_s: espera } }];
A sequência fica 60s, 2min, 4min, 8min, 16min, 32min, e depois trava no teto de 1 hora. E aí grava:
update recuperacao_tarefa
set status = 'falha',
proxima_em = now() + ($2 || ' seconds')::interval,
ultimo_erro = $3,
atualizada_em = now()
where tarefa_id = $1;
O jitter não é firula. Se a sua instância cai e 200 tarefas falham no mesmo minuto, sem jitter todas as 200 voltam exatamente no mesmo segundo e derrubam a instância de novo assim que ela levanta. Com ±20% de dispersão, elas chegam espalhadas.
Nem todo erro merece retry
Essa é a parte que quase todo tutorial pula. Tentar de novo um erro que nunca vai dar certo gasta rate limit à toa e, em alguns casos, piora sua reputação de envio. Conferi a tabela oficial de códigos de erro da WhatsApp Cloud API da Meta antes de escrever, e a classificação prática fica assim:
- Tenta de novo:
130429(throughput da Cloud API atingido),80007(limite de taxa da conta) e131056(muitas mensagens pro mesmo destinatário em pouco tempo). Os três são transitórios e a própria Meta orienta esperar e repetir. O131056, se aparece com frequência, também é recado sobre a sua régua estar apertada demais. - Não tenta:
131026(mensagem não entregável, tipicamente número que não está no WhatsApp ou usuário que não aceitou os termos),131047(passou a janela de 24 horas desde a última resposta do cliente, aí só template resolve) e131048(restrição por qualidade e spam). Esses três você marca comomortae segue a vida. Repetir131048é acelerar o problema, não resolver.
Se você usa Evolution API em vez da API oficial, o erro não vem com código bonitinho. Vem Connection Closed quando a instância desconectou, e existe um caso conhecido, com issue aberta no repositório, de a API devolver 400 mesmo tendo enviado a mensagem. Guarde essa: retry cego em cima de erro da Evolution manda mensagem repetida. É por isso que o retry só é seguro quando existe chave de idempotência do seu lado e o executor confere se aquela tarefa já foi marcada como enviada antes de repetir.
E teto de tentativas sempre. Depois de 6, o status vira morta e para de consumir ciclo. Fila que tenta pra sempre é fila que fica presa girando na mesma linha ruim enquanto as boas envelhecem atrás.
O terceiro fluxo: alerta quando a automação para
Aqui está o ponto que derruba mais gente. O Error Trigger do n8n serve pra erro de execução, e serve bem. O payload dele traz execution.id, execution.url, execution.error.message, lastNodeExecuted e o workflow.name, o que já dá um alerta decente no Telegram com link direto pra execução que quebrou. Quando o erro é na ativação do trigger, o formato muda: vem um objeto trigger.error com WorkflowActivationError em vez de execution. Se o seu tratamento assume só o primeiro formato, ele quebra justo na hora que você mais precisa dele.
Mas o buraco é outro: execução que nunca começou não gera erro. Se o workflow foi desativado sem querer, se o Schedule Trigger parou de disparar, se o n8n ficou fora do ar, não existe execução falhada pro Error Trigger pegar. Do lado de dentro do n8n, isso é invisível.
Piora com o comportamento de agendamento. A partir do n8n 2.36 o Schedule Trigger ganhou opções de recuperação de execuções perdidas, mas a própria documentação avisa que elas só valem em instância rodando o scheduler durável, e que o scheduler em memória padrão nunca roda execução perdida. Traduzindo: se o seu n8n ficou 3 horas fora, os disparos daquelas 3 horas não aconteceram e não vão acontecer.
Isso, aliás, é mais um argumento pra régua morar na tabela. Se o n8n cai por 3 horas, a tarefa continua lá com agendada_para vencido e sai atrasada quando ele volta. Se a régua morava num node Wait, boa sorte.
A saída é inverter a lógica: em vez de esperar o n8n reclamar, você exige que ele dê sinal de vida. No fim de cada rodada, o executor grava um batimento:
insert into fluxo_batimento (fluxo, visto_em)
values ('recuperacao_executor', now())
on conflict (fluxo) do update set visto_em = excluded.visto_em;
E um vigia checa o silêncio, mais duas coisas que dizem se a fila está saudável:
select
(select now() - visto_em
from fluxo_batimento
where fluxo = 'recuperacao_executor') as silencio,
count(*) filter (where status = 'agendada'
and agendada_para < now() - interval '15 minutes') as atrasadas,
count(*) filter (where status = 'falha'
and atualizada_em > now() - interval '30 minutes') as falhando,
count(*) filter (where status = 'morta'
and atualizada_em > now() - interval '24 hours') as mortas
from recuperacao_tarefa;
Três alertas, e só três, senão vira ruído: silêncio maior que 10 minutos (o executor parou), atrasadas acima de zero por mais de um ciclo (ele está rodando mas não vence o volume, ou está travando no meio), e falhando passando de uns 30% do volume da meia hora (aí não é tarefa ruim, é a instância ou o rate limit).
Falta a varredura de tarefa presa. Se o n8n morre entre reservar e enviar, a linha fica em enviando pra sempre e some das duas pontas. Um passo do vigia resolve:
update recuperacao_tarefa
set status = 'falha', proxima_em = now(), atualizada_em = now()
where status = 'enviando'
and atualizada_em < now() - interval '10 minutes';
Agora o trade-off honesto: se o vigia mora dentro do mesmo n8n, ele cai junto com o que devia vigiar. Isso é meio alerta. Vigia de verdade é um cron na VPS chamando a query e postando no Telegram, ou um monitor externo esperando um ping por HTTP num intervalo fixo. Se você prefere manter tudo numa caixa só, tudo bem, mas saiba que você está coberto contra "o executor travou" e descoberto contra "o n8n inteiro morreu". Eu deixo o vigia fora.
Vale separar dois vigias, aliás: um que olha o canal de envio (a instância que caiu) e outro que olha o orquestrador (a fila que parou de andar). Eles quebram por motivos diferentes, e um alerta genérico de "alguma coisa deu errado" não te diz qual dos dois foi, que é justamente a informação de que você precisa às 2h da manhã.
Fila com hora marcada, revalidação de estado, backoff por classe de erro e vigia externo é a espinha do que o Recupera faz por baixo do capô. Só que nada disso é segredo nem depende de mim: é n8n, Postgres e umas seis queries.
O que dá errado
- Retry cego em cima de erro que não é transitório. Seis tentativas num
131047(janela de 24h fechada) é gastar seis vezes o mesmo erro. Classifique o código antes de reagendar, sempre. - Retry sem idempotência. O caso do 400 da Evolution que envia a mensagem assim mesmo transforma seu retry em duplicador. Antes de repetir, confira se a tarefa já não está
enviada. - Backoff sem jitter. Duzentas tarefas voltando no mesmo segundo derrubam de novo o serviço que acabou de levantar. Vinte por cento de dispersão custa uma linha.
- Régua espremida na mesma janela. Três toques em uma hora no mesmo número é o caminho curto pro
131056e depois pro131048. Espaçamento não é só educação com o cliente, é preservação do canal. - Não revalidar o estado. Mandar cobrança pra quem já pagou é o pior resultado possível dessa automação, porque quebra a confiança de um cliente que você já tinha ganhado.
- Alerta demais. Se o bot manda 40 mensagens por dia, em duas semanas você silencia o grupo e o alerta que importava passa batido. Guarde um
alerta_enviado_eme não repita o mesmo alerta antes de 30 minutos. - Nunca testar o caminho de falha. Esse é o mais comum. Derrube o canal de propósito:
docker stop evolution_api, espere 5 minutos, mande umas tarefas vencerem, e depoisdocker start evolution_api. Se as tarefas voltarem sozinhas e ninguém receber mensagem repetida, o desenho está de pé. Se você nunca fez esse teste, você não tem retry, você tem esperança.
Fechando
Orquestrar recuperação no n8n é menos sobre montar nodes bonitos e mais sobre decidir onde mora a memória do processo. Quando a régua vive numa tabela com hora marcada e estado explícito, você ganha cancelamento em massa, retry com backoff de verdade, e uma query que responde "o que está preso agora". Quando ela vive dentro de uma execução pendurada, você ganha um painel bonito e nenhuma resposta.
E o alerta fecha o ciclo. Recuperação é a automação que trabalha justamente quando você não está olhando, de madrugada e no fim de semana. Se ela não sabe gritar quando para, você vai descobrir do mesmo jeito de sempre: olhando o extrato e sentindo que faltou alguma coisa.
Fontes primárias consultadas na escrita: documentação do n8n (Error Trigger, Schedule Trigger e node Wait), tabela oficial de códigos de erro da WhatsApp Cloud API da Meta, e issues públicas do repositório da Evolution API sobre Connection Closed e resposta 400 com mensagem enviada. Comportamento de plataforma muda, vale reconferir antes de codar em cima.