Voltar para Artigos
Back-end6 min de leitura

Arquitetura de Webhooks: Segurança, Idempotência e Raw Body

Como processar webhooks de pagamento (Stripe/Mercado Pago) de forma segura: o problema do express.json() com o Raw Body, validação de assinaturas HMAC, idempotência para evitar pedidos duplicados e processamento em background (BullMQ).

12 de agosto de 2026

Pagamentos modernos são assíncronos. Você gera um PIX ou inicia um checkout de cartão, mas a confirmação real ocorre minutos ou horas depois. Os gateways avisam seu backend através de Webhooks (uma rota POST na sua API). No entanto, uma rota pública de webhook é o alvo número um de fraudadores. É preciso provar criptograficamente a autoria, processar o payload cru e garantir a idempotência.

1. O Problema do Express e o Raw Body

Ao receber um webhook, sua primeira responsabilidade é verificar a autenticidade antes de processar qualquer dado. Um endpoint público que aceita qualquer POST sem validação pode ser explorado para marcar pedidos como pagos sem pagamento real. A assinatura HMAC-SHA256 gerada pela Stripe resolve isso: apenas quem conhece o segredo do webhook pode gerar uma assinatura válida.

Gateways assinam o evento calculando um hash HMAC sobre a string crua exata que estão enviando. Se você usar app.use(express.json()) globalmente, o Express converte o buffer em um objeto JavaScript. A tentativa de verificar a assinatura da Stripe usando esse objeto vai falhar 100% das vezes, pois as chaves do JSON podem ter mudado de ordem.

server.ts
import express from 'express';
import { webhooksRoutes } from './webhooks.routes';
import { apiRoutes } from './api.routes';

const app = express();

// IMPORTANTE: Rotas de Webhook DEVEM vir antes do express.json() global
// O Stripe exige o Buffer bruto para bater a assinatura criptográfica
app.use('/webhooks', express.raw({ type: 'application/json' }), webhooksRoutes);

// Para o resto da API, usamos JSON parsing normal
app.use('/api', express.json(), apiRoutes);

2. Verificando a Assinatura (Signature)

Com o Buffer garantido, validamos se o request realmente partiu da Stripe usando a Secret Key do Webhook. Se um atacante mandar um payload falso, a validação lança um erro. O ponto crítico da arquitetura de webhook é responder 200 imediatamente: a Stripe tem um timeout curto para o ACK. Se sua lógica de negócio demorar mais que alguns segundos, a Stripe assume falha e reenvia — gerando o problema de entrega duplicada que a idempotência resolve.

StripeWebhooksController.ts
import type { Request, Response } from 'express';
import Stripe from 'stripe';
import { ProcessWebhookJob } from '../jobs/ProcessWebhookJob';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: '2023-10-16'
});

export class StripeWebhooksController {
  async handle(req: Request, res: Response) {
    const signature = req.headers['stripe-signature'] as string;
    let event: Stripe.Event;

    try {
      // Aqui req.body é o Buffer intocado garantido pelo express.raw()
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET as string
      );
    } catch (err) {
      // Responde 400 imediatamente. Ataque bloqueado.
      return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
    }

    // Retorne 200 IMEDIATAMENTE para a Stripe parar de retentar.
    // Nunca bloqueie a resposta esperando lógicas lentas terminarem.
    res.status(200).send();

    // Delega o processamento real para uma fila em background
    await ProcessWebhookJob.add({ event });
  }
}

3. Idempotência: Protegendo contra Duplicidade

Sistemas distribuídos seguem a regra do At-Least-Once Delivery: a Stripe garante que vai entregar o webhook, mas pode acontecer de entregar o mesmo evento duas vezes em caso de instabilidade de rede. Seu sistema não pode entregar o produto (ou adicionar créditos) em duplicidade. Armazenar o eventId da Stripe em uma tabela dedicada e verificar antes de processar é a solução padrão. A chave está em fazer isso dentro da mesma transação do banco: registrar o evento E atualizar o pedido atomicamente.

ProcessPaymentUseCase.ts
export class ProcessPaymentUseCase {
  async execute(eventId: string, paymentIntentId: string) {
    // 1. Chave de Idempotência: Garante que o mesmo evento do gateway não processe duas vezes
    const alreadyProcessed = await this.processedEventsRepo.findById(eventId);
    if (alreadyProcessed) return; // Silent ignore (já foi processado com sucesso)

    await this.uow.transaction(async ({ ordersRepo, processedEventsRepo }) => {
      const order = await ordersRepo.findByPaymentIntent(paymentIntentId);
      if (!order || order.status === 'PAID') return;

      order.status = 'PAID';
      await ordersRepo.save(order);

      // Registra o ID do evento na transação, marcando como concluído
      await processedEventsRepo.save({ id: eventId, processedAt: new Date() });
    });
  }
}

Conclusão

Arquitetar o recebimento de webhooks exige: ler o payload como Buffer cru para validar assinaturas HMAC, responder status 200 quase instantaneamente para evitar timeouts na fila da Stripe (delegando a lógica para um worker BullMQ/RabbitMQ), e garantir idempotência absoluta baseada no ID do evento para prevenir dupla liquidação em retentativas.