DevGuideTK
← Trilha Backend

Webhooks

Notificações entre sistemas por HTTP, com assinatura, novas tentativas e entrega duplicada.

Os receptores desta página foram executados nas cinco linguagens contra as mesmas entregas, assinadas pela biblioteca oficial do Standard Webhooks para Python. O emissor foi executado com um Postgres e destinos de teste.

O receptor, em cada linguagem

Cada versão faz o mesmo, na mesma ordem:

  1. Lê o corpo bruto, sem convertê-lo em objeto.
  2. Recusa com 400 a entrega sem os três cabeçalhos.
  3. Recusa com 401 o carimbo com mais de 5 minutos de diferença ou a assinatura que não confere, comparando em tempo constante e aceitando qualquer uma das assinaturas do cabeçalho (para a troca de segredo).
  4. Responde 204 a um identificador repetido, sem processar de novo.
  5. Guarda o aviso e responde 204 logo em seguida.
import crypto from "node:crypto";
import http from "node:http";

// Segredo no formato do Standard Webhooks: "whsec_" seguido da chave em base64
const chave = Buffer.from(process.env.WEBHOOK_SECRET!.replace(/^whsec_/, ""), "base64");
const TOLERANCIA_S = 5 * 60;
const recebidos = new Set<string>(); // em produção, a tabela mostrada mais abaixo

function assinaturaValida(id: string, ts: string, corpo: Buffer, cabecalho: string): boolean {
  const conteudo = Buffer.concat([Buffer.from(`${id}.${ts}.`), corpo]);
  const esperada = crypto.createHmac("sha256", chave).update(conteudo).digest();
  // O cabeçalho pode trazer várias assinaturas ("v1,abc v1,def") durante a troca de segredo
  return cabecalho.split(" ").some((item) => {
    const [versao, valor] = item.split(",");
    const recebida = Buffer.from(valor ?? "", "base64");
    return versao === "v1"
      && recebida.length === esperada.length
      && crypto.timingSafeEqual(recebida, esperada); // tempo constante
  });
}

http.createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks") {
    res.statusCode = 404;
    return res.end();
  }

  // O corpo bruto, exatamente como chegou: nada de JSON.parse antes de conferir
  const partes: Buffer[] = [];
  for await (const parte of req) partes.push(parte);
  const corpo = Buffer.concat(partes);

  const id = req.headers["webhook-id"] as string | undefined;
  const ts = req.headers["webhook-timestamp"] as string | undefined;
  const assinatura = req.headers["webhook-signature"] as string | undefined;
  if (!id || !ts || !assinatura) {
    res.statusCode = 400;
    return res.end();
  }

  const idade = Math.abs(Date.now() / 1000 - Number(ts));
  if (!(idade <= TOLERANCIA_S) || !assinaturaValida(id, ts, corpo, assinatura)) {
    res.statusCode = 401;
    return res.end();
  }

  if (recebidos.has(id)) {
    res.statusCode = 204; // repetido: confirma de novo, sem processar
    return res.end();
  }
  recebidos.add(id);
  const evento = JSON.parse(corpo.toString("utf8")); // só agora, depois de conferido
  console.log("guardado para processar:", id, evento.type);
  res.statusCode = 204; // responde logo; o processamento fica para depois
  res.end();
}).listen(3000);

O conjunto de identificadores em memória serve para o exemplo rodar sozinho. Ele se perde quando o processo reinicia e não é compartilhado entre instâncias; em produção, use a tabela da seção seguinte.

O resultado, nas cinco linguagens

Cada receptor recebeu as mesmas nove entregas. As assinaturas foram geradas pela biblioteca standardwebhooks, o que confirma que os receptores são compatíveis com o padrão:

EntregaEsperadoResultado nas cinco
Válida204, processada204, processada
A mesma entrega de novo204, sem processar204, sem processar
Assinatura de outro conteúdo401401
Corpo reserializado (json.dumps(json.loads(...))), com a assinatura original401401
Assinada corretamente, mas com carimbo de 10 minutos atrás401401
Assinada só com um segredo antigo401401
Assinada com o antigo e o novo, no mesmo cabeçalho204204
Sem os cabeçalhos400400
Assinatura com caractere fora do ASCII401401

