EmailCheckerEmailChecker

Webhooks de validação — arquiteturas e padrões

Construa consumers de webhook que nunca perdem eventos e nunca processam duplicatas: HMAC-SHA256, idempotência por event_id, fila antes da lógica e dead letter queue com exemplos reais em Node.

Por João Costa (Engenharia)·Publicado em 09/06/2026·9 min de leitura

Quando você integra um provedor externo para receber notificações — resultado de validação de e-mail, pagamento confirmado, status de entrega — a decisão mais importante não é qual biblioteca usar. É decidir como o seu servidor vai receber, verificar e processar esses eventos de forma confiável. Este post cobre os padrões que uso no EmailChecker e que funcionam com qualquer provider que emita webhooks.


Por que webhook e não polling

Polling é a abordagem óbvia: a cada N segundos você pergunta "tem novidade?". Funciona, mas tem custo real. Você faz requisições quando não há nada para buscar, aumenta latência (o intervalo de polling) e consome cota de API desnecessariamente.

Webhook inverte o controle: o provider te notifica assim que o evento acontece. Latência cai para milissegundos. Sem requisições em vazio. O servidor responde ao mundo em vez de interrogá-lo.

O custo é que agora você precisa expor um endpoint público, validar a autenticidade da chamada e garantir que vai processar sem perder nada — mesmo que o seu worker esteja sobrecarregado ou reiniciando quando o provider bate na porta. Esses são exatamente os problemas que os padrões abaixo resolvem.


Anatomia de um webhook bem desenhado

Um webhook maduro (veja webhooks.fyi para um levantamento completo de providers) chega com pelo menos:

  • Header de assinatura — normalmente X-Signature-SHA256 ou X-Hub-Signature-256. Contém HMAC do body com a chave secreta compartilhada.
  • Header de timestampX-Timestamp ou similar, em epoch seconds. Permite rejeitar replays antigos.
  • event_id no body — UUID único por evento, gerado pelo provider. Fundamental para idempotência.
  • event_type — string que identifica o que aconteceu (email.validation.completed, payment.succeeded).
  • data — payload específico do evento.

Exemplo de body real de um webhook de validação:

{
  "event_id": "evt_01HX9K2P4TMQR3FVBG7N5C8WD",
  "event_type": "email.validation.completed",
  "created_at": 1748390400,
  "data": {
    "email": "joao@empresa.com.br",
    "status": "valid",
    "score": 0.97,
    "checks": {
      "mx": true,
      "smtp": true,
      "disposable": false
    }
  }
}

Padrao 1 — Validacao de assinatura HMAC-SHA256

O primeiro passo é verificar que o payload veio de quem diz ser. Sem isso qualquer pessoa pode postar no seu endpoint e injetar eventos falsos.

O mecanismo padrão usa HMAC-SHA256: o provider assina o body bruto com um segredo compartilhado. Você recomputa o HMAC e compara. O detalhe critico: use crypto.timingSafeEqual em vez de ===. Comparacao com === curto-circuita na primeira diferença — isso vaza informacao de tempo que pode ser explorada para forjar assinaturas. O Stripe documenta exatamente esse cuidado em stripe.com/docs/webhooks/signatures.

Outro detalhe: você precisa do body bruto (Buffer), antes de qualquer parse. Middlewares como express.json() consomem o stream. Configure express.raw() especificamente para a rota de webhook.

// src/webhooks/middleware/verifySignature.js
const crypto = require('crypto');

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // nunca em codigo
const MAX_TIMESTAMP_AGE_SECONDS = 300; // 5 minutos

function verifySignature(req, res, next) {
  const signature = req.headers['x-signature-sha256'];
  const timestampHeader = req.headers['x-timestamp'];

  if (!signature || !timestampHeader) {
    return res.status(400).json({ error: 'Missing signature headers' });
  }

  // Rejeita replays com mais de 5 minutos
  const timestamp = parseInt(timestampHeader, 10);
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > MAX_TIMESTAMP_AGE_SECONDS) {
    return res.status(400).json({ error: 'Timestamp out of tolerance' });
  }

  // Assina timestamp + body (mesmo esquema que o provider)
  const signedPayload = `${timestampHeader}.${req.body.toString('utf8')}`;
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('hex');

  const expectedBuffer = Buffer.from(`sha256=${expected}`, 'utf8');
  const receivedBuffer = Buffer.from(signature, 'utf8');

  // Buffers precisam ter o mesmo comprimento para timingSafeEqual
  if (expectedBuffer.length !== receivedBuffer.length) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  if (!crypto.timingSafeEqual(expectedBuffer, receivedBuffer)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Disponibiliza o body parseado para os handlers seguintes
  req.webhookBody = JSON.parse(req.body.toString('utf8'));
  next();
}

