Webhooks: o que são e como validar com segurança

Entenda a diferença entre uma API tradicional e um webhook, por que o servidor precisa ficar esperando uma notificação em vez de pedir o dado, e como validar a assinatura pra garantir que a chamada é legítima.


Já expliquei aqui o que é uma API e como funciona WebSocket pra comunicação em tempo real. Webhook resolve um problema parecido, notificação de evento acontecendo em outro lugar, mas com uma lógica bem mais simples por trás, e que aparece o tempo todo em integração com serviço de pagamento, sistema de e-mail transacional, plataforma de notificação.

API tradicional x webhook

Numa API tradicional, o seu sistema é quem pergunta. Você quer saber se um pagamento foi confirmado, então faz uma requisição perguntando: "esse pagamento já foi confirmado?" Se a resposta muda de tempos em tempos, você acaba tendo que perguntar de novo e de novo, o mesmo problema de polling que já falei no post de WebSocket.

Num webhook, a lógica se inverte: em vez de você perguntar, o outro serviço avisa. Você registra uma URL sua (o endpoint do webhook) junto do provedor, e quando o evento que interessa acontece (pagamento confirmado, por exemplo), o provedor faz uma requisição POST direto nessa URL, te entregando o dado do evento assim que ele acontece, sem você precisar ficar perguntando.

app.post("/webhooks/pagamento", (req, res) => {
  const evento = req.body;
 
  if (evento.tipo === "pagamento.confirmado") {
    // atualiza o pedido no seu sistema
  }
 
  res.sendStatus(200);
});

Diferença de WebSocket

Vale separar isso de WebSocket, que resolve algo parecido mas de outro jeito. WebSocket mantém uma conexão aberta e contínua entre cliente e servidor, útil quando o próprio usuário logado precisa receber atualização em tempo real na tela dele. Webhook é uma notificação pontual, servidor conversando com servidor, sem conexão persistente, cada evento chega como uma requisição HTTP independente das outras.

O problema real: qualquer um pode chamar seu endpoint

Aqui mora o detalhe de segurança que mais gente esquece. Como o endpoint do webhook é uma URL pública, respondendo a POST, nada impede, em teoria, que qualquer pessoa mande uma requisição fingindo ser o provedor de pagamento, dizendo "esse pedido foi pago", sem ter pago nada de verdade.

{ "tipo": "pagamento.confirmado", "pedido_id": "123" }

Se o seu código simplesmente confia em qualquer corpo de requisição que chega nessa URL, alguém mal intencionado pode forjar esse evento e liberar um pedido sem pagamento real ter acontecido.

Validando a assinatura

A solução padrão é o provedor assinar cada requisição com uma chave secreta compartilhada só entre ele e você, e mandar essa assinatura num header:

X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c59

Essa assinatura geralmente é um HMAC calculado a partir do corpo da requisição e da chave secreta. No seu servidor, antes de confiar em qualquer coisa do corpo, você recalcula essa assinatura com a mesma chave e confere se bate com a que veio no header:

import crypto from "crypto";
 
function assinaturaValida(corpoBruto, assinaturaRecebida, chaveSecreta) {
  const assinaturaCalculada = crypto
    .createHmac("sha256", chaveSecreta)
    .update(corpoBruto)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(assinaturaCalculada),
    Buffer.from(assinaturaRecebida)
  );
}

Se as duas assinaturas não baterem, a requisição é rejeitada antes de qualquer processamento, porque só quem conhece a chave secreta (você e o provedor) consegue gerar uma assinatura válida pra aquele corpo específico. Um detalhe importante: a comparação usa timingSafeEqual em vez de um simples ===, porque uma comparação normal de string pode vazar informação sobre a assinatura correta através do tempo que leva pra falhar, e é isso que a comparação seguro contra timing attack evita.

Corpo bruto, não o corpo já interpretado

Um erro comum é calcular a assinatura em cima do objeto JavaScript já parseado (req.body), depois que o framework já transformou o JSON de volta em objeto. A assinatura do provedor foi calculada em cima do texto bruto exato que ele enviou, então qualquer diferença de formatação na hora de reserializar (ordem de campo, espaço, quebra de linha) já faz a assinatura não bater mais. O certo é capturar o corpo bruto da requisição, antes do parse, especificamente pra esse cálculo.

Idempotência: o mesmo evento pode chegar duas vezes

Outro cuidado comum em webhook é que a maioria dos provedores não garante entrega única, o mesmo evento pode chegar repetido, geralmente porque seu servidor demorou a responder e o provedor tentou de novo por segurança. O código que processa o webhook deveria ser idempotente: processar o mesmo evento duas vezes não pode causar efeito duplicado, como cobrar duas vezes ou liberar dois pedidos. Guardar o ID do evento já processado e ignorar repetição é o jeito mais direto de resolver isso.

Fechando

Webhook resolve o problema de saber que algo aconteceu em outro sistema sem precisar ficar perguntando, mas isso só é seguro se o endpoint validar a assinatura de cada requisição antes de confiar em qualquer dado do corpo. Sem essa validação, você está basicamente com uma porta aberta aceitando qualquer um que souber a URL como se fosse o provedor de verdade.