Cada receptor processou exatamente dois avisos, um para cada identificador válido.

O que muda de uma linguagem para outra

Corpo brutoHMACComparação em tempo constante
TypeScriptJuntar os pedaços do req num Buffercrypto.createHmaccrypto.timingSafeEqual, que exige tamanhos iguais
Pythonawait request.body()hmac.newhmac.compare_digest, com bytes
Goio.ReadAll, com http.MaxBytesReader para limitar o tamanhocrypto/hmachmac.Equal
JavagetRequestBody().readAllBytes()javax.crypto.MacMessageDigest.isEqual
C#Copiar Request.Body para um MemoryStreamHMACSHA256.HashDataCryptographicOperations.FixedTimeEquals

Com frameworks, o cuidado é o mesmo: no Express, express.raw({ type: "application/json" }) na rota do webhook, em vez de express.json(); no Spring, receber o corpo como byte[] ou String, e não como objeto. A versão em Go é a única que limita o tamanho do corpo; nas outras, configure o limite no framework ou no proxy reverso.

Gravar antes de responder

No lugar do conjunto em memória, uma tabela com o identificador como chave primária. A rota grava e responde; um trabalhador processa:

CREATE TABLE webhooks_recebidos (
  id            text PRIMARY KEY,         -- o webhook-id
  corpo         text NOT NULL,
  recebido_em   timestamptz NOT NULL DEFAULT now(),
  processado_em timestamptz
);

-- Na rota, depois de conferir a assinatura (um repetido não insere nada)
INSERT INTO webhooks_recebidos (id, corpo) VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING;

-- No trabalhador, numa transação: pegar, processar, marcar
BEGIN;
SELECT id, corpo FROM webhooks_recebidos
 WHERE processado_em IS NULL
 ORDER BY recebido_em
 LIMIT 1
 FOR UPDATE SKIP LOCKED;  -- vários trabalhadores não pegam o mesmo aviso
-- ... processa o aviso ...
UPDATE webhooks_recebidos SET processado_em = now() WHERE id = $1;
COMMIT;

No teste do nível Conceito, este desenho respondeu em 66 ms à primeira tentativa, e o aviso foi processado uma única vez. Se o trabalhador cair no meio, a transação é desfeita e o aviso volta a ficar pendente. Por isso o processamento também precisa ser idempotente, como mostra o tópico de idempotência.

O lado de quem envia

Se a sua aplicação oferece webhooks, a entrega começa numa tabela gravada na mesma transação que gerou o evento:

CREATE TABLE webhook_destinos (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  url     text NOT NULL,
  segredo text NOT NULL
);

