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).
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.
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.
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.
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.