module.exports = { verifySignature };

Padrao 2 — Idempotencia via event_id

At-least-once delivery é o contrato de praticamente todo provider sério: se o seu endpoint responder com erro ou demorar demais, o evento vai ser reenviado. Isso significa que você vai receber duplicatas. O contrato do seu consumer deve ser: processar o mesmo event_id mais de uma vez tem o mesmo efeito que processar uma vez.

A implementacao mais simples é uma tabela de eventos processados com INSERT ON CONFLICT DO NOTHING. Se a linha já existe, o insert é ignorado e você retorna 200 sem reprocessar.

// src/webhooks/middleware/deduplicateEvent.js
const { db } = require('../../db'); // instancia Drizzle/pg/knex

async function deduplicateEvent(req, res, next) {
  const { event_id, event_type, created_at } = req.webhookBody;

  if (!event_id) {
    return res.status(400).json({ error: 'Missing event_id' });
  }

  try {
    // PostgreSQL: INSERT ... ON CONFLICT DO NOTHING retorna rowCount = 0 se duplicado
    const result = await db.query(
      `INSERT INTO webhook_events (event_id, event_type, created_at, received_at)
       VALUES ($1, $2, to_timestamp($3), NOW())
       ON CONFLICT (event_id) DO NOTHING`,
      [event_id, event_type, created_at]
    );

    if (result.rowCount === 0) {
      // Duplicata: ja processamos, retorna 200 sem reprocessar
      console.log({ event_id, msg: 'duplicate event ignored' });
      return res.status(200).json({ status: 'duplicate' });
    }

    next();
  } catch (err) {
    console.error({ event_id, err: err.message });
    return res.status(500).json({ error: 'Deduplication check failed' });
  }
}

module.exports = { deduplicateEvent };

O schema minimo da tabela:

CREATE TABLE webhook_events (
  event_id    TEXT PRIMARY KEY,
  event_type  TEXT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  status      TEXT NOT NULL DEFAULT 'received'
);

Padrao 3 — Queue antes de processar

O endpoint de webhook deve ser o mais rapido e dumb possivel. Valida assinatura, deduplica, coloca na fila, responde 200. Pronto. A logica de negocio — atualizar banco, enviar email, chamar terceiros — vai no worker que consome a fila.

Por que? Porque o provider tem um timeout. Se o seu processamento leva 8 segundos e o timeout do provider e 5, ele vai marcar a entrega como falha e reenviar. Com fila, o endpoint responde em menos de 100ms sempre.

// src/webhooks/queue.js — usando ioredis + Bull (ou BullMQ)
const Queue = require('bull');

const webhookQueue = new Queue('webhook-processing', {
  redis: {
    host: process.env.REDIS_HOST || '127.0.0.1',
    port: parseInt(process.env.REDIS_PORT || '6379', 10),
    password: process.env.REDIS_PASSWORD,
  },
  defaultJobOptions: {
    attempts: 5,
    backoff: {
      type: 'exponential',
      delay: 1000, // 1s, 2s, 4s, 8s, 16s
    },
    removeOnComplete: 100,  // mantem ultimos 100 jobs concluidos
    removeOnFail: false,    // falhas ficam para analise (DLQ implicita)
  },
});

module.exports = { webhookQueue };

Padrao 4 — Retry policy e dead letter queue

Falhas acontecem: banco fora, servico externo lento, bug que voce nao viu. A fila precisa de politica de retry com backoff exponencial para nao martelar um servico ja degradado.

Quando todos os attempts se esgotam o job cai em "failed". Trate isso como sua dead letter queue — monitore, alerte, e tenha um mecanismo para replay manual ou automatico apos corrigir o problema raiz.

// src/webhooks/worker.js
const { webhookQueue } = require('./queue');
const { processValidationCompleted } = require('./handlers/validationCompleted');
const { processPaymentSucceeded } = require('./handlers/paymentSucceeded');

const handlers = {
  'email.validation.completed': processValidationCompleted,
  'payment.succeeded': processPaymentSucceeded,
};

webhookQueue.process(async (job) => {
  const { event_id, event_type, data } = job.data;
  const handler = handlers[event_type];

  if (!handler) {
    // Evento desconhecido: nao tentar de novo, apenas logar
    console.warn({ event_id, event_type, msg: 'No handler registered' });
    return { skipped: true };
  }

  console.log({ event_id, event_type, attempt: job.attemptsMade + 1 });
  await handler(data, event_id);
  return { processed: true };
});

// Dead letter queue — alerta quando job falha apos todos os attempts
webhookQueue.on('failed', (job, err) => {
  console.error({
    event_id: job.data.event_id,
    event_type: job.data.event_type,
    attempts: job.attemptsMade,
    err: err.message,
    msg: 'Job moved to DLQ after exhausting retries',
  });
  // Aqui: Slack alert, PagerDuty, salvar em tabela dlq_events, etc.
});