-- Uma linha por entrega, gravada na mesma transação da mudança que gerou o evento
CREATE TABLE webhook_entregas (
  id                   text PRIMARY KEY,            -- o webhook-id, igual em todas as tentativas
  destino_id           bigint NOT NULL REFERENCES webhook_destinos (id),
  corpo                text NOT NULL,
  situacao             text NOT NULL DEFAULT 'pendente',  -- pendente, entregue, desistiu
  tentativas           int NOT NULL DEFAULT 0,
  proxima_tentativa_em timestamptz NOT NULL DEFAULT now(),
  criado_em            timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON webhook_entregas (proxima_tentativa_em) WHERE situacao = 'pendente';

Um trabalhador lê as entregas vencidas, assina, envia e agenda a próxima tentativa:

import crypto from "node:crypto";
import dns from "node:dns/promises";
import net from "node:net";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const espera = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Faixas internas: o emissor nunca entrega para elas (proteção contra SSRF)
const internas = new net.BlockList();
const faixas = [
  ["10.0.0.0", 8, "ipv4"], ["172.16.0.0", 12, "ipv4"], ["192.168.0.0", 16, "ipv4"],
  ["127.0.0.0", 8, "ipv4"], ["169.254.0.0", 16, "ipv4"], ["100.64.0.0", 10, "ipv4"],
  ["0.0.0.0", 8, "ipv4"], ["::1", 128, "ipv6"], ["fc00::", 7, "ipv6"], ["fe80::", 10, "ipv6"],
] as const;
for (const [rede, bits, tipo] of faixas) internas.addSubnet(rede, bits, tipo);

async function destinoPermitido(url: URL): Promise<boolean> {
  const nome = url.hostname.replace(/^\[|\]$/g, ""); // IPv6 literal vem entre colchetes
  const enderecos = await dns.lookup(nome, { all: true });
  return enderecos.every((e) => !internas.check(e.address, e.family === 6 ? "ipv6" : "ipv4"));
}

function assinar(segredo: string, id: string, ts: number, corpo: string): string {
  const chave = Buffer.from(segredo.replace(/^whsec_/, ""), "base64");
  return "v1," + crypto.createHmac("sha256", chave).update(`${id}.${ts}.${corpo}`).digest("base64");
}

// Intervalo crescente com variação aleatória: 30 s, 2 min, 8 min, 32 min... até 6 h
const proximoIntervaloS = (tentativas: number) =>
  Math.min(30 * 4 ** (tentativas - 1), 6 * 3600) * (0.5 + Math.random());

async function entregar(e: { id: string; url: string; segredo: string; corpo: string }) {
  const url = new URL(e.url);
  if (url.protocol !== "https:" && process.env.NODE_ENV === "production") return "recusado";
  try {
    if (!(await destinoPermitido(url))) return "recusado";
    const ts = Math.floor(Date.now() / 1000);
    const resposta = await fetch(url, {
      method: "POST",
      body: e.corpo,
      redirect: "manual", // um redirecionamento poderia apontar para a rede interna
      signal: AbortSignal.timeout(10_000),
      headers: {
        "content-type": "application/json",
        "webhook-id": e.id,
        "webhook-timestamp": String(ts),
        "webhook-signature": assinar(e.segredo, e.id, ts, e.corpo),
      },
    });
    return resposta.ok ? "entregue" : "falhou"; // 3xx, 4xx e 5xx contam como falha
  } catch {
    return "falhou"; // tempo esgotado, conexão recusada, DNS
  }
}

// Trabalhador: pega as entregas vencidas; várias cópias podem rodar ao mesmo tempo
for (;;) {
  const cliente = await pool.connect();
  try {
    await cliente.query("BEGIN");
    const { rows } = await cliente.query(
      `SELECT e.id, e.corpo, e.tentativas, d.url, d.segredo
         FROM webhook_entregas e JOIN webhook_destinos d ON d.id = e.destino_id
        WHERE e.situacao = 'pendente' AND e.proxima_tentativa_em <= now()
        ORDER BY e.proxima_tentativa_em LIMIT 10
        FOR UPDATE OF e SKIP LOCKED`,
    );
    for (const e of rows) {
      const resultado = await entregar(e);
      const tentativas = e.tentativas + 1;
      const situacao = resultado === "entregue" ? "entregue"
        : resultado === "recusado" || tentativas >= 10 ? "desistiu" : "pendente";
      await cliente.query(
        `UPDATE webhook_entregas SET tentativas = $2, situacao = $3,
                proxima_tentativa_em = now() + make_interval(secs => $4) WHERE id = $1`,
        [e.id, tentativas, situacao, proximoIntervaloS(tentativas)],
      );
    }
    await cliente.query("COMMIT");
    if (rows.length === 0) await espera(1000);
  } catch (erro) {
    await cliente.query("ROLLBACK");
    console.error(erro);
    await espera(1000);
  } finally {
    cliente.release();
  }
}

O teste usou uma cópia com os intervalos encurtados (1 segundo no lugar de 30) e com a faixa 172.16.0.0/12 liberada, porque os destinos de teste estavam numa rede Docker:

DestinoResultado
Responde 503 nas duas primeiras tentativas do aviso "pago"Entregue na terceira; o aviso "enviado", criado depois, chegou antes
http://169.254.169.254/latest/meta-data/Recusado sem nenhum pedido; desistiu na primeira
http://[::ffff:127.0.0.1]:3000/ (IPv4 escrito como IPv6)Recusado sem nenhum pedido
Responde com redirecionamento para 127.0.0.1Cada tentativa contou como falha, e o redirecionamento não foi seguido

O filtro também recusou localhost, http://2130706433/ e http://0x7f.1/, duas formas de escrever 127.0.0.1 que o URL converte sozinho.

A conferência de endereços acontece antes de cada entrega, mas entre ela e a conexão o nome ainda pode mudar de endereço (DNS rebinding). Para fechar essa brecha, conecte no endereço conferido ou faça as entregas por um proxy de saída que aplica o filtro, numa rede sem rota para os serviços internos.

Testar localmente

O emissor precisa alcançar a sua máquina, que normalmente não tem endereço público. Há três caminhos:

  • A ferramenta do emissor, quando existe: a CLI da Stripe, por exemplo, recebe os eventos e os repassa para localhost (stripe listen --forward-to localhost:3000/webhooks).
  • Um túnel que dá um endereço público temporário à sua máquina.
  • Entregas assinadas por um script, como as do teste desta página, usando a biblioteca do padrão com o mesmo segredo. É a forma de cobrir os casos de erro: assinatura errada, carimbo velho, repetidos.

Lista de revisão

Receber

  • A assinatura é conferida sobre o corpo bruto, antes de qualquer conversão?
  • A comparação é feita em tempo constante, e um cabeçalho malformado gera 401, e não 500?
  • O carimbo de tempo é conferido, com tolerância de poucos minutos?
  • O cabeçalho aceita várias assinaturas, para a troca de segredo?
  • O tamanho do corpo é limitado?

Processar

  • O aviso é gravado de forma durável antes da resposta 2xx?
  • O identificador da entrega é uma chave única, e um repetido recebe 2xx sem processar de novo?
  • O processamento é feito fora da rota, por um trabalhador que tenta de novo?
  • Avisos fora de ordem não sobrescrevem um estado mais novo?
  • Existe uma conciliação periódica com a API do emissor?

Enviar

  • A entrega é gravada na mesma transação da mudança que gerou o evento?
  • As novas tentativas têm intervalos crescentes, com variação aleatória, e um limite?
  • O webhook-id é o mesmo em todas as tentativas?
  • Endereços internos são recusados antes de cada entrega, e redirecionamentos não são seguidos?
  • O cliente vê o registro das entregas, pode pedir o reenvio e trocar o segredo?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar um receptor de webhooks

    Quero implementar a rota que recebe webhooks na minha aplicação.
    
    Pergunte qual linguagem e framework eu uso e qual é o provedor.
    
    Vamos em etapas:
    1. Ler o corpo bruto na rota, sem a conversão automática do framework.
    2. Conferir a assinatura e o carimbo de tempo, com comparação em tempo constante.
    3. Gravar o aviso numa tabela com o identificador como chave única, e responder 2xx.
    4. Um trabalhador que processa os avisos pendentes, com FOR UPDATE SKIP LOCKED ou uma fila.
    5. Testes que enviam entregas válidas, repetidas, com assinatura errada, com carimbo antigo e com o corpo alterado.
    
    Em cada etapa, me passe os comandos para testar localmente.
  • Implementar o envio de webhooks

    Quero implementar o envio de webhooks a partir da minha aplicação.
    
    Pergunte qual linguagem e banco eu uso.
    
    Explique, com código:
    1. A tabela de entregas, gravada na mesma transação da mudança que gera o evento.
    2. O trabalhador que pega as entregas vencidas com FOR UPDATE SKIP LOCKED.
    3. A assinatura no formato do Standard Webhooks.
    4. O tempo limite, as novas tentativas com intervalo crescente e variação aleatória, e o limite de tentativas.
    5. O filtro de endereços internos antes de cada entrega e a recusa de redirecionamentos.
    6. Testes com um destino que falha, um que redireciona e um endereço interno.
  • Revisar uma integração de webhooks

    Vou colar abaixo o código de uma integração de webhooks: a rota que recebe, o código que envia, ou os dois. Revise para produção.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. A assinatura é conferida sobre o corpo bruto, em tempo constante?
    2. O carimbo de tempo é conferido?
    3. Um cabeçalho malformado gera 401, e não um erro 500?
    4. O aviso é gravado antes da resposta, e o processamento acontece fora da rota?
    5. Entregas repetidas são reconhecidas pelo identificador?
    6. Avisos fora de ordem podem sobrescrever um estado mais novo?
    7. No envio: fila de saída na mesma transação, novas tentativas espaçadas, filtro de endereços internos, redirecionamentos recusados?
    
    Para cada problema, descreva como ele aparece em produção e mostre a correção.
    
    Código:
    [cole aqui]