Skip to content

Webhook Idempotency Middleware

Visão Geral

O middleware de idempotência garante que webhooks duplicados entregues por provedores externos (Stone, Stripe, WhatsApp/Evolution, iFood, etc.) sejam processados uma única vez, mesmo que o provedor reenvie a mesma notificação várias vezes.

A implementação usa Redis como store de deduplicação e segue o padrão Guard + Interceptor do NestJS — o guard verifica na entrada, o interceptor armazena a resposta na saída.

Localização

CamadaArquivo
Guardbackend/src/common/webhook/idempotency.guard.ts
Interceptorbackend/src/common/webhook/idempotency.interceptor.ts
Decoratorbackend/src/common/webhook/idempotency.decorator.ts
Módulobackend/src/common/webhook/common-webhook.module.ts
Serviço de auditoriabackend/src/common/services/webhook-audit.service.ts
Serviço de idempotênciabackend/src/common/services/webhook-idempotency.service.ts

Como Funciona

Webhook recebido → WebhookIdempotencyGuard

  ├── Resolve chave Redis (idem:<source>:<key>)
  │     Prioridade: 1. Header customizado / "idempotency-key"
  │                 2. SHA-256 do body (fallback)

  ├── SETNX idem:<source>:<key> com TTL (default 24h)
  │     ├── Nova entrega → seta __idempotencyKey na request → handler executa
  │     └── Duplicata → lê resposta cacheada → interceptor faz short-circuit

WebhookIdempotencyInterceptor (pós-handler)
  ├── Se __idempotencyKey está setado → serializa resposta → SET Redis
  └── Se __idempotentCachedResponse está setado → retorna cached sem chamar handler

Fail-open: erros de Redis são logados como WARN e a requisição prossegue normalmente — nunca derruba o webhook.

Decorator @IdempotentWebhook

Aplica Guard + Interceptor + Metadata em um único decorator de método:

typescript
import { IdempotentWebhook } from '../common/webhook/idempotency.decorator';

// Stone: sem header estável → usa SHA-256 do body
@Post('webhook')
@IdempotentWebhook({ source: 'stone', ttlSec: 86_400 })
async handleWebhook(@Body() body: any) { ... }

// Stripe: assinatura é estável entre reentregas → usa como chave
@Post('webhook')
@IdempotentWebhook({ source: 'stripe', headerName: 'stripe-signature' })
async stripeWebhook(@Req() req: Request) { ... }

// WhatsApp/Evolution: sem header estável → SHA-256 do body
@Post('webhooks/whatsapp/evolution')
@IdempotentWebhook({ source: 'whatsapp' })
async evolutionWebhook(@Body() body: any) { ... }

Parâmetros IdempotencyMeta

CampoObrigatórioPadrãoDescrição
sourceSimLabel do provedor, ex: "stone", "stripe". Prefixo da chave Redis.
ttlSecNão86400 (24h)TTL da chave de dedup no Redis.
headerNameNão"idempotency-key"Nome do header HTTP a usar como chave. Se ausente no request, cai para body-hash.

Resolução da Chave Redis

Formato: idem:<source>:<key>

Prioridade:

  1. Header headerName (ou idempotency-key se não especificado) — extraído como string
  2. SHA-256 do body serializado como JSON — fallback quando o header está ausente

Exemplos reais:

  • Stone: idem:stone:sha256:<hash-do-body>
  • Stripe: idem:stripe:v1=<hmac-do-stripe>
  • WhatsApp: idem:whatsapp:sha256:<hash-do-body>

Integrações que Usam o Middleware

WebhooksourceEstratégia de chave
Stonestonebody-hash
Stripestripestripe-signature header
WhatsApp Evolutionwhatsappbody-hash
WhatsApp Orderingwhatsapp-orderingbody-hash
iFood / Marketplacemarketplacebody-hash

Comportamento em Caso de Duplicata

Quando o Redis detecta uma entrega duplicada (chave já existe com TTL ativo):

  1. O guard lê a resposta cacheada do Redis (armazenada pelo interceptor na primeira entrega).
  2. O interceptor retorna a resposta cacheada sem invocar o handler.
  3. O provedor recebe 200 OK com a mesma resposta da primeira entrega.

Se a primeira entrega ainda está em andamento (chave existe mas sem resposta cacheada ainda), o guard permite que o handler execute novamente — prevenindo drop silencioso.

Observabilidade

Warnings gerados pelo guard (nível WARN, visíveis nos logs do backend):

MensagemCausa
WebhookIdempotencyGuard key resolution failed: ...Fonte sem source configurado
WebhookIdempotencyGuard cached value parse error for key=...Valor cacheado corrompido
WebhookIdempotencyGuard Redis error for key=...Redis offline — fail-open
WebhookIdempotencyInterceptor cache write failed for key=...Falha ao escrever no Redis

Relacionados

Lançado sob a licença MIT.