Voltar pro blog json-p.org

PilarRecuperação de vendas automática Publicado11 de agosto de 2026 AutorElton

Idempotência em webhook: nunca mandar a mesma mensagem 2x

Idempotência em webhook é o que impede o mesmo evento reenviado virar mensagem duplicada pro cliente. Veja o mecanismo, o SQL e os erros mais comuns.

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:

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:

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):

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).