Webhooks são uma forma eficiente de integrar sistemas sem depender de consultas repetidas. Em vez de uma aplicação perguntar a cada minuto se um pagamento foi aprovado, o provedor envia uma requisição HTTP assim que o evento acontece. Esse modelo reduz latência e consumo de recursos, mas também cria desafios importantes de segurança, confiabilidade e observabilidade.
Neste guia, você vai aprender a implementar webhooks seguros com Node.js, validar assinaturas HMAC, evitar processamento duplicado, responder rapidamente, organizar retries e usar filas para tarefas demoradas. O objetivo é construir um endpoint que funcione bem em desenvolvimento e continue previsível quando receber eventos reais em produção.
O que é um webhook?
Um webhook é uma requisição HTTP enviada automaticamente por um sistema para outro quando um evento ocorre. Plataformas de pagamento, serviços de e-mail, repositórios de código, ferramentas de automação e sistemas de entrega usam esse padrão para informar mudanças em tempo quase real.
Normalmente, o provedor envia uma requisição POST com um corpo JSON. O receptor valida a origem, interpreta o tipo de evento e executa uma ação. Se você ainda está revisando os fundamentos, consulte o artigo sobre o que é Node.js e o guia de como criar uma API com Node.js.
Principais riscos
Um endpoint público pode receber requisições falsas, repetidas ou malformadas. Os riscos mais comuns são:
- aceitar eventos enviados por um atacante;
- processar o mesmo evento várias vezes;
- confiar em campos sem validação;
- demorar demais para responder e provocar retries;
- registrar segredos ou dados sensíveis nos logs;
- executar ações irreversíveis antes de confirmar a autenticidade.
A segurança de um webhook não depende apenas de HTTPS. O TLS protege o transporte, mas o servidor ainda precisa verificar se a mensagem realmente veio do provedor esperado.
Preparando o projeto
Crie um projeto simples com Express:
mkdir webhooks-node
cd webhooks-node
npm init -y
npm install express
No package.json, adicione "type": "module". Depois, crie src/server.js:
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/provider", (req, res) => {
console.log(req.body);
res.sendStatus(204);
});
app.listen(3000, () => {
console.log("Servidor em http://localhost:3000");
});
Esse exemplo recebe eventos, mas ainda não é seguro. O próximo passo é preservar o corpo original para validar a assinatura.
Por que o corpo bruto é importante?
Muitos provedores calculam a assinatura sobre os bytes exatos enviados. Se o Express converter o JSON em objeto e depois você serializar novamente, espaços, ordem de campos ou codificação podem mudar. Por isso, a validação deve usar o corpo bruto.
app.use(express.json({
verify: (req, res, buffer) => {
req.rawBody = buffer;
}
}));
Agora req.rawBody contém os bytes usados na assinatura. Leia a documentação do provedor, porque nomes de cabeçalhos, formato e algoritmo variam.
Validando assinatura HMAC
Uma estratégia comum usa HMAC com SHA-256. O provedor combina o corpo da requisição com um segredo compartilhado e envia o resultado em um cabeçalho. O servidor repete o cálculo e compara os valores.
import crypto from "node:crypto";
function verifySignature(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expectedBuffer = Buffer.from(expected, "utf8");
const receivedBuffer = Buffer.from(signature ?? "", "utf8");
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(
expectedBuffer,
receivedBuffer
);
}
Use timingSafeEqual para reduzir diferenças de tempo durante a comparação. Guarde o segredo em variável de ambiente e nunca o inclua no repositório.
app.post("/webhooks/provider", (req, res) => {
const signature = req.get("x-webhook-signature");
const secret = process.env.WEBHOOK_SECRET;
if (!secret || !verifySignature(req.rawBody, signature, secret)) {
return res.status(401).json({ message: "Assinatura inválida" });
}
res.sendStatus(204);
});
A documentação de Crypto no Node.js explica HMAC e comparações seguras. A documentação oficial do Express também detalha middlewares e tratamento de requisições.
Protegendo contra replay attacks
Mesmo uma mensagem legítima pode ser capturada e reenviada. Para reduzir esse risco, provedores costumam incluir um timestamp. O servidor deve rejeitar mensagens muito antigas.
function isRecent(timestamp, toleranceSeconds = 300) {
const eventTime = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
return Number.isFinite(eventTime)
&& Math.abs(now - eventTime) <= toleranceSeconds;
}
Quando o timestamp faz parte do cálculo da assinatura, um atacante não consegue alterá-lo sem invalidar o HMAC. Use uma tolerância curta, considerando possíveis diferenças de relógio.
Idempotência
Provedores repetem webhooks quando não recebem resposta ou quando há falha de rede. Portanto, o mesmo evento pode chegar mais de uma vez. Seu processamento deve ser idempotente: executar duas vezes deve produzir o mesmo resultado que executar uma vez.
Use um identificador único do evento e registre-o antes de aplicar ações irreversíveis:
async function processEvent(event) {
const exists = await eventRepository.exists(event.id);
if (exists) {
return { duplicated: true };
}
await eventRepository.create({
id: event.id,
type: event.type,
status: "processing"
});
try {
await handleEvent(event);
await eventRepository.markCompleted(event.id);
} catch (error) {
await eventRepository.markFailed(event.id);
throw error;
}
}
Crie uma constraint única no banco para o ID do evento. Assim, duas instâncias concorrentes não processam o mesmo webhook simultaneamente.
Responda rapidamente
O endpoint não deve executar tarefas demoradas antes de responder. Enviar e-mail, gerar relatório ou chamar várias APIs pode ultrapassar o timeout do provedor. O padrão mais seguro é validar, armazenar e colocar o evento em uma fila.
app.post("/webhooks/provider", async (req, res) => {
if (!verifyProviderRequest(req)) {
return res.sendStatus(401);
}
await queue.add("provider-event", {
eventId: req.body.id,
payload: req.body
});
return res.sendStatus(202);
});
Um worker separado processa a tarefa. Se você quiser revisar filas e armazenamento temporário, leia o guia sobre Redis com Node.js para cache e filas.
Validando o payload
Assinatura válida não significa payload correto. Verifique campos obrigatórios, tipos e valores permitidos antes de usar os dados.
function validateEvent(payload) {
if (!payload || typeof payload !== "object") {
return false;
}
if (typeof payload.id !== "string") {
return false;
}
if (![
"payment.approved",
"payment.failed"
].includes(payload.type)) {
return false;
}
return true;
}
Bibliotecas de schema podem deixar a validação mais clara. O importante é nunca confiar em valores recebidos apenas porque vieram de um provedor conhecido.
Retries e dead letter queue
Falhas temporárias devem ser repetidas com atraso crescente. Uma política comum usa backoff exponencial. Depois de um número limitado de tentativas, envie a tarefa para uma fila de erros, conhecida como dead letter queue.
- defina máximo de tentativas;
- registre motivo e horário da falha;
- não repita indefinidamente erros de validação;
- permita reprocessamento manual;
- monitore crescimento da fila de erros.
Retentativas sem controle podem amplificar uma indisponibilidade. Diferencie falhas temporárias, como timeout, de falhas permanentes, como evento inválido.
Logs e observabilidade
Registre o ID do evento, tipo, duração, resultado e número da tentativa. Evite armazenar segredo, assinatura completa, tokens, dados de cartão ou informações pessoais desnecessárias.
logger.info({
eventId: event.id,
type: event.type,
attempt,
durationMs,
result: "completed"
}, "Webhook processado");
Crie métricas para eventos recebidos, rejeitados, duplicados, concluídos e falhos. Alertas devem detectar aumento de assinaturas inválidas ou filas acumuladas. O artigo sobre OpenTelemetry no Node.js ajuda a organizar traces e métricas.
Testando localmente
Durante o desenvolvimento, use uma ferramenta de túnel para expor temporariamente o servidor local. Configure um segredo exclusivo de teste e envie eventos fictícios. Teste pelo menos estes cenários:
- assinatura correta;
- assinatura incorreta;
- timestamp expirado;
- evento duplicado;
- payload incompleto;
- falha temporária no worker;
- concorrência com o mesmo ID.
Não reutilize segredos de produção no ambiente local. Também confirme que o endpoint responde dentro do tempo esperado.
Boas práticas para produção
- Use HTTPS: nunca aceite eventos sensíveis por HTTP aberto.
- Rotacione segredos: planeje uma janela em que dois segredos possam ser aceitos.
- Restrinja tamanho: limite o corpo para evitar consumo excessivo de memória.
- Use idempotência: toda ação financeira ou irreversível precisa tolerar duplicatas.
- Separe recebimento e processamento: use uma fila para tarefas demoradas.
- Monitore falhas: eventos perdidos precisam gerar alerta.
- Documente versões: trate mudanças de schema de forma explícita.
- Proteja logs: aplique retenção e controle de acesso.
Conclusão
Criar webhooks seguros com Node.js exige mais do que abrir uma rota POST. O endpoint precisa verificar assinatura, limitar replay, validar o payload, impedir duplicidade e responder rapidamente. A execução pesada deve ocorrer em uma fila, com retries controlados e visibilidade sobre falhas.
Comece implementando o corpo bruto e a verificação HMAC. Depois, adicione idempotência no banco, fila, métricas e testes de concorrência. Com essas camadas, os webhooks deixam de ser uma integração frágil e passam a funcionar como uma parte confiável da arquitetura.




