Aprenda integrações webhook confiáveis com assinatura, chaves de idempotência, proteção contra replay e um fluxo de debug rápido para falhas reportadas por clientes.

Quando alguém diz “os webhooks estão quebrados”, geralmente quer dizer três coisas: eventos nunca chegaram, eventos chegaram duas vezes, ou chegaram em uma ordem confusa. Do ponto de vista do cliente, o sistema “perdeu” algo. Do seu ponto de vista, o provedor enviou, mas seu endpoint não aceitou, não processou ou não registrou do jeito esperado.
Webhooks vivem na internet pública. Requisições atrasam, são re-enviadas e às vezes chegam fora de ordem. A maioria dos provedores faz retries agressivos quando vê timeouts ou respostas não 2xx. Isso transforma um pequeno problema (um banco de dados lento, um deploy, uma breve queda) em duplicações e condições de corrida.
Logs ruins fazem isso parecer aleatório. Se você não pode provar se uma requisição era autêntica, não pode agir com segurança. Se não consegue ligar a reclamação de um cliente a uma tentativa de entrega específica, acaba chutando.
A maioria das falhas no mundo real cai em alguns grupos:
O objetivo prático é simples: aceitar eventos reais uma vez, rejeitar falsos e deixar um rastro claro para você depurar uma reclamação de cliente em minutos.
Um webhook é apenas uma requisição HTTP que um provedor envia para um endpoint que você expõe. Você não o puxa como uma chamada de API. O remetente empurra quando algo acontece, e seu trabalho é receber, responder rapidamente e processar com segurança.
Uma entrega típica inclui um corpo de requisição (frequentemente JSON) mais headers que ajudam a validar e rastrear o que você recebeu. Muitos provedores incluem um timestamp, um tipo de evento (como invoice.paid) e um ID único de evento que você pode guardar para detectar duplicatas.
O que surpreende times: a entrega quase nunca é “exatamente uma vez.” A maioria dos provedores visa “ao menos uma vez”, o que significa que o mesmo evento pode chegar várias vezes, às vezes minutos ou horas depois.
Retries acontecem por motivos mundanos: seu servidor está lento ou timeoutou, você retorna um 500, a rede deles não viu seu 200, ou seu endpoint ficou indisponível durante deploys ou picos de tráfego.
Timeout é especialmente traiçoeiro. Seu servidor pode receber a requisição e até terminar de processá-la, mas a resposta não chega ao remetente a tempo. Do ponto de vista do provedor, falhou, então ele reenvia. Sem proteção, você processa o mesmo evento duas vezes.
Um bom modelo mental é tratar a requisição HTTP como uma “tentativa de entrega”, não como “o evento”. O evento é identificado pelo seu ID. Seu processamento deve se basear nesse ID, não em quantas vezes o provedor chamou você.
Assinatura de webhook é como o remetente prova que uma requisição veio realmente dele e não foi alterada no caminho. Sem assinatura, qualquer um que adivinhe sua URL pode postar eventos falsos de “pagamento concluído” ou “usuário atualizado”. Pior, um evento real pode ser alterado em trânsito (valor, ID do cliente, tipo de evento) e ainda parecer válido para sua aplicação.
O padrão mais comum é HMAC com um segredo compartilhado. Ambos os lados conhecem o mesmo valor secreto. O remetente pega o payload exato do webhook (normalmente o corpo bruto da requisição), calcula um HMAC usando esse segredo e envia a assinatura junto com o payload. Seu trabalho é recalcular o HMAC sobre os mesmos bytes e verificar se as assinaturas coincidem.
Os dados de assinatura geralmente ficam num header HTTP. Alguns provedores também incluem um timestamp ali para que você possa adicionar proteção contra replay. Menos comumente, a assinatura fica embutida no corpo JSON, o que é mais arriscado porque parsers ou re-serialização podem mudar a formatação e quebrar a verificação.
Ao comparar assinaturas, não use uma comparação de string normal. Comparações básicas vazam diferenças de tempo que ajudam um atacante a adivinhar a assinatura correta em muitas tentativas. Use uma função de comparação em tempo constante da sua linguagem ou biblioteca criptográfica e rejeite em qualquer discrepância.
Se um cliente reporta “seu sistema aceitou um evento que nós nunca enviamos”, comece pelas checagens de assinatura. Se a verificação falha, provavelmente há um segredo diferente ou você está fazendo hash dos bytes errados (por exemplo, JSON parseado em vez do corpo bruto). Se passar, você pode confiar na identidade do remetente e seguir para dedup, ordenação e retries.
O manejo confiável de webhooks começa com uma regra chata: verifique o que você recebeu, não o que você gostaria de ter recebido.
Capture o corpo bruto da requisição exatamente como chegou. Não parseie e re-serialize o JSON antes de checar a assinatura. Pequenas diferenças (whitespace, ordem de chaves, unicode) mudam os bytes e podem fazer assinaturas válidas parecerem inválidas.
Então construa o payload exato que seu provedor espera que você assine. Muitos sistemas assinam uma string como timestamp + "." + raw_body. O timestamp não é decoração. Ele existe para que você possa rejeitar requisições antigas.
Calcule o HMAC usando o segredo compartilhado e o hash requerido (frequentemente SHA-256). Mantenha o segredo em um cofre seguro e trate-o como uma senha.
Por fim, compare seu valor computado com o header de assinatura usando uma comparação em tempo constante. Se não bater, retorne um 4xx e pare. Não “aceite mesmo assim”.
Uma checklist rápida de implementação:
Um cliente reporta “webhooks pararam de funcionar” depois que você adicionou middleware de parsing JSON. Você vê mismatches de assinatura, especialmente em payloads maiores. A correção normalmente é verificar usando o corpo bruto antes de qualquer parsing e logar qual passo falhou (por exemplo, “header de assinatura ausente” vs “timestamp fora da janela permitida”). Esse detalhe costuma reduzir o tempo de debug de horas para minutos.
Provedores reexecutam porque a entrega não é garantida. Seu servidor pode ficar indisponível por um minuto, um salto de rede pode perder a requisição, ou seu handler pode timeoutar. O provedor assume “talvez funcionou” e manda o mesmo evento de novo.
Uma chave de idempotência é o número de recibo que você usa para reconhecer um evento que já processou. Não é um recurso de segurança e não substitui verificação de assinatura. Também não resolve condições de corrida a menos que você a grave e verifique em segurança sob concorrência.
Escolher a chave depende do que o provedor fornece. Prefira um valor que permaneça estável entre retries:
Quando receber um webhook, grave a chave no armazenamento primeiro usando uma regra de unicidade para que só uma requisição “vença”. Depois processe o evento. Se ver a mesma chave outra vez, retorne sucesso sem repetir o trabalho.
Mantenha seu “recibo” armazenado pequeno mas útil: a chave, status de processamento (recebido/processado/falhou), timestamps (primeira vez visto/última vez visto) e um resumo mínimo (tipo de evento e ID do objeto relacionado). Muitos times retêm chaves por 7 a 30 dias para cobrir retries tardios e a maioria das reclamações de clientes.
Proteção contra replay evita um problema simples e desagradável: alguém captura uma requisição webhook real (com assinatura válida) e a envia novamente mais tarde. Se seu handler tratar cada entrega como nova, esse replay pode causar reembolsos duplicados, convites de usuário repetidos ou mudanças de status repetidas.
Uma abordagem comum é assinar não só o payload, mas também um timestamp. Seu webhook inclui headers como X-Signature e X-Timestamp. Ao receber, verifique a assinatura e também se o timestamp está fresco dentro de uma janela curta.
Clock drift é o que normalmente causa rejeições falsas. Seus servidores e os servidores do remetente podem discordar por um minuto ou dois, e redes podem atrasar entrega. Mantenha uma margem e logue por que você rejeitou uma requisição.
Regras práticas que funcionam bem:
abs(now - timestamp) <= window (por exemplo, 5 minutos mais uma pequena folga).Se timestamps estiverem ausentes, você não pode fazer proteção contra replay baseada apenas no tempo. Nesse caso, apoie-se mais em idempotência (armazene e rejeite IDs de evento duplicados) e considere exigir timestamps na próxima versão do webhook.
Rotação de segredos também importa. Se você rotacionar segredos de assinatura, mantenha múltiplos segredos ativos por um curto período de sobreposição. Verifique contra o segredo mais novo primeiro e depois caia para os antigos. Isso evita quebra para clientes durante a implantação. Se sua equipe publica endpoints rapidamente (por exemplo, gerando código com Koder.ai e usando snapshots e rollback durante deploys), essa janela de sobreposição ajuda porque versões antigas podem ficar ativas por pouco tempo.
Retries são normais. Assuma que cada entrega pode ser duplicada, atrasada ou fora de ordem. Seu handler deve se comportar igual se vir um evento uma vez ou cinco vezes.
Mantenha o caminho de requisição curto. Faça apenas o que for necessário para aceitar o evento e mova trabalho pesado para um job em background.
Um padrão simples que funciona em produção:
Retorne 2xx somente depois de verificar a assinatura e registrar o evento (ou enfileirá-lo). Se você responder 200 antes de salvar qualquer coisa, pode perder eventos durante um crash. Se fizer trabalho pesado antes de responder, timeouts disparam retries e você pode repetir efeitos colaterais.
Sistemas downstream lentos são a principal razão pela qual retries viram problema. Se seu provedor de email, CRM ou banco estiver lento, deixe uma fila absorver o atraso. O worker pode retryar com backoff, e você pode alertar sobre jobs travados sem bloquear o remetente.
Eventos fora de ordem também acontecem. Por exemplo, um subscription.updated pode chegar antes de subscription.created. Construa tolerância verificando estado atual antes de aplicar mudanças, permitindo upserts e tratando “não encontrado” como motivo para tentar novamente depois (quando fizer sentido) em vez de como falha permanente.
Muitos problemas “aleatórios” são auto-infligidos. Parecem redes instáveis, mas se repetem em padrões, geralmente após um deploy, rotação de segredo ou pequena mudança no parsing.
O bug de assinatura mais comum é fazer hash dos bytes errados. Se você parseia JSON primeiro, seu servidor pode reformatar (whitespace, ordem de chaves, formatação de números). Então você verifica a assinatura contra um corpo diferente do que o remetente assinou, e a verificação falha mesmo com payload genuíno. Sempre verifique contra os bytes brutos exatamente como recebidos.
A próxima grande fonte de confusão são secrets. Times testam em staging mas acidentalmente verificam com o segredo de produção, ou mantêm um segredo antigo depois da rotação. Quando um cliente reporta falhas “só em um ambiente”, assuma segredo errado ou config errada primeiro.
Alguns erros que levam a longas investigações:
Exemplo: um cliente diz “order.paid nunca chegou”. Você vê falhas de assinatura começando após um refactor que trocou o middleware de parsing da requisição. O middleware lê e re-encoda o JSON, então sua checagem de assinatura agora usa um corpo modificado. A correção é simples, mas só se você souber procurar por isso.
Quando um cliente diz “seu webhook não foi acionado”, trate como um problema de rastreio, não de adivinhação. Aperte em uma tentativa exata de entrega do provedor e siga-a pelo seu sistema.
Comece pegando o identificador de entrega do provedor, request ID ou event ID da tentativa que falhou. Com esse único valor você deve conseguir encontrar a entrada de log correspondente rapidamente.
A partir daí, cheque três coisas em ordem:
Confirme então o que você retornou ao provedor. Um 200 lento pode ser tão ruim quanto um 500 se o provedor timeoutar e reexecutar. Veja código de status, tempo de resposta e se seu handler reconheceu antes de fazer trabalho pesado.
Se precisar reproduzir, faça com segurança: armazene uma amostra bruta redigida (headers-chave mais corpo bruto) e reproduza em um ambiente de teste usando o mesmo segredo e código de verificação.
Quando uma integração de webhook começa a falhar “aleatoriamente”, velocidade importa mais que perfeição. Este runbook pega as causas usuais.
Pegue um exemplo concreto primeiro: nome do provedor, tipo de evento, timestamp aproximado (com timezone) e qualquer event ID que o cliente possa ver.
Depois verifique:
Se o provedor diz “reexecutamos 20 vezes”, cheque padrões comuns primeiro: segredo errado (signature falha), clock drift (janela de replay), limites de tamanho de payload (413), timeouts (sem resposta) e picos de 5xx de dependências downstream.
Um cliente escreve: “Perdemos um evento invoice.paid ontem. Nosso sistema nunca atualizou.” Aqui está uma forma rápida de traçar.
Primeiro, confirme se o provedor tentou a entrega. Pegue o event ID, timestamp, URL de destino e o código exato de resposta que seu endpoint retornou. Se houve retries, note a causa da primeira falha e se um retry posterior teve sucesso.
Em seguida, valide o que seu código viu na borda: confirme o segredo de assinatura configurado para aquele endpoint, recompute a verificação de assinatura usando o corpo bruto e cheque o timestamp da requisição contra sua janela permitida.
Cuidado com janelas de replay durante retries. Se sua janela é de 5 minutos e o provedor reexecuta 30 minutos depois, você pode rejeitar um retry legítimo. Se essa é sua política, documente; se não, aumente a janela ou mude a lógica para que a idempotência seja a defesa principal contra duplicações.
Se assinatura e timestamp estiverem ok, siga o event ID pelo seu sistema e responda: você processou, deduplicou ou descartou?
Desfechos comuns:
Ao responder ao cliente, seja objetivo e específico: “Recebemos tentativas de entrega às 10:03 e 10:33 UTC. A primeira timeoutou após 10s; o retry foi rejeitado porque o timestamp estava fora da nossa janela de 5 minutos. Aumentamos a janela e adicionamos reconhecimento mais rápido. Por favor reenvie o event ID X se necessário.”
A maneira mais rápida de parar incêndios com webhooks é fazer cada integração seguir o mesmo roteiro. Escreva o contrato que você e o remetente concordam: headers obrigatórios, método exato de assinatura, qual timestamp usar e quais IDs você trata como únicos.
Depois padronize o que vocês gravam para cada tentativa de entrega. Um pequeno log de recibo costuma ser suficiente: received_at, event_id, delivery_id, signature_valid, idempotency_result (novo/duplicado), handler_version e status de resposta.
Um fluxo que se mantém útil conforme você cresce:
Se você constrói apps com Koder.ai (koder.ai), o Planning Mode é uma maneira útil de definir o contrato do webhook primeiro (headers, assinatura, IDs, retry behavior) e depois gerar um endpoint consistente e um registro de recibo entre projetos. Essa consistência é o que torna a depuração rápida em vez de heróica.
Porque a entrega de webhooks costuma ser at-least-once, não exatamente uma vez. Os provedores reexecutam em timeouts, respostas 5xx e às vezes quando não veem seu 2xx a tempo, então você pode ter duplicações, atrasos e entregas fora de ordem mesmo quando tudo parece “funcionar”.
Siga esta regra: verifique a assinatura primeiro, depois grave/deduplique o evento, responda 2xx, e então faça o trabalho pesado assincronamente.
Se você fizer trabalho pesado antes de responder, baterá em timeouts e acionará retries; se responder antes de gravar qualquer coisa, pode perder eventos em crashes.
Use os bytes brutos do corpo da requisição exatamente como chegaram. Não parseie JSON e re-serialize antes da verificação—espaços, ordem de chaves e formatação de números podem quebrar assinaturas.
Também verifique que você está recriando exatamente o payload que o provedor assina (frequentemente timestamp + "." + raw_body).
Retorne um 4xx (comum 400 ou 401) e não processe o payload.
Registre um motivo mínimo (header de assinatura ausente, mismatch, janela de timestamp inválida), mas não registre segredos nem payloads sensíveis completos.
Uma chave de idempotência é um identificador estável e único que você armazena para que retries não reapliquem efeitos colaterais.
Melhores opções:
Imponha com uma para que apenas uma requisição “vença” sob concorrência.
Grave a chave de idempotência antes de fazer efeitos colaterais, com uma regra de unicidade. Depois:
Se o insert falhar porque a chave já existe, retorne 2xx e pule a ação de negócio.
Inclua um timestamp nos dados assinados e rejeite requisições fora de uma janela curta (por exemplo, alguns minutos).
Para não bloquear retries legítimos:
Não presuma que a ordem de entrega seja a ordem dos eventos. Faça handlers tolerantes:
Armazene o event ID e o tipo para poder raciocinar sobre o que aconteceu mesmo com ordem estranha.
Registre um pequeno “recibo” por tentativa de entrega para rastrear um evento de ponta a ponta:
Mantenha logs pesquisáveis por event ID para que o suporte responda rapidamente aos clientes.
Peça um identificador concreto: event ID ou delivery ID, mais um timestamp aproximado.
Depois verifique nesta ordem:
Se você usa Koder.ai, mantenha o padrão do handler consistente (verificar → gravar/dedupe → enfileirar → responder). A consistência torna essas checagens rápidas em incidentes.