A engenharia de nunca perder um webhook: fila, retry e idempotência
Todo mundo que já integrou um gateway de pagamento (Kiwify, Hotmart, Stripe, Asaas) conhece o roteiro: a plataforma manda um POST pro seu endpoint avisando "pedido aprovado", e o seu sistema libera o acesso do cliente. Simples. Até o dia em que um cliente paga e não recebe nada, e você descobre que aquele webhook se perdeu no meio do caminho.
O que parece um POST trivial esconde um problema clássico de sistemas distribuídos: entrega confiável entre dois sistemas que falham em momentos diferentes. Este artigo destrincha os pontos que tornam isso difícil e como se resolve cada um. No fim, comento a ferramenta que estou construindo em cima dessas ideias.
O ingênuo: receber e processar
A primeira versão que todo mundo escreve é assim:
POST /webhook -> valida -> libera acesso -> envia e-mail -> responde 200
O problema aparece rápido. Se "libera acesso" e "envia e-mail" demoram, ou dependem de uma API externa lenta, a resposta 200 demora junto. E o remetente (o gateway) não espera pra sempre: ele tem um timeout. Se você não confirma a tempo, ele considera falha, mesmo que seu processamento termine depois. Resultado: do lado dele, "falhou"; do seu lado, pode ter processado, ou não. Ambiguidade.
Ponto 1: responder rápido, processar depois
A primeira correção é desacoplar recepção de processamento:
POST /webhook -> grava o evento cru -> responde 200 imediatamente (em segundo plano) worker -> lê o evento -> processa
O endpoint agora faz só duas coisas: persistir o payload e responder. Milissegundos. O trabalho pesado sai da frente e roda num worker separado, no seu ritmo. O gateway recebe o 2xx rápido e fica feliz.
Isso introduz a necessidade de uma fila (ou de uma tabela de eventos funcionando como fila). E fila traz os próximos problemas.
Ponto 2: idempotência (o cliente não pode receber o produto duas vezes)
Gateways reenviam webhooks quando acham que falharam. Isso é bom (não perde evento), mas significa que você vai receber o mesmo evento mais de uma vez. Se o seu worker processa cada entrega cegamente, o cliente recebe o produto duas vezes, ou toma dois e-mails, ou você conta a venda em dobro.
A solução é idempotência: cada evento precisa de uma chave estável, e você processa cada chave uma única vez. Um detalhe que pega muita gente: use um identificador do recurso (o id do pedido/venda), não um id da tentativa de entrega. Na Kiwify, por exemplo, o id do "envelope" muda a cada reenvio, então ele não serve pra deduplicar. No banco, um UNIQUE na chave de idempotência resolve de forma limpa: a segunda inserção do mesmo evento simplesmente não entra.
Ponto 3: retry com backoff (o servidor de destino também cai)
Até aqui protegemos contra o nosso endpoint ser lento. Mas o worker, ao entregar o evento pro destino final (o sistema do cliente), enfrenta o mesmo problema ao contrário: e se o destino estiver fora do ar?
Reentregar na hora, em loop, é a pior ideia: você martela um servidor que já está sofrendo. A resposta é backoff exponencial: falhou, espera um pouco e tenta de novo, aumentando o intervalo a cada tentativa (1min, 5min, 15min, 1h). Isso dá tempo do destino se recuperar sem ser bombardeado. E, depois de N tentativas sem sucesso, o evento vai pra uma "fila morta" (Dead Letter Queue) pra inspeção manual, em vez de tentar pra sempre.
Ponto 4: visibilidade e replay
Quando algo dá errado (e vai), você precisa enxergar. Sem log do payload que entrou, do que saiu e da resposta exata do destino, você fica no escuro. Guardar isso permite duas coisas valiosas: diagnosticar ("o servidor devolveu 500 nesse header") e reprocessar em massa (o destino ficou 2h fora, você reenvia todos os eventos daquele período de uma vez, em vez de um por um).
Ponto 5: aguentar o pico
Tudo isso é testado no pior momento: a Black Friday ou o lançamento, quando chegam milhares de eventos em minutos. O endpoint de ingestão precisa ser leve o suficiente pra absorver o pico sem cair (por isso ele só persiste e responde), e a fila precisa segurar o acúmulo enquanto o worker drena no seu ritmo. Desacoplar recepção de processamento não é elegância acadêmica: é o que mantém tudo de pé quando a carga sobe.
Juntando as peças
Uma arquitetura confiável de webhook, no mínimo, tem: um endpoint de ingestão que persiste e responde 2xx na hora; uma fila entre recepção e processamento; idempotência por chave estável; retry com backoff exponencial e uma DLQ; e logs que permitam diagnóstico e replay. Nenhuma dessas peças é exótica sozinha. A dificuldade é que você precisa de todas juntas, corretas, pra não perder um evento, e mantê-las é trabalho contínuo.
Foi partindo disso que comecei a construir o HookSafe: uma camada que fica entre o gateway e o seu servidor, responde 2xx na hora, e cuida de fila, retry, idempotência e replay pra você, sem precisar reescrever o seu sistema. Está em early access. Se o tema te interessa, o problema é mais fundo do que parece, e é um puta exercício de engenharia.
Como você resolve entrega confiável de webhook nos seus projetos? Curioso pra saber as abordagens de vocês nos comentários.