Destino Dev

Blog

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:

  1. 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.
  2. Identificador único: o provedor deve fornecer um event_id. Sem isso, gere um hash do payload, mas entenda que hashes colidem teoricamente.
  3. Deduplicação rápida: Redis SET NX + TTL protege contra reenvios simultâneos.
  4. Persistência atômica: use constraints únicas e transactions no DB para garantir 1x persistido.
  5. Retries controlados: não dependa apenas do remetente. Tenha uma fila de retry com backoff e dead-letter.
  6. 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