08h47. O cara que gerou PIX ontem recebe no zap: "Oi, seu PIX de R$ 197 ainda tá esperando, quer o código de novo?". 08h47 e 4 segundos, a mesma mensagem. 08h47 e 12 segundos, de novo. Três balões idênticos, um embaixo do outro.
Ele não paga. Ele responde "para com isso" ou nem responde: silencia, bloqueia e às vezes clica em denunciar. A leitura do lado dele é óbvia e é justa. Isso aí é robô, e robô quebrado. Se a operação não controla nem quantas mensagens dispara, por que ele confiaria R$ 197 nela?
Aí você abre o log procurando o bug e não acha bug nenhum. A função rodou três vezes porque foi chamada três vezes. Isso não é falha da Hotmart, nem da Meta, nem da Evolution: é o contrato de webhook funcionando exatamente como foi desenhado. Quem tem que resolver é o seu lado.
A resposta curta: idempotência em webhook é gravar antes de agir
Idempotência em webhook quer dizer que processar o mesmo evento dez vezes produz o mesmo resultado que processar uma vez. Na prática é uma linha de defesa só: antes de fazer qualquer coisa que o cliente veja, você tenta gravar o identificador daquele evento numa tabela com chave única. Se a gravação passou, é a primeira vez que você vê aquilo, pode disparar. Se bateu em conflito de chave, é repetido: você responde 200 e não faz nada.
O conceito é simples e quase todo mundo entende. O que quase todo mundo erra é a ordem. Gravar antes, agir depois. Quem processa primeiro e checa duplicata depois deixa aberta uma janela de milissegundos que, num dia de pico, é exatamente onde os dois webhooks simultâneos entram.
Webhook não é entrega exatamente uma vez, é pelo menos uma vez
A raiz do problema é uma expectativa errada. Você acha que o webhook é um telefonema: toca uma vez, você atende, acabou. Ele é mais parecido com alguém insistindo até ter certeza de que você ouviu. As plataformas escolheram, de propósito, o modelo "pelo menos uma vez" (at least once), porque perder um evento de venda é muito pior do que entregar dois.
Os números reais das três plataformas que mais aparecem numa operação brasileira de infoproduto, conferidos na documentação delas na data em que escrevi isto:
- Hotmart: posts com erro são reenviados automaticamente até cinco vezes, ou até o servidor devolver uma resposta positiva. Além disso, os eventos ficam no histórico por até 60 dias e você (ou seu suporte) pode clicar pra reenviar um post manualmente. Ou seja: o mesmo evento pode voltar semanas depois, por ação humana. E se a URL cadastrada ficar dando erro, a Hotmart desativa a configuração automaticamente, o que troca o problema de "mensagem duplicada" por "nenhuma mensagem".
- Meta / WhatsApp Cloud API: se a entrega falha, a Meta tenta de novo imediatamente e depois mais algumas vezes com frequência decrescente ao longo das 36 horas seguintes. Passou disso, a notificação é descartada. A própria documentação diz, com todas as letras, que o seu servidor deve tratar deduplicação nesses casos. Ela também avisa que as notificações podem vir agrupadas em lote (até 1000 updates numa requisição) e que o agrupamento não é garantido, então cada item precisa ser tratado individualmente.
- Evolution API: o retry é configurável por variável de ambiente e os padrões documentados são generosos:
WEBHOOK_RETRY_MAX_ATTEMPTS=10,WEBHOOK_RETRY_INITIAL_DELAY_SECONDS=5, backoff exponencial ligado, teto deWEBHOOK_RETRY_MAX_DELAY_SECONDS=300e timeout de 60000 ms. Os códigos 400, 401, 403, 404 e 422 não geram retentativa. A doc dela é direta: se você devolver status de erro, a API reenvia o webhook e isso pode gerar duplicados.
Repare no gatilho comum: o reenvio quase sempre nasce de você. Seu endpoint estourou o timeout, devolveu 500 porque o banco engasgou, ou o proxy cortou a conexão. Do lado da plataforma, "não recebi confirmação" e "ele processou mas não conseguiu responder" são indistinguíveis, então ela manda de novo. Por isso endpoint lento vira endpoint duplicador. Vale a mesma disciplina de por que seu webhook do WhatsApp não dispara: antes de culpar a lógica, prove o que chega e em quanto tempo você responde.
Escolher a chave: ID do evento ou chave de negócio
Deduplicar é comparar alguma coisa. A qualidade da sua idempotência é a qualidade da chave que você escolheu. Tem dois casos.
Caso 1: o evento tem ID estável
É o caso feliz e o mais comum. A plataforma manda um identificador único do evento e repete o mesmo identificador no reenvio.
Na Hotmart, o payload versão 2.0.0 vem com um campo id no envelope, fora do data. É ele que identifica o evento (não confunda com data.purchase.transaction, que identifica a compra e é o mesmo em vários eventos diferentes ao longo da vida daquela venda). Se você ainda não mapeou esses campos, o desenho completo do envelope está em webhook Hotmart: todos os eventos.
No WhatsApp Cloud API, o identificador é o wamid, que aparece em messages[].id pra mensagem recebida e em statuses[].id pra atualização de status. Aqui mora uma armadilha boa: uma mesma mensagem gera vários eventos de status (enviado, entregue, lido) e todos carregam o mesmo id. Se você deduplicar só por id, vai jogar fora os status legítimos. A chave certa nesse caso é a dupla id + status.
Na Evolution API, o payload traz o nome da instância, o nome do evento e a chave da mensagem. Uma chave composta sensata é instancia + evento + data.key.id.
Caso 2: não tem ID estável (ou você não confia nele)
Aí você constrói uma chave de negócio: uma string determinística montada com os campos que identificam o fato, nunca a entrega. Por exemplo, pro lembrete de PIX pendente:
chave = "lembrete_pix:HP0000000000000000:etapa_1"
Essa chave diz "a etapa 1 do lembrete de PIX da transação HP0000000000000000". Ela não depende de a plataforma mandar id nenhum. E ela protege contra um cenário que a dedup por id do evento não cobre: dois eventos diferentes (com ids diferentes, legítimos) que na sua régua acabariam gerando a mesma mensagem pro mesmo cliente.
Antes de fixar qualquer chave, faça o teste que vale mais do que qualquer post: force um reenvio e compare os dois payloads campo a campo. Devolva 500 de propósito num evento de teste, ou use o botão de reenviar do painel, e faça um diff. O que muda entre a primeira entrega e a segunda está proibido de entrar na chave. O que se mantém igual é a sua chave. Não confie no meu palpite nem no da doc: meça no seu ambiente, porque payload de plataforma muda sem aviso.
O INSERT que resolve (e por que ele tem que vir antes)
A tabela é a coisa mais chata do mundo, e é isso mesmo que você quer:
CREATE TABLE evento_visto (
chave text PRIMARY KEY,
origem text NOT NULL,
visto_em timestamptz NOT NULL DEFAULT now()
);
A operação inteira de deduplicação é uma consulta só, atômica:
INSERT INTO evento_visto (chave, origem)
VALUES ($1, $2)
ON CONFLICT (chave) DO NOTHING
RETURNING chave;
Se voltou uma linha, você é o primeiro a ver esse evento: seguir. Se voltou zero linha, alguém já registrou: parar. Sem SELECT antes, sem if antes, sem checagem em duas etapas.
Por que isso importa? Porque SELECT seguido de INSERT tem uma janela entre as duas consultas. Se dois POSTs do mesmo evento chegam com 40 ms de diferença e caem em duas réplicas do seu app, as duas fazem o SELECT, as duas veem "não existe" e as duas seguem em frente. O único árbitro confiável de "quem chegou primeiro" é o índice único do banco. Delega a corrida pra ele e aceita o veredito.
O handler completo, em pseudo-código agnóstico de stack:
POST /webhook/hotmart:
1. valida o token do header
se não bater: responde 200 e descarta (não processa nada)
2. chave = "hotmart:" + payload.id
3. linhas = INSERT INTO evento_visto ... ON CONFLICT DO NOTHING RETURNING chave
4. se linhas == 0:
log("duplicado", chave)
responde 200 # de propósito: nada acontece
fim
5. enfileira(payload) # trabalho pesado sai do request
6. responde 200
Note o passo 5. O disparo da mensagem, a chamada da API do WhatsApp, a consulta ao CRM, tudo isso sai do ciclo do request e vai pra uma fila. O request do webhook faz duas coisas rápidas (valida e registra) e devolve 200. Isso mata na raiz o retry por timeout, que é a origem mais comum de duplicata.
Se você prefere Redis pra camada quente, a versão equivalente é uma linha:
SET dedup:hotmart:e8f9a0b1 1 NX EX 259200
O NX faz o trabalho do ON CONFLICT: grava só se a chave não existir e devolve nulo se já existia. O EX cuida da expiração (aqui, 3 dias). O trade-off honesto: Redis sem persistência configurada perde tudo num restart, e se ele é a sua única barreira, um reinício no meio do lançamento reabre a janela inteira.
Janela de deduplicação: quanto tempo guardar
Guardar id de evento pra sempre é possível e quase sempre inútil. Cada linha é minúscula, então o custo não é disco, é o índice crescendo pra milhões de linhas que ninguém vai consultar. A regra prática é dimensionar pela janela de retry do provedor, com folga:
- Meta / Cloud API: as tentativas acontecem dentro de 36 horas. TTL de 72 horas cobre com sobra.
- Evolution API: com os padrões (10 tentativas, delay inicial de 5s, teto de 300s por intervalo), tudo termina em bem menos de uma hora. A própria doc sugere 24 horas de TTL, o que é generoso.
- Hotmart: as 5 tentativas automáticas são rápidas, mas o reenvio manual pelo painel alcança até 60 dias de histórico. Guardar 60 dias de id só por causa disso é exagero; o que resolve é a segunda camada.
A segunda camada é a trava de negócio, e ela não mora na tabela de dedup. Mora no seu domínio: um campo lembrete_pix_1_enviado_em no registro daquela transação. Essa você guarda enquanto a venda for relevante, porque ela responde a pergunta comercial ("eu já falei isso pra essa pessoa?"), não a técnica ("esse POST é repetido?"). Duas camadas, dois TTLs. Quem resolve as duas com a mesma tabela acaba guardando lixo pra sempre ou limpando cedo demais.
A limpeza da camada técnica é um cron chato de uma linha:
DELETE FROM evento_visto WHERE visto_em < now() - interval '7 days';
O que dá errado
Os furos que eu mais vejo (e vários eu já cometi):
- Checar depois de processar. Manda a mensagem, depois grava o id "pra não mandar de novo". Funciona no teste com um evento. Quebra no dia de pico, quando dois webhooks chegam quase juntos e os dois passam pela checagem antes de qualquer um gravar. Gravar é o primeiro passo, não o último.
- Usar
SELECTantes doINSERT. Versão elegante do erro anterior. A janela entre as duas consultas é pequena, e é exatamente por ser pequena que você não reproduz o bug no teste, só em produção. - Botar timestamp na chave. Se a chave inclui a hora da entrega, ou o
creation_dateque muda a cada tentativa, cada reenvio gera uma chave nova e você nunca deduplica nada. O código parece existir, o efeito é zero. É o pior tipo de bug: aquele que dá a sensação de proteção. - Usar hash do corpo inteiro como chave. Falha dos dois jeitos. Se o provedor muda um campo no reenvio (contador de tentativa, timestamp), o hash muda e você não deduplica. Se ele agrupa vários eventos num POST só (o lote da Meta), o hash cobre o lote e você descarta eventos legítimos junto.
- Guardar em memória. Um
Setno processo, um cache local. Some no próximo deploy e não existe pro seu segundo container: com duas réplicas atrás de um load balancer, a memória de uma não sabe nada da outra e a dedup simplesmente não acontece. Precisa ser estado compartilhado e persistente. - Responder 500 pra evento duplicado. Parece certo ("rejeitei, então erro"), e é o jeito mais rápido de criar um loop: a plataforma interpreta como falha e reenvia. Duplicado é sucesso, responde 200.
- Responder 200 antes de garantir persistência. O oposto, e mais grave. Se você responde 200 e o processo morre antes de gravar ou enfileirar, o evento sumiu pra sempre. Na Meta, passadas as 36 horas a notificação é descartada e não volta. Responda 200 depois de gravar, nunca antes.
- Deduplicar o webhook e esquecer o disparo. Você blindou a entrada, mas o seu worker de envio faz retry quando a API do WhatsApp devolve timeout. Aí a mensagem sai duas vezes sem nenhum webhook duplicado envolvido. A camada de envio precisa da própria trava (a chave de negócio), marcada antes da chamada externa.
- Esquecer que o canal também erra. Idempotência resolve o duplicado nascido do reenvio de evento. Ela não resolve duplicado nascido de reconexão de sessão ou de instância que recebe o mesmo update duas vezes. Os trade-offs de cada canal de disparo (e onde cada um costuma vazar) estão em Evolution API vs Cloud API.
Por que isso é problema comercial, não capricho de dev
Mensagem duplicada custa em três lugares, e nenhum deles aparece no dashboard.
O primeiro é a confiança. Ninguém escreve "cancelei porque recebi a cobrança três vezes", a pessoa só some. A duplicata é prova visível de que tem um robô mal cuidado do outro lado, e isso contamina a leitura de tudo que veio antes: a landing, a promessa, o preço.
O segundo é o canal. No WhatsApp, mensagem repetida pro mesmo número é praticamente um pedido de denúncia, e volume de bloqueio e report é sinal de qualidade. Dá pra fazer tudo certo em aquecimento e volume e queimar a operação com um retry mal tratado.
O terceiro é o mais direto: você paga por mensagem que não deveria ter saído. Numa régua de PIX com alguns milhares de eventos por dia, o duplicador silencioso é uma linha a mais na fatura todo mês, e ninguém procura ali.
O conserto é chato e pequeno. Uma tabela com chave única, um INSERT ... ON CONFLICT DO NOTHING antes de qualquer disparo, um 200 rápido, uma fila pro trabalho pesado e um cron de limpeza. Meia hora de trabalho pra fechar o buraco por onde escapa a única coisa que essa operação de fato vende, que é parecer gente do outro lado.
Fontes primárias consultadas em 07/08/2026: central de ajuda da Hotmart sobre webhook/postback (reenvio até cinco vezes, histórico de 60 dias, reenvio manual, desativação da URL com erro); doc de webhooks da Meta for Developers (retentativa com frequência decrescente ao longo de 36 horas, descarte depois disso, deduplicação no servidor, lotes de até 1000 updates); doc de webhooks da Evolution API (variáveis de retry, backoff, timeout e aviso de duplicação ao responder erro).