Webhooks Explicados: Payloads Assinados e Retentativas Confiáveis

Backend2026-11-05TryQuickToolBox

Sua integração funciona em staging: o provedor de pagamento dispara um evento, seu endpoint o recebe, o pedido muda para paid. Então, em produção, um cliente paga duas vezes, uma entrega desaparece silenciosamente durante um deploy, e alguém reproduz uma requisição capturada para marcar um pedido como reembolsado. Todas as três falhas compartilham a mesma causa raiz: o webhook foi tratado como um simples POST HTTP em vez de um canal de entrega pelo menos uma vez, crítico para a segurança.

Este guia cobre as duas metades de uma implementação correta de webhook — verificar que um payload é autêntico e fazer a entrega sobreviver a retentativas — com código que você pode adaptar a qualquer stack.

O que um webhook realmente é

Um webhook é uma requisição HTTP servidor-a-servidor que um sistema envia a outro quando um evento acontece. Ele inverte o modelo usual de polling: em vez de seu app perguntar "tem algo novo?" a cada minuto, o produtor empurra o evento para uma URL que você registrou.

Duas propriedades decorrem desse design, e ambas importam:

Tudo abaixo é consequência desses dois fatos.

Assinando payloads com HMAC

A abordagem padrão é um segredo compartilhado mais um HMAC sobre o corpo bruto da requisição. O remetente calcula uma assinatura e a coloca em um cabeçalho; você recalcula e compara. Se coincidirem, o payload veio de alguém que conhece o segredo e não foi modificado em trânsito.

Três detalhes separam uma implementação funcional de uma quebrada:

  1. Assine os bytes brutos. Faça o parse do JSON somente após a verificação. Re-serializar o corpo altera espaços em branco e ordem das chaves, e a assinatura nunca vai coincidir.
  2. Use comparação em tempo constante. Um == ingênuo vaza informações de timing. Use a comparação segura embutida da linguagem.
  3. Inclua um timestamp na string assinada. Assinar timestamp + "." + body permite rejeitar requisições antigas, o que bloqueia ataques de replay.

Verificando uma assinatura em Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const signature = headers['x-signature'];   // e.g. "t=1712345678,v1=abc123..."
  const timestamp = headers['x-timestamp'];
  if (!signature || !timestamp) return false;

  // 1. Rejeitar requisições antigas (proteção contra replay)
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;

  // 2. Recalcular sobre os bytes exatos recebidos
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 3. Comparação em tempo constante
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

No Express, isso exige express.raw({ type: 'application/json' }) na rota do webhook para que o corpo chegue como Buffer. Se um middleware global express.json() rodar primeiro, o corpo bruto se perde e a verificação falhará — um bug muito comum.

Verificando em Python

import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Sempre retorne 401 em uma verificação falha e registre o ID da requisição — mas nunca registre o segredo ou a assinatura completa.

Projetando retentativas que realmente funcionam

Retentativas só são úteis se seu endpoint se comportar de forma previsível. O contrato é simples: retorne 2xx rapidamente assim que aceitar o evento de forma durável, e deixe todo o resto ser retentado.

Sua respostaComportamento do remetenteO que fazer em vez disso
200 / 204Marcar como entregueCorreto — confirme após enfileirar
400 / 401Geralmente sem retentativaCorrija o tratamento de assinatura ou payload
429 / 503Retentar com backoffRetorne quando sobrecarregado, não um 500 genérico
500 / timeoutRetentarMova trabalho lento para uma fila

Duas regras mantêm isso saudável:

Torne os handlers idempotentes

Como a entrega é pelo menos uma vez, o mesmo ID de evento pode chegar duas vezes — uma após um timeout em que seu handler na verdade teve sucesso. Armazene IDs de eventos processados e ignore duplicatas:

INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- Se 0 linhas inseridas, isto é uma duplicata: retorne 200 e pare.

Quando possível, torne a operação de negócio em si idempotente — defina o status do pedido como paid em vez de incrementar um contador, e use o ID do evento como chave de idempotência ao chamar serviços downstream.

Uma checklist antes de ir para produção

  1. Verifique o HMAC sobre bytes brutos com comparação em tempo constante.
  2. Rejeite timestamps mais antigos que alguns minutos para impedir replays.
  3. Persista o evento e então retorne 2xx dentro de um ou dois segundos.
  4. Deduplique pelo ID do evento antes de fazer qualquer trabalho.
  5. Registre o ID do evento, tipo e resultado — nunca o segredo.
  6. Exponha uma forma de reproduzir eventos manualmente a partir do seu dashboard ou ferramenta de admin.
  7. Teste com assinaturas deliberadamente quebradas, entregas duplicadas e handlers lentos.

Quando você estiver depurando uma integração com falha, o corpo bruto da requisição é o que você mais vai encarar. Colá-lo em um formatador JSON torna objetos de evento aninhados legíveis sem alterar os bytes que você já capturou.

FAQ

Por que minha verificação de assinatura falha mesmo que o payload pareça idêntico?

Quase sempre porque o corpo foi parseado e re-serializado antes do hash. A ordem das chaves JSON, espaços em branco e escape unicode mudam durante parse/stringify, então os bytes não coincidem mais com o que o remetente assinou. Capture o corpo bruto e faça o hash dele.

Devo retornar 200 antes ou depois de processar o evento?

Depois de armazená-lo de forma durável, antes de fazer o trabalho pesado. Se você processar primeiro, chamadas downstream lentas causam timeouts, o remetente retenta e você obtém duplicatas. Enfileire o evento, responda imediatamente e processe de forma assíncrona.

HTTPS é suficiente para proteger um endpoint de webhook?

Não. TLS protege os dados em trânsito, mas não prova quem enviou a requisição — qualquer pessoa pode fazer POST em uma URL pública. A assinatura HMAC com um segredo compartilhado autentica o remetente, e um timestamp assinado impede o replay de requisições capturadas.

Conclusão

Trate webhooks como qualquer entrada não confiável: verifique a assinatura sobre os bytes brutos, confira o timestamp, deduplique pelo ID do evento e confirme somente após o evento estar armazenado com segurança. Faça isso, e as retentativas deixam de ser uma fonte de cobranças duplicadas e se tornam o que deveriam ser — uma rede de segurança.