Webhooks em Node.js: como receber, validar e reprocessar eventos
Webhooks em Node.js são a forma mais direta de saber que algo aconteceu fora do seu sistema: o pagamento foi aprovado, a mensagem foi entregue, o cliente respondeu, a nota fiscal foi autorizada. Em vez de perguntar a cada minuto, você publica uma URL e o provedor avisa na hora, com um POST.
A implementação ingênua cabe em cinco linhas de Express. A implementação que sobrevive à produção precisa de mais: validar que o POST veio mesmo do provedor, não processar o mesmo evento duas vezes, responder antes do timeout, guardar o que chegou e conseguir reprocessar quando algo falhar.
Este guia constrói esse endpoint de ponta a ponta, com assinatura HMAC, idempotência por event_id, fila com BullMQ, testes locais com ngrok e uma rotina de reprocessamento, tudo com código que você pode adaptar para qualquer provedor.
O que é um webhook e como ele difere de polling
Com polling, o seu sistema consulta a API do provedor em intervalos (“o pagamento 42 já foi aprovado?”) até obter a resposta. Com webhook, o fluxo inverte: você cadastra uma URL pública e o provedor faz um POST nela quando o evento ocorre. O seu endpoint vira uma API que o provedor consome.
| Polling | Webhook | |
|---|---|---|
| Latência | Depende do intervalo de consulta | Segundos após o evento |
| Carga | Muitas requisições sem novidade | Uma requisição por evento |
| Complexidade | Um job agendado | Endpoint público, segurança e idempotência |
| Quando falha | Você só atrasa | Você pode perder o evento se não tratar retries |
Um exemplo concreto: quando um sistema usa um serviço para enviar código de verificação pelo WhatsApp via API, o envio nasce com status “na fila” e passa por enviado, entregue, lido e verificado (ou falha). Esperar cada etapa por polling é desperdício; com webhook, cada mudança de status chega ao seu servidor no momento em que acontece.
O ponto que mais confunde quem começa: webhooks não substituem a API de consulta. Eles são uma notificação. Se um evento se perder, a consulta é o caminho para reconciliar, e um bom desenho usa os dois.
Criando o endpoint no Express
Em webhooks em Node.js com Express, o endpoint tem uma única responsabilidade: receber, conferir e guardar. Nada de regra de negócio aqui. Um detalhe técnico decide se a validação de assinatura vai funcionar: o HMAC é calculado sobre os bytes originais do corpo, então é preciso preservar o corpo bruto antes de o express.json() transformá-lo em objeto.
// server.js
import 'dotenv/config';
import express from 'express';
import { validarAssinatura } from './assinatura.js';
import { fila } from './fila.js';
const app = express();
// Guarda o corpo bruto: a assinatura é calculada sobre os bytes originais
app.use('/webhooks', express.json({
limit: '1mb',
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
app.post('/webhooks/pagamentos', async (req, res) => {
if (!validarAssinatura(req.rawBody, req.get('x-signature'))) {
return res.status(401).json({ erro: 'Assinatura inválida' });
}
const evento = req.body;
if (!evento?.id || !evento?.type) {
return res.status(400).json({ erro: 'Evento malformado' }); // 4xx: o provedor não deve reenviar
}
try {
await fila.add(evento.type, evento, { jobId: evento.id }); // mesmo jobId = não duplica na fila
res.sendStatus(200);
} catch (erro) {
console.error('Falha ao enfileirar', erro);
res.sendStatus(500); // 5xx: o provedor vai tentar de novo
}
});
app.listen(process.env.PORT ?? 3000, () => console.log('Webhook pronto'));
Os códigos de resposta são parte do contrato. 2xx diz “recebi, pode parar de tentar”. 4xx diz “esse evento está errado, não insista”. 5xx ou timeout dizem “tente mais tarde”. Responder 200 para um corpo que você não conseguiu guardar é a forma mais comum de perder eventos em silêncio.
Use um caminho específico por provedor (/webhooks/pagamentos, /webhooks/whatsapp), porque formatos, assinaturas e regras de retry variam entre eles e o código fica mais simples quando cada rota sabe exatamente o que esperar.
Validando a assinatura HMAC e garantindo idempotência
Uma URL pública recebe POST de qualquer pessoa. Sem validação, alguém pode enviar um “pagamento aprovado” falso e liberar um pedido. A proteção padrão é o HMAC: o provedor assina o corpo com um segredo compartilhado e envia a assinatura em um header; você recalcula e compara.
// assinatura.js
import crypto from 'node:crypto';
const SEGREDO = process.env.WEBHOOK_SECRET; // copiado do painel do provedor
export function validarAssinatura(corpoBruto, assinaturaRecebida) {
if (!SEGREDO || !corpoBruto || !assinaturaRecebida) return false;
const esperada = crypto.createHmac('sha256', SEGREDO).update(corpoBruto).digest('hex');
const a = Buffer.from(esperada, 'hex');
const b = Buffer.from(assinaturaRecebida, 'hex');
// Comparação em tempo constante: impede descobrir a assinatura byte a byte
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Confira na documentação do provedor o nome do header, o algoritmo e o que entra na assinatura. Vários incluem um timestamp (timestamp.corpo) e esperam que você rejeite eventos com mais de alguns minutos, o que bloqueia ataques de replay. O segredo vai em variável de ambiente, nunca no código; o guia de segurança de tokens com variáveis de ambiente e cofres de senhas mostra como tratá-lo no deploy.
Nem todo provedor assina. Os webhooks de WhatsApp e SMS com Node.js da APIBrasil, por exemplo, chegam como uma lista de eventos e identificam o dispositivo por header, sem HMAC. Nesse caso, a defesa é combinar HTTPS, um caminho difícil de adivinhar (um UUID na URL), a checagem desse header e, quando a decisão for sensível, uma consulta de confirmação à API antes de agir.
Idempotência: o mesmo evento pode chegar duas vezes
Provedores reenviam quando não recebem 2xx a tempo, e a rede duplica requisições. Idempotência significa que processar o mesmo evento duas vezes produz o mesmo resultado de processá-lo uma. A chave é o identificador do evento (id, event_id), guardado em uma tabela com restrição de unicidade:
// dentro do worker, antes de qualquer efeito colateral (exemplo com PostgreSQL via pg)
const resultado = await db.query(
'INSERT INTO eventos_processados (id, tipo, recebido_em) VALUES ($1, $2, now()) ON CONFLICT (id) DO NOTHING',
[evento.id, evento.type]
);
if (resultado.rowCount === 0) {
return; // já processado: sai em silêncio
}
O jobId do BullMQ evita duplicatas enquanto o job existe na fila, mas a tabela é a garantia definitiva, porque sobrevive a limpezas da fila e a reprocessamentos.

Respondendo rápido e processando em fila
Provedores esperam poucos segundos pela resposta. Se o seu endpoint liberar o pedido, enviar e-mail e atualizar o estoque antes de responder, ele vai estourar o timeout em dias de pico, e o provedor vai reenviar um evento que você já processou. A solução é separar receber de processar: o endpoint enfileira e responde; um worker faz o trabalho pesado.
// fila.js
import { Queue, Worker } from 'bullmq';
import { processarEvento } from './eventos.js';
const connection = { host: process.env.REDIS_HOST ?? '127.0.0.1', port: 6379 };
export const fila = new Queue('webhooks', {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 }, // 2s, 4s, 8s, 16s, 32s
removeOnComplete: 1000, // mantém os últimos 1000 concluídos para inspeção
removeOnFail: false, // falhas ficam guardadas para reprocessar
},
});
export const worker = new Worker('webhooks', async (job) => {
await processarEvento(job.data);
}, { connection, concurrency: 5 });
worker.on('failed', (job, erro) => {
console.error(JSON.stringify({ nivel: 'erro', jobId: job?.id, tentativa: job?.attemptsMade, msg: erro.message }));
});
// eventos.js — a regra de negócio vive aqui, longe do endpoint
export async function processarEvento(evento) {
switch (evento.type) {
case 'pagamento.aprovado':
return liberarPedido(evento.data.pedido_id);
case 'pagamento.recusado':
return notificarRecusa(evento.data.pedido_id, evento.data.motivo);
default:
console.warn('Evento ignorado:', evento.type); // tipo desconhecido não é erro
}
}
O BullMQ usa o Redis como armazenamento; para desenvolvimento, um container resolve (docker run -d -p 6379:6379 redis), e o guia prático de Docker cobre a instalação. Em produção, o worker pode rodar no mesmo processo do Express ou em um serviço separado, o que permite escalar o processamento sem mexer no endpoint.
Com essa separação, o endpoint responde em milissegundos, o retry do provedor deixa de ser um problema e as tentativas internas ficam sob o seu controle.
Testando webhooks em Node.js localmente com ngrok
O provedor precisa alcançar o seu servidor, e localhost não existe na internet. O ngrok cria um túnel HTTPS público para a porta local, o que permite testar com eventos reais sem fazer deploy:
node server.js # Express na porta 3000
ngrok http 3000 # gera uma URL https://xxxx.ngrok-free.app apontando para a porta 3000
Cadastre a URL gerada, com o caminho da rota, no painel do provedor e dispare um evento de teste (um pagamento em sandbox, uma mensagem para o número conectado). O painel local do ngrok, em http://127.0.0.1:4040, mostra cada requisição recebida com headers e corpo, e permite reenviá-la com um clique, o que é a maneira mais rápida de depurar sem gerar um evento novo a cada tentativa.
Para testar a assinatura sem depender do provedor, gere o HMAC localmente com o mesmo segredo e envie com o curl:
export WEBHOOK_SECRET=teste-local
BODY='{"id":"evt_123","type":"pagamento.aprovado","data":{"pedido_id":42}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -i -X POST http://localhost:3000/webhooks/pagamentos \
-H "Content-Type: application/json" \
-H "X-Signature: $SIG" \
-d "$BODY"
Envie o mesmo comando duas vezes: a primeira deve processar, a segunda deve ser ignorada pela idempotência. Altere um caractere do corpo sem recalcular a assinatura e confira o 401. Esses três cenários viram testes automatizados com node:test e supertest em poucos minutos.
Retries, logs e reprocessamento
Há duas camadas de retry, e é importante não confundi-las. A do provedor acontece quando o seu endpoint não responde 2xx: ele reenvia o evento em intervalos crescentes, por um período que varia conforme o serviço. A sua acontece dentro da fila, quando o worker falha: o BullMQ repete com backoff até esgotar as tentativas e então marca o job como falho.
Para que nada se perca entre essas camadas, guarde o evento bruto antes de qualquer processamento. Uma tabela simples basta:
CREATE TABLE webhook_eventos (
id TEXT PRIMARY KEY, -- event_id do provedor
tipo TEXT NOT NULL,
payload JSONB NOT NULL,
recebido_em TIMESTAMPTZ NOT NULL DEFAULT now(),
processado_em TIMESTAMPTZ,
erro TEXT
);
Com o payload guardado, reprocessar vira uma operação rotineira: um script lê o evento pelo id, limpa o estado de processamento e enfileira de novo. Use um jobId diferente do original para o BullMQ não considerar o job repetido.
// reprocessar.js — uso: node reprocessar.js evt_123
import { fila } from './fila.js';
import { db } from './db.js';
const id = process.argv[2];
const { rows } = await db.query('SELECT tipo, payload FROM webhook_eventos WHERE id = $1', [id]);
if (!rows.length) throw new Error(`Evento ${id} não encontrado`);
await db.query('DELETE FROM eventos_processados WHERE id = $1', [id]); // libera a idempotência
await fila.add(rows[0].tipo, rows[0].payload, { jobId: `${id}:reproc:${Date.now()}` });
console.log('Reenfileirado', id);
Os logs fecham o ciclo. Registre em JSON estruturado o id do evento, o tipo, o status da resposta, a tentativa e a duração, e nunca o segredo nem o payload completo quando ele contiver dados pessoais. Um alerta simples (jobs falhos acima de zero, ou nenhum evento recebido em um período em que normalmente há) avisa antes que o cliente ligue perguntando pelo pedido.
Perguntas frequentes
Preciso responder 200 antes de processar?
Responda assim que o evento estiver guardado ou enfileirado, o que leva milissegundos. Processar antes de responder funciona enquanto o trabalho é leve, mas quebra no primeiro pico. O timeout dos provedores costuma ser de poucos segundos.
E se o provedor não assina o webhook?
Combine HTTPS, uma URL com segmento secreto, a checagem de qualquer header de identificação que ele envie e uma lista de IPs permitidos quando existir. Para decisões sensíveis, confirme o evento consultando a API do provedor pelo id antes de agir.
Como testar sem uma URL pública?
Para testes de lógica, envie requisições com o curl ou com o supertest, gerando a assinatura localmente. Para testes de integração com o provedor real, use o ngrok e o painel de inspeção dele para reenviar requisições enquanto ajusta o código.
O que acontece quando o mesmo evento chega duas vezes?
Se o seu endpoint for idempotente, nada: a segunda cópia é descartada ao bater na restrição de unicidade do id. Sem isso, você libera um pedido duas vezes ou envia dois e-mails. Idempotência é a primeira coisa a implementar depois da assinatura.
Webhook substitui o polling por completo?
Não. O webhook é o caminho principal, mas um job de reconciliação (consultar na API os itens pendentes há mais de X minutos) pega o que se perdeu por falha de rede ou por um bug seu. Os dois juntos dão a confiabilidade que nenhum deles tem sozinho.
Próximos passos
Com assinatura, idempotência, fila, logs e reprocessamento, webhooks em Node.js deixam de ser um ponto frágil e viram a integração mais estável do sistema.
Aplique o mesmo esqueleto a cada provedor novo, mudando apenas a validação e o switch de eventos, escreva os três testes de assinatura, duplicidade e corpo inválido, e adicione o job de reconciliação antes de ir para produção. O restante é operação: olhar os jobs falhos de vez em quando e reprocessar com um comando.