Handler completo — juntando tudo

// src/webhooks/router.js
const express = require('express');
const { verifySignature } = require('./middleware/verifySignature');
const { deduplicateEvent } = require('./middleware/deduplicateEvent');
const { webhookQueue } = require('./queue');

const router = express.Router();

// express.raw() para que req.body seja Buffer — necessario para HMAC do body bruto
router.post(
  '/webhook/emailchecker',
  express.raw({ type: 'application/json' }),
  verifySignature,
  deduplicateEvent,
  async (req, res) => {
    const event = req.webhookBody;

    try {
      await webhookQueue.add(event, {
        jobId: event.event_id, // Bull usa jobId para deduplicacao na fila tambem
      });

      res.status(200).json({ status: 'queued', event_id: event.event_id });
    } catch (err) {
      console.error({ err: err.message, event_id: event.event_id });
      // Retorna 500 para o provider reenviar — a deduplicacao garante idempotencia
      res.status(500).json({ error: 'Failed to enqueue event' });
    }
  }
);

module.exports = router;

// src/app.js (fragmento)
// const webhookRouter = require('./webhooks/router');
// app.use('/api', webhookRouter);
// Nota: nao aplique express.json() globalmente antes desta rota —
// o express.raw() dentro do router ja cuida do parse para o endpoint de webhook

Para mais exemplos de integracao com a API do EmailChecker veja o guia de validacao de email via API (secao 7 cobre configuracao de webhook) e os exemplos de validacao em Node. Se voce prefere orquestrar o consumo sem escrever um servidor proprio, o tutorial de como integrar validacao de email com n8n mostra o mesmo fluxo em modo no-code.


Como testar localmente

O problema classico: seu servidor local nao e acessivel pela internet para o provider bater.

ngrok e a solucao mais rapida:

# Instala uma vez
npm install -g ngrok

# Expo o servidor local na porta 3000
ngrok http 3000

# Saida: https://abc123.ngrok.io -> http://localhost:3000
# Configure essa URL no dashboard do provider como endpoint de webhook

O ngrok tambem exibe um dashboard em http://127.0.0.1:4040 onde voce ve cada requisicao recebida, headers, body e pode fazer replay de qualquer evento com um clique — indispensavel para depurar o handler de assinatura sem ficar pedindo ao provider para reenviar.

Para inspecao rapida sem precisar de servidor proprio, webhook.site gera uma URL publica temporaria que captura e exibe tudo que chega. Util para inspecionar o formato real dos eventos antes de comecar a codar o handler.

Para simular o provider em testes automatizados, gere o payload e a assinatura manualmente:

// test/helpers/signPayload.js
const crypto = require('crypto');

function signPayload(body, secret, timestamp) {
  const ts = timestamp || Math.floor(Date.now() / 1000);
  const signedPayload = `${ts}.${JSON.stringify(body)}`;
  const sig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');
  return { signature: `sha256=${sig}`, timestamp: ts };
}

module.exports = { signPayload };

// Uso em teste (Jest/supertest):
// const { signature, timestamp } = signPayload(body, 'test-secret');
// await request(app)
//   .post('/api/webhook/emailchecker')
//   .set('X-Signature-SHA256', signature)
//   .set('X-Timestamp', String(timestamp))
//   .set('Content-Type', 'application/json')
//   .send(JSON.stringify(body));

Conclusao

Consumir webhooks de forma robusta se resume a quatro invariantes:

  1. Nunca confie sem verificar — HMAC com timing-safe compare e rejeicao de replays antigos.
  2. Nunca assuma entrega exatamente uma vez — deduplicacao por event_id no banco antes de qualquer efeito colateral.
  3. Separe recebimento de processamento — fila entre o endpoint HTTP e a logica de negocio, responda 200 rapido.
  4. Falhas sao previstas — retry com backoff exponencial e dead letter queue com alerta para o que nao se recuperou.

Os padroes acima nao sao especificos do EmailChecker. Funcionam com Stripe, GitHub, PagerDuty, qualquer provider que siga boas praticas de webhook — e a referencia webhooks.fyi e um otimo ponto de partida para comparar como cada um implementa assinatura e retry.

Se o seu sistema processa eventos de forma idempotente, com fila e com dead letter monitorada, voce pode dormir tranquilo mesmo que o provider reenvie, o worker reinicie ou o banco fique indisponivel por alguns minutos. O evento vai estar esperando na fila quando tudo voltar.

Comece agora

Pronto pra parar de mandar email pra endereço morto?

Comece grátis com 500 créditos. Sem cartão, sem compromisso.