Webhooks idempotentes em Node/Express: deduplicação, verificação e retries
Por John Pires · 10 de março de 2026

Webhooks idempotentes em Node/Express: deduplicação, verificação e retries
Contexto e motivacao
Webhooks são fundamentais para integrar serviços em tempo real: uma plataforma envia um evento para sua API e você reage. Mas webhooks trazem desafios práticos: retransmissões automáticas do remetente, duplicação de eventos, assinaturas equivocadas e a necessidade de garantir processamento exatamente uma vez (ou, na prática, idempotência). Ignorar isso resulta em cobranças duplicadas, envios de e-mail repetidos, filas inchadas e bugs difíceis de reproduzir.
Este artigo foca em uma pilha comum: Node.js + Express, Redis para coordenação e PostgreSQL (ou outro banco relacional) para persistência. A intenção é oferecer padrões e código prático para lidar com verificação, deduplicação e retries de forma segura e operável.
Alvo: desenvolvedores com 1–3 anos de experiência. Pressupõe familiaridade com Express, Promises/async-await e noções básicas de Redis/SQL. Objetivo: você sair com um conjunto de técnicas testáveis que reduzem falhas e facilitam a observabilidade.
Por que idempotência importa
Idempotência evita efeitos colaterais causados por processamento repetido do mesmo evento. No contexto de webhooks, o emissor pode reenviar notificações quando não recebe uma confirmação 200 em tempo, ou por falha de rede. Mesmo se seu processamento falhar parcialmente (ex.: DB gravado, mas fila não enfileirada), mecanismos idempotentes ajudam a restaurar um estado consistente.
Casos práticos que levam a bugs
Exemplos reais: uma cobrança processada duas vezes por reenvio de webhook; criação duplicada de usuários; atualizações intermitentes em sistemas legados. Esses problemas contaminam dados e geram custo humano para correção.
Como funciona por baixo dos panos
Idempotência em webhooks envolve três camadas: autenticação/assinatura, detecção de duplicados e persistência atômica. Cada camada reduz a superfície de erro e melhora a confiabilidade.
Fluxo básico:
- Receber requisição raw e verificar assinatura HMAC (ou JWT) enviada pelo provedor.
- Extrair um identificador único do evento (event_id/receipt_id) — essencial.
- Registrar tentativa de processamento em uma store rápida (Redis) com TTL curto; se já existir, rejeitar como duplicado.
- Executar lógica de negócio e persistir com constraint/transaction para garantir atomicidade.
- Se falhar, colocar evento em uma fila de retry com backoff e limites de tentativas.
Assinaturas e verificações
Provedores como Stripe, GitHub e outros oferecem assinatura HMAC usando um segredo compartilhado. A verificação é feita sobre o corpo raw, não sobre o JSON parseado — portanto seu servidor deve expor o raw body para calcular HMAC corretamente.
Deduplicação: Redis e constraints
Redis é ideal para deduplicação rápida: use SET com NX (somente cria se não existe) e expiração (TTL). Complementarmente, use uma constraint de unicidade no banco (ex.: event_id unique) para cobrir falhas nos checks em Redis — defend-in-depth.
Implementacao passo a passo
Vamos construir um conjunto mínimo de componentes: middleware de verificação HMAC, handler de webhook com deduplicação via Redis + persistência PostgreSQL e um worker de retry baseado em um sorted set no Redis.
1) Middleware: recebendo raw body e verificando HMAC
Contexto: muitos desenvolvedores usam express.json() que perde o raw body necessário para checar a assinatura. A alternativa é usar express.raw() para rotas de webhook ou custom body parser que guarda o raw.
O código abaixo implementa um middleware que valida HMAC-SHA256 com timing-safe compare. Substitua process.env.WEBHOOK_SECRET pelo segredo do provedor.
const express = require('express');
const crypto = require('crypto');
// Middleware para expor raw body e validar assinatura HMAC SHA256
function rawBodyMiddleware(req, res, next) {
// Apenas para application/json (mude conforme necessário)
express.raw({ type: 'application/json' })(req, res, () => next());
}
function verifySignature(secret) {
return (req, res, next) => {
try {
const signatureHeader = req.get('x-signature') || req.get('x-hub-signature-256');
if (!signatureHeader) return res.status(400).send('missing signature');
const raw = req.body;
const computed = crypto.createHmac('sha256', secret).update(raw).digest('hex');
// header pode vir com prefixo 'sha256='
const incoming = signatureHeader.replace(/^sha256=/, '');
const bufIncoming = Buffer.from(incoming, 'hex');
const bufComputed = Buffer.from(computed, 'hex');
if (bufIncoming.length !== bufComputed.length) return res.status(400).send('invalid signature');
if (!crypto.timingSafeEqual(bufIncoming, bufComputed)) return res.status(400).send('invalid signature');
// parse JSON só depois de verificar
req.parsedBody = JSON.parse(raw.toString('utf8'));
next();
} catch (err) {
next(err);
}
};
}
module.exports = { rawBodyMiddleware, verifySignature };
O que acontece: quando aplicada, a rota terá req.parsedBody já validado. Se a assinatura falhar, retornamos 400 imediatamente, evitando processamento desnecessário.
2) Handler com deduplicação em Redis e persistência atômica
Contexto: aqui implementamos a lógica de deduplicação usando Redis SET NX + TTL e a persistência em PostgreSQL com uma constraint de unicidade em event_id. O padrão é: tentar marcar o evento como "em processamento" em Redis; se já existir, retornar 200 (ou 409 conforme sua política) e parar.
Pré-requisitos: npm install redis pg
const { createClient } = require('redis');
const { Pool } = require('pg');
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect();
const pg = new Pool({ connectionString: process.env.DATABASE_URL });
// Handler de webhook: dedupe + process + persist
async function handleWebhook(req, res) {
const event = req.parsedBody;
const eventId = event.id || event.event_id || event.payload?.id;
if (!eventId) return res.status(400).send('missing event id');
const dedupeKey = `webhook:processing:${eventId}`;
// tenta criar a chave por 30 segundos (NX)
const created = await redis.set(dedupeKey, '1', { NX: true, PX: 30_000 });
if (!created) {
// já em processamento/foi processado recentemente
return res.status(200).send('duplicate');
}
try {
// exemplo de operação que deve ser idempotente: inserir evento com unique(event_id)
const client = await pg.connect();
try {
await client.query('BEGIN');
const insert = 'INSERT INTO webhooks(event_id, payload, processed_at) VALUES($1, $2, now())';
await client.query(insert, [eventId, event]);
// lógica de negocio: enfileirar, chamar APIs, etc.
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
res.status(200).send('ok');
} catch (err) {
// se deu erro, remover a chave para permitir future retries
await redis.del(dedupeKey);
// opcional: enviar para fila de retry
await enqueueRetry(eventId, event);
res.status(500).send('processing failed');
}
}
module.exports = { handleWebhook };
O que acontece: o set NX protege contra duplicatas concorrentes; a transação no PostgreSQL garante que a gravação do evento é atômica. Se a inserção falhar por quebra de unicidade, capture e trate como duplicado.
3) Worker de retries com backoff baseado em Redis sorted set
Contexto: provedores reencaminham webhooks, mas seu processamento interno também pode falhar. Em vez de depender exclusivamente do remetente, mantenha uma fila de retry controlada para retentar operações com backoff exponencial e limites de tentativas.
Estratégia: usar um sorted set (ZSET) onde o score é o timestamp do próximo attempt. Um worker periodicamente busca eventos cujo score <= now e tenta processar novamente.
const RETRY_KEY = 'webhook:retry:zset';
async function enqueueRetry(eventId, event, attempts = 1) {
const maxAttempts = 5;
if (attempts > maxAttempts) return; // drop or move to dead-letter
const backoffMs = Math.min(60_000, 1000 * Math.pow(2, attempts - 1));
const nextAttempt = Date.now() + backoffMs;
const payload = JSON.stringify({ eventId, event, attempts });
await redis.zAdd(RETRY_KEY, { score: nextAttempt, value: payload });
}
// worker simples: rodar a cada N segundos
async function retryWorker() {
while (true) {
const now = Date.now();
// pega até 50 itens prontos
const items = await redis.zRangeByScore(RETRY_KEY, 0, now, { LIMIT: { offset: 0, count: 50 } });
if (items.length === 0) {
await new Promise(r => setTimeout(r, 2000));
continue;
}
for (const raw of items) {
const { eventId, event, attempts } = JSON.parse(raw);
// tentar processar de novo
try {
// marque como processing usando mesmo dedupeKey do handler
const dedupeKey = `webhook:processing:${eventId}`;
const created = await redis.set(dedupeKey, '1', { NX: true, PX: 30_000 });
if (!created) {
// outro worker/processo já está lidando
await redis.zRem(RETRY_KEY, raw);
continue;
}
// aqui chamaria a mesma lógica de processamento (DB insert, etc.)
// simulateProcessing(event)
// se ok:
await redis.zRem(RETRY_KEY, raw);
await redis.del(dedupeKey);
} catch (err) {
// aumentar attempts e re-enfileirar com backoff
await redis.zRem(RETRY_KEY, raw);
await enqueueRetry(eventId, event, attempts + 1);
}
}
}
}
module.exports = { enqueueRetry, retryWorker };
O que acontece: itens com nextAttempt <= now são processados pelo worker. Em falha, re-enfileiramos com backoff exponencial, evitando spams e controlando carga. Uma política de dead-letter (mover para tabela de erros) é recomendada ao exceder tentativas.
Patterns e boas praticas
Segue uma lista consolidada de padrões que você deve aplicar em produção:
- Raw body para verificação: sempre calcule HMAC sobre o raw body. Use express.raw() apenas para rotas de webhook para não quebrar outras rotas.
- Identificador único: o provedor deve fornecer um event_id. Sem isso, gere um hash do payload, mas entenda que hashes colidem teoricamente.
- Deduplicação rápida: Redis SET NX + TTL protege contra reenvios simultâneos.
- Persistência atômica: use constraints únicas e transactions no DB para garantir 1x persistido.
- Retries controlados: não dependa apenas do remetente. Tenha uma fila de retry com backoff e dead-letter.
- Observability: logue eventos, metas de latência e taxas de retry; exponha métricas para alertas.
Idempotency key vs event id
Event ID vem do provedor e é a melhor escolha. Em cenários sem event_id, você pode aceitar um header personalizado Idempotency-Key ou criar um hash determinístico do payload e de campos relevantes (timestamp normalizado, tipo do evento). Evite chave única baseada em timestamp puro.
Transactional outbox
Quando seu processamento gera efeitos em outros sistemas (ex.: publicar em fila), use o padrão transactional outbox: grave a intenção (mensagem) junto com a transação do banco e tenha um worker que lê a outbox e publica de forma confiável. Isso evita inconsistências entre DB e filas.
Regra prática: trate Redis como cache/coordenação, não como fonte de verdade. A fonte de verdade deve ser seu banco relacional ou sistema com garantia ACID.
Armadilhas comuns e como evitar
Existem erros recorrentes ao implementar webhooks idempotentes. Vou listar os mais frequentes e como mitigá-los.
1) Verificar assinatura depois de parsear o body
Erro: usar express.json() e checar assinatura no JSON parseado. Isso quebra HMAC porque a string usada para cálculo difere. Solução: capture raw bytes e só JSON.parse após validação.
2) Confiar só em Redis para persistência
Erro: usar apenas Redis SETNX sem nenhuma garantia no banco. Se o processo morrer após setNX e antes de gravar no DB, você perde o evento. Solução: use Redis como semáforo temporário + persistência durável com unique constraint. Se falhar, remova a chave para permitir retry.
3) Não tratar replays intencionais
Alguns provedores reenviam completamente o mesmo payload muitas vezes. Sua política deve ser: idempotente é aceitável — sempre retornar 200 para duplicatas se o efeito já foi aplicado. Evite duplicar trabalho em background.
4) Fazer retries agressivos que saturam serviços downstream
Erro: reenfileirar imediatamente sem backoff. Resultado: efeito negativo em serviços dependentes. Solução: usar backoff exponencial com jitter, limitar tentativas e implementar dead-letter para análise manual.
5) Ausência de observabilidade
Erro: não registrar métricas de latência, taxa de duplicatas, retries e falhas permanentes. Sem isso, incidents ficam sem diagnóstico. Envie métricas para Prometheus/Datadog e alerte quando retry rates aumentam.
Conclusao e checklist prático
Webhooks são confiáveis somente quando a arquitetura atende três pontos: verificação segura, deduplicação rápida e retries controlados. A combinação de HMAC sobre raw body, Redis para coordenação e banco com constraints fornece uma base sólida.
Checklist mínimo para colocar em produção:
- Rotas de webhook usam express.raw() e middleware de assinatura.
- Extrair e exigir um event_id; documente para provedores e clientes.
- Deduplicate com Redis SET NX + TTL e remova a chave se falhar.
- Persistência com transaction + unique constraint em event_id.
- Fila de retry com backoff exponencial, jitter e limite de tentativas.
- Dead-letter para análise manual e dashboards com métricas chave.
Implemente as peças mostradas aqui, adapte para sua infraestrutura (por exemplo, use Bull/BullMQ ou Sidekiq analog na sua stack se preferir). Priorize defesa em camadas: autenticação, coordenação e persistência. Isso reduzirá incidentes, facilitará o diagnóstico e garantirá que seus webhooks não causem efeitos colaterais indesejados.
Se quiser, posso gerar um repositório de exemplo completo com Docker Compose (Node, Redis, Postgres) e testes de integração que simulam reenvios e falhas. Indique seu interesse e eu preparo os artefatos.
Assuntos: Node.js, express, webhooks, idempotencia, NoSQL, Segurança
Leia também
Worker Threads: sandbox seguro para executar código de usuário no Node.js
Implementar sandbox seguro para executar código de usuário em Node.js: use worker threads + vm, limite memória com execArgv, aplique timeout externo, evite…
Abortar pipelines de Streams em Node.js: AbortController, timeouts e limpeza de Workers
Guia prático para abortar pipelines de streams em Node.js: uso de AbortController, patterns de timeout, e limpeza segura de Worker Threads para evitar…
Autenticação com Passkeys (WebAuthn) em SPA e API: arquitetura e fallback JWT
Arquitetura prática para usar Passkeys/WebAuthn em SPAs com API: pattern attempt-first WebAuthn com fallback JWT seguro. Padrões, exemplos de código, testes…
Rotação segura de refresh tokens JWT: detectar reuse e revogar sessões
Padrão prático para rotacionar refresh tokens JWT com detecção de reuse: como modelar sessões no Redis, atualizar currentJti de forma atômica, revogar sessões…
Gerar thumbnails e checksums de uploads usando Streams e Worker Threads no Node.js
Como gerar thumbnails e checksums de uploads sem sobrecarregar o Node: padrões com Streams (PassThrough/pipeline) e offload para Worker Threads. Boas…
Processar uploads CSV grandes com Streams, Worker Threads e progresso via SSE em Node.js
Processar CSV grandes sem travar o Node: mostro o jeito errado (ler tudo) e a solução correta com streams, worker threads e SSE para progresso em tempo real…