Catalisa.Building Blocks
Catálogo/Plataforma/Webhooks Engine

Webhooks Engine

Produção

Entrega eventos aos sistemas dos seus clientes com assinatura, retentativa e histórico

18
Endpoints
4
Entidades
0
Provedores
Tenant
Escopo
3013
Porta
2025-11
Desde

Seu cliente quer ser avisado quando algo acontece na plataforma. Em vez de o seu time construir fila, assinatura, retentativa e uma tela de "por que não chegou", você cadastra a URL dele e a entrega passa a ser problema resolvido.

Para quem é
  • Plataformas B2B que precisam notificar o ERP ou o CRM do cliente quando um evento acontece
  • Fintechs cujo cliente exige aviso em tempo real de liquidação, contrato assinado ou proposta aprovada
  • Times que já apanharam de webhook perdido e não querem escrever a terceira versão do retry
Substitui
  • Assinatura de um serviço dedicado de entrega de webhooks (Svix, Hookdeck, Convoy)
  • Fila caseira com `setTimeout` de retentativa e log espalhado no CloudWatch
  • Planilha de "quais clientes recebem quais eventos"
O que não é
  • Gateway de webhooks de ENTRADA — não recebe webhook de terceiros por você
  • Plataforma de automação no estilo Zapier, com conectores prontos
  • Barramento de eventos interno entre building blocks (isso é o Redis Stream por trás)
O que dá para fazer

19 endpoints em 6 recursos.

Explorar a API →
01

Resumo executivo

O Webhooks Engine é a peça que avisa o sistema do seu cliente quando algo acontece na plataforma. O cliente cadastra uma URL e diz quais eventos quer receber; a partir daí, entregar aquilo — com assinatura criptográfica, retentativa quando o servidor dele estiver fora e registro de cada tentativa — deixa de ser código do seu time.

Na prática: uma financeira quer que o ERP dela seja avisado no instante em que um contrato é assinado. Sem este BB, alguém do seu time escreve um fetch, descobre em produção que o ERP cai às terças de madrugada, escreve um retry, descobre que o retry duplicou eventos, escreve idempotência, e seis meses depois o suporte ainda não consegue responder "o evento das 14h chegou?". Com este BB, o cliente cadastra a subscription e consulta GET /delivery-logs sozinho.

Está em produção desde novembro de 2025, é tenant-scoped sobre o token do IAM, consome o barramento interno de eventos da plataforma e é usado pelos dispatchers de WPP, WPP Business e Slack através de uma facade compartilhada.

AtributoValor
Identificadorwebhooks-engine
CategoriaPlataforma
EscopoTenant (exige organizationId no token)
Porta (standalone)3013
Path alias@webhooks-engine
Prefixo HTTP/webhooks-engine
Schema no bancowebhooks
StatusProdução desde 2025-11
Depende dePostgreSQL, Redis (Streams), IAM, WEBHOOK_MASTER_KEY

02

O problema

negócio

O cenário

A sua plataforma faz coisas que importam para o sistema do cliente: um pagamento liquida, um contrato é assinado, uma proposta muda de status. O cliente não quer ficar perguntando de minuto em minuto — ele quer ser avisado. Você promete um webhook.

flowchart LR
  E["Evento na sua plataforma<br/>contrato assinado, fatura paga"] --> P["A promessa: um webhook"]
  P --> F["A entrega: um fetch no handler"]
  F --> R["A realidade:<br/>perda, duplicata, evento forjado,<br/>SSRF e suporte sem resposta"]

O que trava hoje

  • A primeira versão é um fetch e funciona por três semanas. Depois o endpoint do cliente fica fora por 20 minutos numa manutenção, cinco eventos se perdem, e o cliente descobre pelo relatório do mês seguinte.
  • O retry ingênuo cria problemas novos. Repetir a chamada sem backoff transforma uma indisponibilidade do cliente em ataque de negação de serviço contra ele. Repetir sem identificador estável faz o cliente processar o mesmo pedido duas vezes.
  • A assinatura é postergada e nunca feita. Sem assinatura, qualquer um que descubra a URL do cliente consegue mandar um evento falso. "Contrato assinado" forjado é fraude, não bug.
  • A URL do cliente é uma superfície de ataque. Um cliente que cadastra http://169.254.169.254/latest/meta-data/ está pedindo para o seu servidor buscar as credenciais da sua instância e entregar a resposta para ele. Isso é SSRF, a categoria A10:2021 do OWASP Top 10 (OWASP A10:2021 — Server-Side Request Forgery).
  • O suporte não tem resposta. "O webhook não chegou" vira um chamado que consome duas pessoas: uma procurando log no CloudWatch, outra pedindo para o cliente confirmar o horário. Sem histórico consultável, a conversa não converge.

O custo de não resolver. Dá para medir pelo que os líderes de mercado consideram necessário. O Stripe tenta entregar cada evento por até três dias com backoff exponencial, não garante ordem, recomenda que o receptor registre os IDs já processados para lidar com duplicata, assina cada payload com HMAC-SHA256 e sugere tolerância de cinco minutos no timestamp para barrar replay (Stripe — *Webhooks*). O Svix faz 8 tentativas espaçadas ao longo de aproximadamente 24 horas e desabilita automaticamente o endpoint que falha por 5 dias seguidos (Svix — *Retry Schedule*).

Isso é o piso do que "entrega confiável" significa. Nenhuma dessas peças é difícil isoladamente; juntas, são um subsistema — e é um subsistema que não diferencia o seu produto.


03

Proposta de valor

negócio
AntesDepois
fetch no handler, sem retentativaFila com backoff exponencial e limite configurável por subscription
Payload sem assinatura, ou HMAC feito às pressasRSA-SHA256 com chave de 2048 bits, uma por subscription
Cliente cadastra qualquer URLValidação anti-SSRF na criação e na hora do envio, contra DNS rebinding
"O webhook não chegou" é investigaçãoGET /delivery-logs com código HTTP, corpo da resposta e tempo de cada tentativa
Trocar o segredo derruba a integração do clienteRotação de chave com período de graça (7 dias por padrão)
Cada BB novo reimplementa entregaFacade compartilhada: WPP, WPP Business e Slack já usam

A assinatura é assimétrica, não HMAC

O cliente recebe a chave pública por API e verifica com ela. Diferente de segredo compartilhado, quem consegue verificar não consegue assinar. Um segredo HMAC vazado do lado do cliente permite forjar eventos contra ele; uma chave pública vazada não permite nada.

sequenceDiagram
  autonumber
  participant WE as Webhooks Engine
  participant KS as Cofre de chaves
  participant CL as Sistema do cliente
  participant AT as Atacante

  Note over WE,KS: nós assinamos com a chave PRIVADA
  CL->>WE: GET /subscriptions/:id/keys
  WE-->>CL: chave PÚBLICA em PEM, com o keyId
  WE->>KS: busca a chave ACTIVE da subscription
  KS-->>WE: privada decifrada em memória, AES-256-GCM
  WE->>WE: assina messageId + timestamp + corpo com RSA-SHA256
  WE->>CL: POST com x-webhook-signature e x-webhook-key-id
  CL->>CL: seleciona a pública pelo keyId e verifica
  Note over CL: verifica, mas não consegue assinar
  AT->>CL: evento forjado com a chave pública vazada
  CL-->>AT: assinatura inválida, evento recusado

Atenção. Com HMAC — o que Stripe, Svix e GitHub usam — o quadro acima muda: o segredo que o cliente guarda para verificar é o mesmo que assina, então o atacante que o obtém consegue forjar eventos que o cliente aceita como legítimos.

A chave privada nunca fica em claro no banco

Cada subscription tem seu par RSA; a privada é cifrada em AES-256-GCM com a WEBHOOK_MASTER_KEY, que vive fora do banco. Dump do banco não dá poder de assinar.

A retentativa não vira ataque contra o cliente

Backoff exponencial com teto de uma hora, e 4xx (exceto 429) marca falha na hora, sem insistir — porque insistir contra um 400 só gasta o servidor do cliente.

A URL do cliente é tratada como entrada hostil

Bloqueio de loopback, faixas privadas, link-local (inclusive 169.254.169.254), Classe E reservada, endereços IPv6 equivalentes e hostnames de metadados de nuvem. E a validação é feita duas vezes: ao cadastrar e no momento do envio, com resolução de DNS e revalidação do IP, para fechar a janela de DNS rebinding.

O suporte responde sozinho

O cliente com WEBHOOKS_DELIVERIES_READ consulta o próprio histórico, vê o 500 que o servidor dele devolveu, e reenvia com POST /delivery-logs/:id/retry.


04

Casos de uso reais

negócio

Caso 1 — O ERP do cliente cai de madrugada e nenhum contrato se perde Cenário ilustrativo

Contexto

Plataforma de crédito que assina contratos digitalmente. O ERP do cliente precisa registrar cada contrato assinado para liberar o pagamento.

A dor

O ERP tem janela de manutenção às terças, das 2h às 2h40. A integração anterior era um fetch no handler de assinatura: nessas 40 minutos os eventos sumiam. A conciliação era manual, feita por uma pessoa na quarta de manhã comparando dois relatórios.

A solução com o BB

O cliente cadastra uma subscription apontando para o ERP, com filtro e-signature.* e retryConfig ajustado para maxRetries: 8, retryBackoffMs: 5000, retryBackoffMultiplier: 3. Durante a janela, cada entrega falha com erro de rede, entra em RETRYING, e o job de retentativa reprocessa. As que ainda assim estourarem as tentativas ficam em FAILED e são reenviadas em lote com POST /delivery-logs/:id/retry.

flowchart LR
  A["Contrato assinado às 2h15"] --> B["Entrega tentada · erro de rede"]
  B --> C["Log em RETRYING<br/>nextRetryAt calculado com backoff de 5s e multiplicador 3"]
  C --> D["DeliveryRetryJob reprocessa a cada 60s"]
  D -->|"ERP voltou dentro das 8 tentativas"| E["DELIVERED<br/>evento webhooks.delivery.succeeded"]
  D -->|"tentativas esgotadas"| F["FAILED<br/>linha preservada com payload e resposta"]
  F -->|"POST /delivery-logs/:id/retry em lote"| E
O resultado

A conciliação manual de quarta-feira some. E quando alguém pergunta "o contrato X foi notificado?", a resposta é GET /delivery-logs?filter[eventType]=e-signature.document.signed, com o código HTTP que o ERP devolveu.

Caso 2 — Um cliente tenta cadastrar o endereço de metadados da nuvem Cenário ilustrativo

Contexto

Plataforma multi-tenant onde qualquer cliente com WEBHOOKS_SUBSCRIPTIONS_CREATE cadastra a própria URL de destino.

A dor

Numa arquitetura ingênua, a URL cadastrada pelo cliente é buscada pelo servidor da plataforma. Um cliente mal-intencionado cadastra http://169.254.169.254/latest/meta-data/iam/security-credentials/ — ou um domínio próprio cujo DNS aponta para esse IP só na segunda resolução — e recebe, no corpo de resposta gravado no log de entrega, as credenciais da instância. É SSRF clássico, A10:2021 do OWASP.

A solução com o BB

isUrlSafe reprova a URL na criação, exige HTTPS em produção e registra SSRF_BLOCKED no log de segurança com a organização e a URL tentada. Para o caso do DNS que muda entre a validação e o envio, o secureFetch resolve o hostname no momento da entrega, revalida o IP resolvido contra as mesmas faixas bloqueadas e só então faz a requisição — para o IP, preservando o Host original.

sequenceDiagram
  autonumber
  participant CLI as Cliente mal-intencionado
  participant WE as Webhooks Engine
  participant DNS as Resolvedor DNS
  participant MD as Endereço de metadados

  CLI->>WE: POST /subscriptions com URL apontando para 169.254.169.254
  WE->>WE: isUrlSafe reprova e exige HTTPS em produção
  WE-->>CLI: 400 e registro SSRF_BLOCKED no log de segurança
  Note over CLI: segunda tentativa, agora com domínio próprio
  CLI->>WE: POST /subscriptions com dominio-do-atacante.com
  WE->>DNS: resolve na criação
  DNS-->>WE: IP público, aprovado
  Note over CLI,DNS: o atacante muda o DNS para 169.254.169.254
  WE->>DNS: secureFetch resolve de novo, no momento da entrega
  DNS-->>WE: 169.254.169.254
  WE->>WE: revalida o IP resolvido contra as mesmas faixas
  WE--xMD: requisição bloqueada, nunca sai
  WE-->>CLI: erro de entrega registrado no log
O resultado

A superfície de SSRF fica fechada nos dois momentos, e a tentativa vira evento de segurança em vez de incidente.

Caso 3 — Rotação de chave de assinatura sem combinar janela com o cliente Cenário ilustrativo

Contexto

Integração em produção há dois anos. Política interna do cliente exige rotação anual do material criptográfico.

A dor

Com segredo compartilhado, rotacionar é atômico e destrutivo: no instante em que o novo segredo passa a valer, o cliente que ainda não atualizou começa a rejeitar tudo. Na prática isso exige uma janela combinada, alguém de plantão dos dois lados e, quase sempre, uma noite.

A solução com o BB

POST /subscriptions/:id/keys/rotate cria uma chave nova em ACTIVE e move a antiga para ROTATING com validUntil de 7 dias (ajustável entre 1 hora e 30 dias). Os envios já saem assinados com a chave nova, e o header x-webhook-key-id diz qual foi usada. O cliente busca as chaves em GET /subscriptions/:id/keys, que devolve a ACTIVE e a ROTATING, e mantém as duas no verificador dele até terminar a migração.

flowchart LR
  R["POST /subscriptions/:id/keys/rotate"] --> N["Chave nova em ACTIVE<br/>assina todas as entregas a partir de agora"]
  R --> V["Chave antiga vira ROTATING<br/>validUntil de 7 dias, ajustável de 1 hora a 30 dias"]
  N --> L["GET /subscriptions/:id/keys devolve as duas"]
  V --> L
  L --> C["Cliente guarda as duas públicas<br/>e escolhe pelo header x-webhook-key-id"]
  C --> M["Migração concluída em horário comercial<br/>sem janela combinada e sem entrega rejeitada"]
O resultado

Rotação em horário comercial, sem janela combinada e sem entrega rejeitada — porque o cliente escolhe a chave pelo x-webhook-key-id em vez de adivinhar.

Caso 4 — O que os líderes de mercado consideram obrigatório Referência de mercado

Contexto

As duas implementações públicas mais copiadas do padrão são a do Stripe e a do Svix. Stripe: assinatura HMAC-SHA256 no header Stripe-Signature, com timestamp t= e assinatura v1=, sobre a string timestamp.payload; retentativa por até três dias com backoff exponencial; sem garantia de ordem; tolerância padrão de cinco minutos no timestamp contra replay (Stripe — *Webhooks*). Svix: headers svix-id, svix-timestamp e svix-signature com HMAC-SHA256 em base64 e prefixo v1,; 8 tentativas ao longo de cerca de 24 horas; desativação automática do endpoint que falha por 5 dias (Svix — *Verificando payloads*, Svix — *Retries*). O GitHub assina com HMAC-SHA256 em X-Hub-Signature-256 e recomenda comparação em tempo constante (GitHub — *Validating webhook deliveries*).

A dor

A dor aqui é do mercado inteiro: os três convergem para o mesmo desenho — e os três usam assinatura simétrica. Isso significa que o segredo que o cliente usa para verificar é o mesmo que assina: um vazamento do lado do cliente permite que o atacante forje eventos contra ele.

A solução com o BB

Como a Catalisa endereça: mesmo desenho de headers e de payload assinado (id, timestamp, corpo cru, com prefixo de versão v1=), mas com RSA-SHA256 assimétrica. A chave que o cliente guarda é pública; vazá-la não permite forjar nada. Em troca, a nossa janela de retentativa é hoje muito mais curta que a do Stripe e a do Svix — cerca de quatro minutos contra três dias e 24 horas (§15). É um trade-off real, e está documentado como tal.

flowchart LR
  subgraph SIM["Assinatura simétrica — Stripe, Svix, GitHub"]
    S1["Um segredo compartilhado"] --> S2["Verifica"]
    S1 --> S3["Assina"]
    S3 --> S4["Vazou do lado do cliente:<br/>o atacante forja eventos"]
  end
  subgraph ASS["Assinatura assimétrica — Catalisa"]
    A1["Chave privada, só nossa"] --> A2["Assina"]
    A3["Chave pública, com o cliente"] --> A4["Só verifica"]
    A4 --> A5["Vazou do lado do cliente:<br/>não dá capacidade nenhuma"]
  end
  subgraph TR["O trade-off, dito de frente"]
    T1["Janela de retentativa Catalisa:<br/>cerca de 4 minutos"]
    T2["Svix: cerca de 24 horas · Stripe: 3 dias"]
  end
O resultado

Superioridade clara no eixo de assinatura, desvantagem clara no eixo de persistência da retentativa. Quem compra precisa saber os dois.


05

Mercado e diferenciais

negócio

Panorama

Entregar webhook parece uma linha de código e é um subsistema. O mercado percebeu isso e se dividiu em três grupos. Os especializados em saída (Svix) fazem uma coisa e fazem bem: assinam, tentam de novo por muito tempo e dão ao seu cliente um portal para ver o histórico. Os gateways bidirecionais (Hookdeck, Convoy) tratam entrada e saída no mesmo produto, com transformação e replay. E a infraestrutura de nuvem (AWS EventBridge com API Destinations) entrega barato em volume, sem assinar payload e sem noção de tenant.

flowchart TD
  M["Entregar webhook: parece uma linha de código, é um subsistema"]
  M --> G1["Especializados em saída<br/>Svix"]
  M --> G2["Gateways bidirecionais<br/>Hookdeck · Convoy"]
  M --> G3["Infraestrutura de nuvem<br/>AWS EventBridge com API Destinations"]
  M --> G4["Catalisa Webhooks Engine"]
  G1 --> D1["Assinam, tentam de novo por muito tempo<br/>e dão portal de histórico ao seu cliente"]
  G2 --> D2["Entrada e saída no mesmo produto,<br/>com transformação e replay"]
  G3 --> D3["Barato em volume,<br/>sem assinar payload e sem noção de tenant"]
  G4 --> D4["Do primeiro grupo, já dentro da plataforma:<br/>lê o token do IAM e o barramento comum"]

O Webhooks Engine da Catalisa é do primeiro grupo, com uma diferença de projeto: ele já sabe o que é uma organização, porque lê o token do IAM, e já está plugado no barramento onde os outros 31 building blocks publicam. Você não conecta a plataforma nele — ele já está dentro.

CritérioCatalisa Webhooks EngineSvixHookdeckConvoyAWS EventBridge
Dimensão de cobrançaEventos entregues + subscriptionsPor mensagemPor evento + throughputLicença (Premium)Por milhão de eventos
Preço público de entradaEm definiçãoUS$ 0 (50k msgs/mês)US$ 0 (10k eventos/mês)US$ 0 (self-host)US$ 1,00/milhão publicado
Assinatura do payloadRSA-SHA256 assimétricaHMAC-SHA256HMACHMACNão assina
Rotação com período de graçaSim (1h a 30 dias)SimSimSim—
Janela de retentativa~4 min (5 tentativas)~24h (8 tentativas)Configurável, longaConfigurávelRetentativa do EventBridge
Reenvio manual por APISimSimSimSimNão
Log de entrega consultável pelo clienteSim, por APISim, com portalSim, com painelSimNão
Proteção anti-SSRF com revalidação de DNSSimNão documentadoNão documentadoNão documentadoNão se aplica
Ingestão de webhook de entradaNãoSim (Ingest)SimSimSim
Portal pronto para o cliente finalNão (ver §15)SimSimParcialNão
Isolamento multi-tenant nativoSim, do IAMPor aplicaçãoPor projetoPor projetoNão
Self-hostSim (é seu deploy)Só no EnterpriseNãoSimNão

Preços de tabela pública consultados em 2026-08-16: Svix, Hookdeck, Convoy, AWS EventBridge. Tabelas mudam; confira na data da sua análise.

Nossos diferenciais

  1. Assinatura assimétrica. Svix, Stripe, GitHub e Hookdeck usam HMAC simétrica. Nós usamos RSA-SHA256 com par de 2048 bits: o cliente recebe só a chave pública. É difícil de copiar não pela criptografia — que é trivial — mas porque migrar uma base instalada de HMAC para assimétrico quebra todos os verificadores dos clientes de uma vez. Quem já vendeu HMAC fica preso a ele.
  2. A chave privada é cifrada em repouso com chave-mestra externa. encryptedKey guarda iv:authTag:ciphertext de AES-256-GCM, e a WEBHOOK_MASTER_KEY (32 bytes) não está no banco. Um dump não dá poder de assinar.
  3. Anti-SSRF em duas camadas, incluindo DNS rebinding. Validar a URL no cadastro é o que quase todo mundo faz. Resolver o DNS no momento do envio, revalidar o IP resolvido e então requisitar diretamente o IP com o Host preservado é o que fecha a janela entre validação e envio. Está em secureFetch, e é a diferença entre bloquear o ingênuo e bloquear o determinado.
  4. Já nasce dentro da plataforma. O consumidor lê o barramento de eventos onde os 32 building blocks publicam, e o organizationId vem do token. Não há mapeamento de "aplicação Svix ↔ cliente meu" para manter em sincronia.

Quando escolher o concorrente

Se a sua exigência éEscolhaPor quê
Entrega persistente por horas ou dias — o cliente pode ficar fora a manhã inteira e ainda assim receber tudoSvix ou HookdeckEles fazem isso hoje e nós não fazemos: nossa janela padrão é de cerca de quatro minutos e depende de reenvio manual a partir daí (§15)
Receber webhooks de terceiros, com validação, transformação e replayHookdeck ou ConvoyCobrem esse lado, e nós não cobrimos nada dele
Um portal pronto onde o cliente final cadastra endpoint, vê o histórico e reenvia sem passar por vocêSvixEntrega isso embalado; nós entregamos a API para você construir a tela
Bilhões de eventos internos entre serviços da AWSAWS EventBridgeÉ mais barato que qualquer alternativa nesse recorte

O Webhooks Engine ganha quando o problema é entregar eventos da sua plataforma multi-tenant aos sistemas dos seus clientes empresariais, com garantia forte de autenticidade — não quando o problema é ingestão ou automação.


06

Modelo de cobrança e ROI

negócio

Unidade de cobrança

Precificação em definição. Os drivers estão definidos:

DriverPor que importa
Volume de eventos entreguesCada entrega é uma assinatura RSA mais uma requisição HTTP de saída
Número de subscriptions ativasUm evento que casa com 5 subscriptions vira 5 entregas, 5 assinaturas e 5 linhas de log
Dias de retenção do log de entregaO payload completo fica em JSONB; retenção longa é armazenamento

O que dispara custo, na prática

O custo não é o evento publicado — é o produto entre evento e subscriptions que casam com o filtro dele. Uma subscription com filtro * recebe tudo. Cinco delas multiplicam por cinco a carga de assinatura, de rede e de log. Oriente o cliente a filtrar por namespace (billing.*) em vez de *.

Comparação de custo

Cenário nomeado: 2 milhões de eventos entregues por mês, 40 subscriptions ativas distribuídas entre 15 empresas clientes, 30 dias de retenção.

CatalisaSvixHookdeckAWS EventBridge (API Dest.)
Base de cálculoEventos + subscriptionsUS$ 0,0001 por mensagem entreguePor evento, com degrau de throughputUS$ 1,00/milhão publicado + US$ 0,20/milhão invocado
Conta do cenárioEm definição2 M × US$ 0,0001Plano Growth + excedente2 M publicados + 2 M invocados
Ordem de grandeza mensal—~US$ 200 (mais o plano)A partir de US$ 499~US$ 2,40
Assina o payload?Sim, assimétricaSim, HMACSim, HMACNão
Retenção de 30 dias inclusa?Sim, configurável 1–365Conforme planoSó no GrowthNão se aplica
Cliente consulta o próprio histórico?Sim, por APISim, com portalSim, com painelNão

Estimativa a partir das tabelas públicas consultadas em 2026-08-16, aplicando a conta direta. Não é proposta comercial. O EventBridge aparece barato porque resolve um problema diferente: ele não assina payload nem dá histórico ao seu cliente, então o custo dele exclui exatamente as duas coisas pelas quais você contrataria este BB.

ROI

A conta de guardanapo tem dois lados:

  • O que não é construído. Assinatura, rotação de chave, backoff, log de tentativa, validação anti-SSRF e uma API para o cliente consultar. Cada um é simples; o conjunto é um subsistema com testes, operação e incidentes próprios.
  • O que não é perdido. Um evento de "contrato assinado" que não chega ao ERP do cliente vira conciliação manual — e conciliação manual é uma pessoa por dia, permanentemente, até alguém consertar.

O ponto de atenção honesto: se a sua exigência de negócio for entrega garantida ao longo de horas, a janela atual de retentativa (§15) exige um processo de reenvio manual do seu lado. Isso é trabalho, e precisa entrar na conta.


07

Arquitetura

flowchart TD
  BBS["Outros 31 building blocks<br/>publicam eventos"]
  RS[("Redis Stream 'iam-events'<br/>grupo webhook-delivery-consumers")]

  subgraph WC["WebhookConsumer"]
    direction TB
    C1["1 · lê organizationId dos metadados do evento<br/>sem ele, descarta"]
    C2["2 · busca subscriptions ACTIVE da organização"]
    C3["3 · filtra por padrão glob — * , namespace.* ou exato, combinados com OR"]
    C4["4 · entrega em paralelo para todas as que casam"]
    C5["5 · XACK mesmo se a entrega falhar<br/>ela tem retentativa própria"]
    C1 --> C2 --> C3 --> C4 --> C5
  end

  subgraph DSV["DeliveryService"]
    direction TB
    D1["cria DeliveryLog em PENDING<br/>messageId = msg_uuid · timestamp ISO 8601"]
    D2["SigningKeyService.signWebhookPayload"]
    D2a["busca a chave ACTIVE da subscription"]
    D2b["decifra a privada — AES-256-GCM com WEBHOOK_MASTER_KEY"]
    D2c["assina 'messageId, timestamp, corpo' com RSA-SHA256"]
    D3["secureFetch do endpointUrl<br/>resolve DNS, valida o IP, requisita o IP com o Host original<br/>timeout = subscription.timeoutMs"]
    D4["handleDeliveryResult — DELIVERED, RETRYING ou FAILED"]
    D1 --> D2 --> D2a --> D2b --> D2c --> D3 --> D4
  end

  EP["Endpoint do cliente"]
  PG[("PostgreSQL · schema 'webhooks'")]
  RJ["DeliveryRetryJob · a cada 60s<br/>busca RETRYING com nextRetryAt já vencido<br/>lote de 100"]
  LJ["LogRetentionJob · a cada 24h<br/>apaga logs além de retentionDays, por organização"]

  BBS --> RS
  RS -->|"XREADGROUP COUNT 10, BLOCK 5s"| C1
  C4 --> D1
  D3 --> EP
  D4 --> PG
  RJ --> PG
  RJ -.->|"reenvia a entrega"| D1
  LJ --> PG

Decisões não óbvias

  • Assinatura assimétrica (RSA-2048 / RS256) em vez de HMAC. O mercado inteiro usa HMAC porque é mais barato: uma comparação de bytes contra alguns kilobytes de assinatura RSA. Aceitamos o custo porque o modelo de ameaça é diferente: o cliente guarda a chave de verificação nos servidores dele, muitas vezes em variável de ambiente compartilhada com fornecedores. Com HMAC, esse vazamento permite forjar eventos contra ele. Com chave pública, não permite nada.
  • Um par de chaves por subscription, não por organização. O raio de dano de um comprometimento fica limitado a um destino. E rotacionar a chave de uma integração não obriga a coordenar com todas as outras do mesmo cliente.
  • O payload assinado é messageId\ntimestamp\ncorpo, não só o corpo. O messageId e o timestamp entram na assinatura de propósito: sem isso, o atacante poderia reenviar um payload legítimo capturado (replay) e o cliente não teria como distinguir. Com eles dentro da assinatura, mudar o timestamp invalida a assinatura, e o cliente pode rejeitar por idade.
  • A chave privada é cifrada com AES-256-GCM, não com AES-CBC. GCM é autenticado: adulterar o texto cifrado no banco faz a decifragem falhar em vez de produzir uma chave corrompida que assinaria lixo. O formato guardado é iv:authTag:ciphertext em hexadecimal.
  • 4xx (exceto 429) não é retentado. Insistir contra um 400 ou um 404 gasta o servidor do cliente sem chance de sucesso. 429 é retentado porque é justamente o cliente pedindo para desacelerar. 5xx, timeout e erro de rede são retentados.
  • O consumidor faz XACK mesmo quando a entrega falha. A retentativa é responsabilidade do DeliveryLog, não do stream. Segurar a mensagem no stream criaria duas camadas de retentativa competindo, com semântica diferente — e o DeliveryLog é a que o cliente consegue enxergar e reenviar.
  • A entrega para múltiplas subscriptions é paralela. Um evento que casa com cinco subscriptions dispara cinco entregas simultâneas. A consequência aceita: não há garantia de ordem entre eventos, nem entre destinos. É a mesma escolha do Stripe, que documenta explicitamente não garantir ordem.
  • Evento sem organizationId nos metadados é descartado. Sem tenant não há como decidir a quem entregar, e adivinhar seria vazamento entre clientes. O consumidor faz XACK e segue.
  • O consumidor desembrulha eventos wpp-biz.dispatch.*. Eventos do dispatcher chegam num envelope com originalEventType e originalPayload; o consumidor entrega o evento original, para que o cliente veja wpp-business.message.received e não o envelope interno.

Monolito vs. standalone. Em monolito, outros building blocks usam a LocalWebhooksEngineFacade, que chama os serviços direto pelo container TypeDI. Em standalone — o modo de produção — a RemoteWebhooksEngineFacade faz HTTP para MODULE_WEBHOOKS_ENGINE_URL. A diferença que importa: o consumidor de stream e os dois jobs só sobem no main.ts do webhooks-engine. Em monolito, quem inicia o processo precisa iniciá-los explicitamente — sem isso, subscriptions são cadastradas mas nada é entregue.


08

Conceitos e modelo de dados

Glossário

TermoSignifica
SubscriptionO contrato de entrega: URL de destino, filtros de evento, timeout e política de retentativa. Uma por par (organização, nome).
Event filterPadrão glob que decide quais eventos aquela subscription recebe: *, namespace.* ou o tipo exato. Combinados com OR.
Signing keyPar RSA-2048 da subscription. A pública é exposta por API; a privada é cifrada no banco.
Key IDIdentificador da chave, no formato whk_<random>. Vai no header x-webhook-key-id de cada entrega.
Delivery logUma tentativa de entrega de um evento a uma subscription, com status, contagem, resposta e erro.
Message IDmsg_<uuid> gerado a cada tentativa. Entra na assinatura. Não é estável entre retentativas — ver §15.
Event IDO id dentro do payload. É o identificador estável do evento; use este para idempotência.
Retry configmaxRetries, retryBackoffMs e retryBackoffMultiplier, por subscription.
Grace periodJanela em que a chave antiga continua listada como válida após uma rotação (padrão 7 dias).
Retention daysQuantos dias os logs de entrega da organização são mantidos (padrão 30).

Modelo de dados

Schema webhooks no PostgreSQL.

Modelo PrismaTabelaPropósitoCampos-chave
WebhookSubscriptionwebhooks.webhook_subscriptionsContrato de entregaÚnico (organizationId, name), endpointUrl, eventFilters (array), status, timeoutMs, retryConfig (JSONB), customHeaders, deletedAt
WebhookSigningKeywebhooks.webhook_signing_keysPar RSA da subscriptionkeyId (único), publicKey (PEM SPKI), encryptedKey (AES-256-GCM), status, validFrom, validUntil
WebhookDeliveryLogwebhooks.webhook_delivery_logsCada tentativaeventId, eventType, payload (JSONB), status, attemptCount, nextRetryAt, responseStatusCode, responseBody, responseTimeMs
OrganizationWebhookConfigwebhooks.organization_webhook_configsLimites por organizaçãoorganizationId (único), retentionDays (padrão 30), maxSubscriptions (padrão 50)
erDiagram
  OrganizationWebhookConfig ||--o{ WebhookSubscription : "limita por organização"
  WebhookSubscription ||--o{ WebhookSigningKey : "uma ACTIVE por vez"
  WebhookSubscription ||--o{ WebhookDeliveryLog : "uma linha por tentativa"

  OrganizationWebhookConfig {
    uuid organizationId PK "único"
    int retentionDays "padrão 30, ajustável de 1 a 365"
    int maxSubscriptions "padrão 50, ajustável de 1 a 1000"
  }
  WebhookSubscription {
    uuid id PK
    uuid organizationId "único com name"
    string name
    string endpointUrl "HTTPS obrigatório em produção"
    array eventFilters "glob, combinados com OR"
    enum status "ACTIVE, PAUSED ou DISABLED"
    int timeoutMs
    jsonb retryConfig "maxRetries, backoff e multiplicador"
    jsonb customHeaders "texto claro, ver §14 e §15"
    timestamp deletedAt "exclusão lógica"
  }
  WebhookSigningKey {
    uuid id PK
    string keyId "único, formato whk_"
    text publicKey "PEM SPKI, em claro"
    text encryptedKey "AES-256-GCM, iv authTag ciphertext"
    enum status "ACTIVE, ROTATING ou REVOKED"
    timestamp validFrom
    timestamp validUntil "só na ROTATING"
  }
  WebhookDeliveryLog {
    uuid id PK
    string eventId "identificador estável do evento"
    string eventType
    jsonb payload "evento completo"
    enum status "PENDING, DELIVERED, RETRYING ou FAILED"
    int attemptCount
    timestamp nextRetryAt "só em RETRYING"
    int responseStatusCode
    text responseBody "truncado em 1024 bytes"
    int responseTimeMs
  }

Enumerações

EnumValores
WebhookSubscriptionStatusACTIVE · PAUSED · DISABLED
WebhookDeliveryStatusPENDING · DELIVERED · FAILED · RETRYING
WebhookSigningKeyStatusACTIVE · ROTATING · REVOKED

Máquina de estados da entrega

A peça central deste BB:

stateDiagram-v2
  PENDING: PENDING — log criado, ainda não tentado
  DELIVERED: DELIVERED — terminal, publica webhooks.delivery.succeeded
  RETRYING: RETRYING — nextRetryAt igual a agora mais o atraso
  FAILED: FAILED — terminal, publica webhooks.delivery.failed

  [*] --> PENDING: DeliveryService.deliverEvent
  PENDING --> DELIVERED: resposta 2xx
  PENDING --> FAILED: 4xx exceto 429, falha na hora sem insistir
  PENDING --> RETRYING: 5xx, 429, timeout ou erro de rede, com tentativas restantes
  PENDING --> FAILED: 5xx, 429, timeout ou erro de rede, com tentativas esgotadas
  RETRYING --> RETRYING: DeliveryRetryJob a cada 60s pega o nextRetryAt vencido
  RETRYING --> DELIVERED: resposta 2xx numa retentativa
  RETRYING --> FAILED: maxRetries atingido
  RETRYING --> DELIVERED: reenvio manual bem-sucedido
  FAILED --> DELIVERED: reenvio manual bem-sucedido
  FAILED --> FAILED: reenvio manual falha de novo
  DELIVERED --> [*]
  FAILED --> [*]

O reenvio manual é POST /delivery-logs/:id/retry, e é aceito em FAILED e em RETRYING — em qualquer outro status a resposta é 400.

Atenção. Não existe fila de mensagens mortas separada. FAILED é o estado terminal e também o "dead letter": a linha fica no banco com payload, resposta e erro, e o reenvio manual é o caminho de recuperação — até o expurgo por retenção.

Atraso entre tentativas

A conta é retryBackoffMs × multiplicador^(tentativa−1), com teto de 1 hora:

Tentativa concluídaAtraso nominal (padrão)Quando de fato ocorre
1ª falhou1 sNo próximo tique do job (até 60 s)
2ª falhou2 sNo próximo tique do job (até 60 s)
3ª falhou4 sNo próximo tique do job (até 60 s)
4ª falhou8 sNo próximo tique do job (até 60 s)
5ª falhou—maxRetries (5) atingido → FAILED

O atraso nominal é menor que o intervalo do job de retentativa, que roda a cada 60 segundos. Na prática, as tentativas ficam espaçadas em cerca de um minuto e a janela total padrão é de aproximadamente quatro minutos. Para janela maior, aumente retryBackoffMs e maxRetries na subscription (§11).

Ciclo de vida da chave de assinatura

stateDiagram-v2
  ACTIVE: ACTIVE — assina todas as entregas daqui em diante
  ROTATING: ROTATING — validUntil igual a agora mais o período de graça
  REVOKED: REVOKED — terminal

  [*] --> ACTIVE: POST /subscriptions cria a primeira chave
  [*] --> ACTIVE: POST /subscriptions/:id/keys/rotate cria o par novo
  ACTIVE --> ROTATING: chega uma rotação e esta chave vira a antiga
  ROTATING --> REVOKED: DELETE /subscriptions/:id/keys/:keyId
  ACTIVE --> REVOKED: DELETE /subscriptions/:id/keys/:keyId
  REVOKED --> [*]

Enquanto está em ROTATING, a chave continua listada junto com a ACTIVE em GET /subscriptions/:id/keys — é assim que o cliente mantém as duas no verificador dele durante a migração.

Atenção. Assinar sempre usa a ACTIVE mais recente; ROTATING existe só para verificação. A transição automática ROTATING → REVOKED ao expirar o validUntil está implementada mas não está agendada — ver §15.

Subcontas: revenda como SaaS

Quando a organização revende um building block, cada cliente dela é uma subconta (API Keys). O cliente cadastra o próprio endpoint com a chave da subconta (ou a organização o faz por ele, com X-Subaccount-Id), e a subscription nasce presa a ela (subaccountId).

EventoVai para
Evento de uma subconta (subaccountId na metadata ou no payload, como os biometrics.*)Endpoints daquela subconta e endpoints da organização
Evento sem subcontaSó endpoints da organização

A subconta A nunca recebe evento da B, e o metadata entregue traz o subaccountId. Os endpoints da organização recebem tudo — é por eles que o tenant audita os clientes.

No escopo de subconta, subscriptions, chaves de assinatura e logs de entrega alcançam só os da própria subconta (o resto responde 404), o nome da subscription é único dentro da subconta e o limite maxSubscriptions vale por subconta. A configuração da organização (/config) responde 403 SUBACCOUNT_SCOPE_FORBIDDEN. A assinatura (RSA-SHA256 sobre x-webhook-id\nx-webhook-timestamp\ncorpo) é a mesma para todos; GET /public-keys devolve, numa chamada, as chaves públicas que verificam os webhooks do escopo.


09

Referência da API

Prefixo real: /webhooks-engine. Em staging, a base é https://webhooks.bb.stg.catalisa.app.

Todos os 18 endpoints aceitam chave de subconta e X-Subaccount-Id (ver Subcontas) e exigem authMiddleware (Bearer JWT do IAM) e requireOrganization — token sem organizationId recebe 403.

Subscriptions — /webhooks-engine/api/v1/subscriptions

MétodoRotaDescriçãoPermissão
POST/webhooks-engine/api/v1/subscriptionsCria subscription e gera o primeiro par de chavesWEBHOOKS_SUBSCRIPTIONS_CREATE
GET/webhooks-engine/api/v1/subscriptionsLista, paginado, filtros filter[status] e filter[subaccountId]WEBHOOKS_SUBSCRIPTIONS_READ
GET/webhooks-engine/api/v1/subscriptions/:idBusca por UUIDWEBHOOKS_SUBSCRIPTIONS_READ
PATCH/webhooks-engine/api/v1/subscriptions/:idAtualiza URL, filtros, timeout, retentativa, headersWEBHOOKS_SUBSCRIPTIONS_UPDATE
DELETE/webhooks-engine/api/v1/subscriptions/:idExclusão lógica. Responde 204WEBHOOKS_SUBSCRIPTIONS_DELETE
POST/webhooks-engine/api/v1/subscriptions/:id/pauseACTIVE → PAUSED. Para de receber eventos novosWEBHOOKS_SUBSCRIPTIONS_UPDATE
POST/webhooks-engine/api/v1/subscriptions/:id/resumePAUSED → ACTIVEWEBHOOKS_SUBSCRIPTIONS_UPDATE
POST/webhooks-engine/api/v1/subscriptions/:id/testEnvia um webhook.test assinado, síncrono. Não grava log de entregaWEBHOOKS_SUBSCRIPTIONS_READ

Chaves de assinatura — /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys

MétodoRotaDescriçãoPermissão
GET/webhooks-engine/api/v1/subscriptions/:subscriptionId/keysLista chaves ACTIVE e ROTATING com as públicas em PEMWEBHOOKS_KEYS_READ
GET/webhooks-engine/api/v1/subscriptions/:subscriptionId/keys/:keyIdBusca uma chave pelo keyId (whk_...)WEBHOOKS_KEYS_READ
POST/webhooks-engine/api/v1/subscriptions/:subscriptionId/keys/rotateCria chave nova; antiga vira ROTATING. Responde 201WEBHOOKS_KEYS_ROTATE
DELETE/webhooks-engine/api/v1/subscriptions/:subscriptionId/keys/:keyIdRevoga imediatamente. Responde 200 com a chave revogadaWEBHOOKS_KEYS_REVOKE

Chaves públicas do escopo — /webhooks-engine/api/v1/public-keys

MétodoRotaDescriçãoPermissão
GET/webhooks-engine/api/v1/public-keysChaves ACTIVE e ROTATING de todos os endpoints do escopo (a subconta vê só as dela), com keyId, subscriptionId e a pública em PEMWEBHOOKS_KEYS_READ

Logs de entrega — /webhooks-engine/api/v1/delivery-logs

MétodoRotaDescriçãoPermissão
GET/webhooks-engine/api/v1/delivery-logsLista, paginado, com filtrosWEBHOOKS_DELIVERIES_READ
GET/webhooks-engine/api/v1/delivery-logs/:idBusca um log, com payload e respostaWEBHOOKS_DELIVERIES_READ
POST/webhooks-engine/api/v1/delivery-logs/:id/retryReenvia. Só aceita FAILED ou RETRYINGWEBHOOKS_DELIVERIES_RETRY

Filtros aceitos em GET /delivery-logs: filter[subscriptionId] (UUID), filter[status], filter[eventType], filter[dateFrom] e filter[dateTo] (ambos ISO 8601 com hora).

Configuração da organização — /webhooks-engine/api/v1/config

MétodoRotaDescriçãoPermissão
GET/webhooks-engine/api/v1/configLê a configuração; cria com os padrões se não existirWEBHOOKS_CONFIG_MANAGE
PATCH/webhooks-engine/api/v1/configAltera retentionDays (1–365) e maxSubscriptions (1–1000)WEBHOOKS_CONFIG_MANAGE

Saúde

MétodoRotaDescrição
GET/webhooks-engine/healthSonda de vida. Não exige token.

POST /webhooks-engine/api/v1/subscriptions

Cria a subscription e gera o primeiro par de chaves na mesma operação. Aceita corpo plano ou envelope JSON:API.

Request

json
{
  "name": "ERP do cliente",
  "endpointUrl": "https://erp.cliente.com.br/webhooks/catalisa",
  "eventFilters": ["e-signature.*", "billing.invoice.paid"],
  "description": "Registro de contratos assinados",
  "timeoutMs": 15000,
  "retryConfig": {
    "maxRetries": 8,
    "retryBackoffMs": 5000,
    "retryBackoffMultiplier": 3
  },
  "customHeaders": { "X-Cliente-Token": "abc123" }
}
{
  "name": "ERP do cliente",
  "endpointUrl": "https://erp.cliente.com.br/webhooks/catalisa",
  "eventFilters": ["e-signature.*", "billing.invoice.paid"],
  "description": "Registro de contratos assinados",
  "timeoutMs": 15000,
  "retryConfig": {
    "maxRetries": 8,
    "retryBackoffMs": 5000,
    "retryBackoffMultiplier": 3
  },
  "customHeaders": { "X-Cliente-Token": "abc123" }
}
CampoTipoObrigatórioRegra
namestringSim1 a 100 caracteres. Único por organização
endpointUrlstring (URL)SimHTTPS obrigatório em produção. Reprovado se resolver para faixa privada, loopback, link-local ou metadados de nuvem
eventFiltersstring[]SimAo menos 1. Cada item: *, namespace.* ou tipo exato
descriptionstringNãoAté 500 caracteres
timeoutMsintNão1000 a 60000. Padrão WEBHOOK_DEFAULT_TIMEOUT_MS (30000)
retryConfig.maxRetriesintNão0 a 10. Padrão 5
retryConfig.retryBackoffMsintNão100 a 60000. Padrão 1000
retryConfig.retryBackoffMultipliernumberNão1 a 5. Padrão 2.0
customHeadersobjectNãoMapa string→string, enviado em toda entrega. Guardado em texto claro e aplicado depois dos headers de assinatura — ver §14 e §15

Resposta 201

json
{
  "data": {
    "type": "webhook-subscriptions",
    "id": "d41c9e10-7b3a-4a55-9f2e-2c8b1a0d5511",
    "links": { "self": "/webhooks-engine/api/v1/subscriptions/d41c9e10-..." },
    "attributes": {
      "name": "ERP do cliente",
      "endpointUrl": "https://erp.cliente.com.br/webhooks/catalisa",
      "eventFilters": ["e-signature.*", "billing.invoice.paid"],
      "status": "ACTIVE",
      "timeoutMs": 15000,
      "retryConfig": { "maxRetries": 8, "retryBackoffMs": 5000, "retryBackoffMultiplier": 3 }
    }
  }
}
{
  "data": {
    "type": "webhook-subscriptions",
    "id": "d41c9e10-7b3a-4a55-9f2e-2c8b1a0d5511",
    "links": { "self": "/webhooks-engine/api/v1/subscriptions/d41c9e10-..." },
    "attributes": {
      "name": "ERP do cliente",
      "endpointUrl": "https://erp.cliente.com.br/webhooks/catalisa",
      "eventFilters": ["e-signature.*", "billing.invoice.paid"],
      "status": "ACTIVE",
      "timeoutMs": 15000,
      "retryConfig": { "maxRetries": 8, "retryBackoffMs": 5000, "retryBackoffMultiplier": 3 }
    }
  }
}

A resposta não traz a chave. Busque em GET /subscriptions/:id/keys logo depois.

Erros

StatusQuando
400VALIDATION — URL insegura ou não-HTTPS em produção, padrão de filtro inválido, timeout fora da faixa, ou limite de subscriptions da organização atingido
403Sem WEBHOOKS_SUBSCRIPTIONS_CREATE, ou token sem organizationId
409name já existe nesta organização

GET /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys

Devolve as chaves em uso — ACTIVE e, se houver rotação em andamento, a ROTATING. É a chamada que o seu cliente faz para configurar o verificador dele.

Resposta 200

json
{
  "data": [
    {
      "type": "webhook-signing-keys",
      "id": "0f8b2c31-...",
      "attributes": {
        "keyId": "whk_9jK2mQx7Rt4B",
        "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...\n-----END PUBLIC KEY-----\n",
        "status": "ACTIVE",
        "validFrom": "2026-08-16T12:00:00.000Z"
      }
    },
    {
      "type": "webhook-signing-keys",
      "id": "7c1e4a09-...",
      "attributes": {
        "keyId": "whk_3pR8vNz1Wq6C",
        "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...\n-----END PUBLIC KEY-----\n",
        "status": "ROTATING",
        "validFrom": "2025-11-02T09:14:00.000Z",
        "validUntil": "2026-08-23T12:00:00.000Z"
      }
    }
  ]
}
{
  "data": [
    {
      "type": "webhook-signing-keys",
      "id": "0f8b2c31-...",
      "attributes": {
        "keyId": "whk_9jK2mQx7Rt4B",
        "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...\n-----END PUBLIC KEY-----\n",
        "status": "ACTIVE",
        "validFrom": "2026-08-16T12:00:00.000Z"
      }
    },
    {
      "type": "webhook-signing-keys",
      "id": "7c1e4a09-...",
      "attributes": {
        "keyId": "whk_3pR8vNz1Wq6C",
        "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...\n-----END PUBLIC KEY-----\n",
        "status": "ROTATING",
        "validFrom": "2025-11-02T09:14:00.000Z",
        "validUntil": "2026-08-23T12:00:00.000Z"
      }
    }
  ]
}

publicKey é PEM no formato SPKI, pronto para crypto.createVerify(...).verify(pem, sig, 'base64') em Node, ou para load_pem_public_key em Python.


POST /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys/rotate

Corpo opcional:

json
{ "gracePeriodHours": 48 }
{ "gracePeriodHours": 48 }

gracePeriodHours aceita 1 a 720 (30 dias). Omitido, usa WEBHOOK_KEY_GRACE_PERIOD_HOURS (padrão 168 h = 7 dias).

Resposta 201

json
{
  "data": {
    "type": "key-rotation-result",
    "attributes": {
      "newKey": {
        "keyId": "whk_5tY9bLm2Xk8D",
        "status": "ACTIVE",
        "publicKey": "-----BEGIN PUBLIC KEY-----..."
      },
      "oldKey": {
        "keyId": "whk_9jK2mQx7Rt4B",
        "status": "ROTATING",
        "validUntil": "2026-08-18T12:00:00.000Z"
      }
    }
  }
}
{
  "data": {
    "type": "key-rotation-result",
    "attributes": {
      "newKey": {
        "keyId": "whk_5tY9bLm2Xk8D",
        "status": "ACTIVE",
        "publicKey": "-----BEGIN PUBLIC KEY-----..."
      },
      "oldKey": {
        "keyId": "whk_9jK2mQx7Rt4B",
        "status": "ROTATING",
        "validUntil": "2026-08-18T12:00:00.000Z"
      }
    }
  }
}

Na primeira rotação de uma subscription sem chave anterior, oldKey volta null. As entregas passam a usar a chave nova imediatamente — o período de graça vale para o cliente aceitar a antiga, não para adiar o uso da nova.


GET /webhooks-engine/api/v1/delivery-logs

Exemplo

texto
GET /webhooks-engine/api/v1/delivery-logs
    ?filter[status]=FAILED
    &filter[eventType]=e-signature.document.signed
    &filter[dateFrom]=2026-08-01T00:00:00Z
    &page[number]=1&page[size]=50
GET /webhooks-engine/api/v1/delivery-logs
    ?filter[status]=FAILED
    &filter[eventType]=e-signature.document.signed
    &filter[dateFrom]=2026-08-01T00:00:00Z
    &page[number]=1&page[size]=50

Resposta 200 (um item)

json
{
  "data": [{
    "type": "webhook-delivery-logs",
    "id": "a7b3c8d9-...",
    "attributes": {
      "subscriptionId": "d41c9e10-...",
      "eventId": "evt_01J9X4TQ",
      "eventType": "e-signature.document.signed",
      "payload": {
        "id": "evt_01J9X4TQ",
        "type": "e-signature.document.signed",
        "data": {},
        "metadata": {}
      },
      "status": "FAILED",
      "attemptCount": 5,
      "lastAttemptAt": "2026-08-16T03:14:22.100Z",
      "responseStatusCode": 503,
      "responseBody": "<html><body>Service Unavailable</body></html>",
      "responseTimeMs": 15012,
      "errorMessage": "HTTP 503"
    }
  }],
  "meta": { "totalItems": 12, "totalPages": 1, "currentPage": 1, "itemsPerPage": 50 }
}
{
  "data": [{
    "type": "webhook-delivery-logs",
    "id": "a7b3c8d9-...",
    "attributes": {
      "subscriptionId": "d41c9e10-...",
      "eventId": "evt_01J9X4TQ",
      "eventType": "e-signature.document.signed",
      "payload": {
        "id": "evt_01J9X4TQ",
        "type": "e-signature.document.signed",
        "data": {},
        "metadata": {}
      },
      "status": "FAILED",
      "attemptCount": 5,
      "lastAttemptAt": "2026-08-16T03:14:22.100Z",
      "responseStatusCode": 503,
      "responseBody": "<html><body>Service Unavailable</body></html>",
      "responseTimeMs": 15012,
      "errorMessage": "HTTP 503"
    }
  }],
  "meta": { "totalItems": 12, "totalPages": 1, "currentPage": 1, "itemsPerPage": 50 }
}

responseBody é truncado em 1024 bytes. nextRetryAt só aparece quando o status é RETRYING.


POST /webhooks-engine/api/v1/delivery-logs/:id/retry

Reenvia. Gera novo messageId e nova assinatura, com o mesmo payload.

StatusQuando
200Reenviado; o corpo traz o log com o status resultante
400VALIDATION — o log não está em FAILED nem em RETRYING
404Log inexistente ou de outra organização

Reenviar um log que já esgotou as tentativas dá exatamente uma tentativa a mais: como attemptCount já atingiu maxRetries, o resultado volta a FAILED se falhar de novo.


Headers enviados em cada entrega

Toda requisição de saída é POST com Content-Type: application/json e User-Agent: CatalisaWebhooks/1.0, mais:

HeaderExemploDescrição
x-webhook-idmsg_5f9a3c1e8b7d4a2f...Identificador desta tentativa. Muda a cada retentativa (§15)
x-webhook-timestamp2026-08-16T14:32:07.451ZISO 8601. Use para rejeitar payload antigo
x-webhook-signaturev1=Zm9vYmFy...v1= seguido da assinatura RSA-SHA256 em base64
x-webhook-key-idwhk_9jK2mQx7Rt4BQual chave assinou. Selecione a chave pública por este valor
x-webhook-versionv1Versão do esquema de assinatura

Corpo entregue — sempre esta forma:

json
{
  "id": "evt_01J9X4TQ",
  "type": "e-signature.document.signed",
  "data": { "documentId": "...", "signedAt": "..." },
  "metadata": {
    "timestamp": "2026-08-16T14:32:07.100Z",
    "correlationId": "req_8812",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }
}
{
  "id": "evt_01J9X4TQ",
  "type": "e-signature.document.signed",
  "data": { "documentId": "...", "signedAt": "..." },
  "metadata": {
    "timestamp": "2026-08-16T14:32:07.100Z",
    "correlationId": "req_8812",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }
}

O campo id é o identificador estável do evento e é o que você deve usar para idempotência — não o header x-webhook-id.


10

Início rápido

Do zero a um webhook assinado, entregue e verificado. Credenciais de staging conforme AMBIENTES.md.

Os comandos abaixo não foram executados na geração deste documento. Confira as respostas contra o seu ambiente.

flowchart LR
  P1["1 · Autenticar no IAM"] --> P2["2 · Preparar um destino público"]
  P2 --> P3["3 · Criar a subscription"]
  P3 --> P4["4 · Pegar a chave pública"]
  P4 --> P5["5 · Disparar um envio de teste"]
  P5 --> P6["6 · Provocar um evento real e ver o log"]

1. Autenticar no IAM

bash
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

export WH=https://webhooks.bb.stg.catalisa.app/webhooks-engine/api/v1
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

export WH=https://webhooks.bb.stg.catalisa.app/webhooks-engine/api/v1

Resposta esperada — o token na variável e a base da API exportada:

bash
echo "${TOKEN:0:24}..."   # eyJhbGciOiJIUzI1NiIsInR5...
echo "${TOKEN:0:24}..."   # eyJhbGciOiJIUzI1NiIsInR5...

2. Preparar um destino público para receber

Use um serviço de inspeção de requisições (por exemplo, uma URL de webhook.site) — o endpoint precisa ser HTTPS e resolver para um IP público. localhost e faixas privadas são reprovados de propósito.

bash
export DESTINO="https://webhook.site/SEU-UUID-AQUI"
export DESTINO="https://webhook.site/SEU-UUID-AQUI"

3. Criar a subscription

bash
SUB_ID=$(curl -s -X POST $WH/subscriptions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"name\": \"Teste de integração\",
    \"endpointUrl\": \"$DESTINO\",
    \"eventFilters\": [\"iam.*\"]
  }" | jq -r '.data.id')

echo "$SUB_ID"
SUB_ID=$(curl -s -X POST $WH/subscriptions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"name\": \"Teste de integração\",
    \"endpointUrl\": \"$DESTINO\",
    \"eventFilters\": [\"iam.*\"]
  }" | jq -r '.data.id')

echo "$SUB_ID"

Resposta esperada — o UUID da subscription recém-criada:

text
d41c9e10-7b3a-4a55-9f2e-2c8b1a0d5511
d41c9e10-7b3a-4a55-9f2e-2c8b1a0d5511

Atenção. A resposta da criação não traz a chave. Ela vem no passo seguinte.

4. Pegar a chave pública

bash
curl -s $WH/subscriptions/$SUB_ID/keys -H "Authorization: Bearer $TOKEN" \
  | jq -r '.data[] | {keyId: .attributes.keyId, status: .attributes.status}'
curl -s $WH/subscriptions/$SUB_ID/keys -H "Authorization: Bearer $TOKEN" \
  | jq -r '.data[] | {keyId: .attributes.keyId, status: .attributes.status}'
json
{ "keyId": "whk_9jK2mQx7Rt4B", "status": "ACTIVE" }
{ "keyId": "whk_9jK2mQx7Rt4B", "status": "ACTIVE" }

5. Disparar um envio de teste

bash
curl -s -X POST $WH/subscriptions/$SUB_ID/test -H "Authorization: Bearer $TOKEN" | jq
curl -s -X POST $WH/subscriptions/$SUB_ID/test -H "Authorization: Bearer $TOKEN" | jq
json
{ "data": { "type": "webhook-test-result",
            "attributes": { "success": true, "responseStatusCode": 200, "responseTimeMs": 214 } } }
{ "data": { "type": "webhook-test-result",
            "attributes": { "success": true, "responseStatusCode": 200, "responseTimeMs": 214 } } }

Olhe o destino: chegou um POST com corpo {"id":"test_...","type":"webhook.test",...} e os cinco headers x-webhook-*.

6. Provocar um evento real e ver o log

Qualquer ação no IAM publica um evento iam.* que casa com o filtro. Depois:

bash
curl -s "$WH/delivery-logs?filter[subscriptionId]=$SUB_ID" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {eventType: .attributes.eventType, status: .attributes.status,
                   http: .attributes.responseStatusCode, ms: .attributes.responseTimeMs}'
curl -s "$WH/delivery-logs?filter[subscriptionId]=$SUB_ID" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {eventType: .attributes.eventType, status: .attributes.status,
                   http: .attributes.responseStatusCode, ms: .attributes.responseTimeMs}'
json
{ "eventType": "iam.user.created", "status": "DELIVERED", "http": 200, "ms": 187 }
{ "eventType": "iam.user.created", "status": "DELIVERED", "http": 200, "ms": 187 }

O envio de teste do passo 5 é síncrono e não cria log de entrega. Só eventos vindos do barramento aparecem em /delivery-logs.


11

Receitas

Verificar a assinatura do lado do cliente

Objetivo. Confirmar que o evento veio da Catalisa e não foi adulterado.

A string assinada é exatamente messageId + \n + timestamp + \n + corpo cru. O corpo precisa ser o byte a byte recebido — se o seu framework fizer parse e re-serializar o JSON, a verificação falha.

flowchart TD
  R["Requisição recebida"] --> H["Tem os quatro headers?<br/>id, timestamp, key-id e signature"]
  H -->|"não"| NO["Recuse"]
  H -->|"sim"| T["Timestamp dentro da sua tolerância?<br/>5 minutos é a recomendação do Stripe"]
  T -->|"não"| NO
  T -->|"sim"| K["Achou a pública no mapa, pelo x-webhook-key-id?"]
  K -->|"não"| NO
  K -->|"sim"| V["Prefixo é v1= ?"]
  V -->|"não"| NO
  V -->|"sim"| S["Verifica RSA-SHA256 sobre<br/>messageId, timestamp e CORPO CRU"]
  S -->|"inválida"| NO
  S -->|"válida"| OK["Processe — idempotência pelo id do corpo"]

Node.js

js
const crypto = require('crypto')

// publicKeys: { [keyId]: pemSpki } — carregue de GET /subscriptions/:id/keys
function verificarWebhook(headers, rawBody, publicKeys, toleranciaSegundos = 300) {
  const messageId = headers['x-webhook-id']
  const timestamp = headers['x-webhook-timestamp']
  const keyId     = headers['x-webhook-key-id']
  const assinada  = headers['x-webhook-signature'] // "v1=<base64>"

  if (!messageId || !timestamp || !keyId || !assinada) return false

  // 1. Rejeitar payload antigo (proteção contra replay)
  const idadeMs = Date.now() - Date.parse(timestamp)
  if (!Number.isFinite(idadeMs) || Math.abs(idadeMs) > toleranciaSegundos * 1000) return false

  // 2. Selecionar a chave pública pelo keyId — nunca assuma que é sempre a mesma
  const pem = publicKeys[keyId]
  if (!pem) return false

  // 3. Conferir o prefixo de versão
  const [versao, assinaturaB64] = assinada.split('=', 2)
  if (versao !== 'v1' || !assinaturaB64) return false

  // 4. Verificar RSA-SHA256 sobre "messageId\ntimestamp\nbody"
  const payload = `${messageId}\n${timestamp}\n${rawBody}`
  const verify = crypto.createVerify('RSA-SHA256')
  verify.update(payload)
  verify.end()

  return verify.verify(pem, assinaturaB64, 'base64')
}
const crypto = require('crypto')

// publicKeys: { [keyId]: pemSpki } — carregue de GET /subscriptions/:id/keys
function verificarWebhook(headers, rawBody, publicKeys, toleranciaSegundos = 300) {
  const messageId = headers['x-webhook-id']
  const timestamp = headers['x-webhook-timestamp']
  const keyId     = headers['x-webhook-key-id']
  const assinada  = headers['x-webhook-signature'] // "v1=<base64>"

  if (!messageId || !timestamp || !keyId || !assinada) return false

  // 1. Rejeitar payload antigo (proteção contra replay)
  const idadeMs = Date.now() - Date.parse(timestamp)
  if (!Number.isFinite(idadeMs) || Math.abs(idadeMs) > toleranciaSegundos * 1000) return false

  // 2. Selecionar a chave pública pelo keyId — nunca assuma que é sempre a mesma
  const pem = publicKeys[keyId]
  if (!pem) return false

  // 3. Conferir o prefixo de versão
  const [versao, assinaturaB64] = assinada.split('=', 2)
  if (versao !== 'v1' || !assinaturaB64) return false

  // 4. Verificar RSA-SHA256 sobre "messageId\ntimestamp\nbody"
  const payload = `${messageId}\n${timestamp}\n${rawBody}`
  const verify = crypto.createVerify('RSA-SHA256')
  verify.update(payload)
  verify.end()

  return verify.verify(pem, assinaturaB64, 'base64')
}

Python

python
import base64, time
from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature

def verificar_webhook(headers, raw_body: bytes, public_keys: dict, tolerancia_s: int = 300) -> bool:
    message_id = headers.get("x-webhook-id")
    timestamp  = headers.get("x-webhook-timestamp")
    key_id     = headers.get("x-webhook-key-id")
    assinada   = headers.get("x-webhook-signature", "")

    if not all([message_id, timestamp, key_id, assinada]):
        return False

    enviado = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    if abs(time.time() - enviado.timestamp()) > tolerancia_s:
        return False

    pem = public_keys.get(key_id)
    if pem is None:
        return False

    versao, _, assinatura_b64 = assinada.partition("=")
    if versao != "v1" or not assinatura_b64:
        return False

    payload = f"{message_id}\n{timestamp}\n".encode() + raw_body
    chave = serialization.load_pem_public_key(pem.encode())

    try:
        chave.verify(base64.b64decode(assinatura_b64), payload,
                     padding.PKCS1v15(), hashes.SHA256())
        return True
    except InvalidSignature:
        return False
import base64, time
from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature

def verificar_webhook(headers, raw_body: bytes, public_keys: dict, tolerancia_s: int = 300) -> bool:
    message_id = headers.get("x-webhook-id")
    timestamp  = headers.get("x-webhook-timestamp")
    key_id     = headers.get("x-webhook-key-id")
    assinada   = headers.get("x-webhook-signature", "")

    if not all([message_id, timestamp, key_id, assinada]):
        return False

    enviado = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    if abs(time.time() - enviado.timestamp()) > tolerancia_s:
        return False

    pem = public_keys.get(key_id)
    if pem is None:
        return False

    versao, _, assinatura_b64 = assinada.partition("=")
    if versao != "v1" or not assinatura_b64:
        return False

    payload = f"{message_id}\n{timestamp}\n".encode() + raw_body
    chave = serialization.load_pem_public_key(pem.encode())

    try:
        chave.verify(base64.b64decode(assinatura_b64), payload,
                     padding.PKCS1v15(), hashes.SHA256())
        return True
    except InvalidSignature:
        return False

Armadilhas.

  • Use o corpo cru. Em Express, express.raw({ type: 'application/json' }) na rota de webhook; em Django, request.body; em Rails, request.raw_post. Middleware que faz parse e re-serializa quebra a verificação — é a causa número um de "assinatura inválida".
  • Selecione a chave por x-webhook-key-id. Guardar uma única chave pública funciona até a primeira rotação e falha silenciosamente depois. Guarde um mapa.
  • A tolerância de timestamp é sua. O BB não recusa payload antigo — quem recusa é você. Cinco minutos é a recomendação do Stripe e é um bom padrão.
  • Não confunda x-webhook-id com o id do corpo. O primeiro muda a cada tentativa; o segundo é estável. Idempotência usa o segundo.

Tornar o seu receptor idempotente

Objetivo. Processar o mesmo evento duas vezes sem efeito duplicado.

Retentativas e reenvios manuais fazem o mesmo evento chegar mais de uma vez. O identificador estável é payload.id.

flowchart LR
  E1["1ª entrega<br/>x-webhook-id msg_aaa · corpo id evt_01J9X4TQ"] --> DB[("Tabela de eventos processados<br/>chave única pelo id do corpo")]
  E2["Retentativa<br/>x-webhook-id msg_bbb · corpo id evt_01J9X4TQ"] --> DB
  E3["Reenvio manual<br/>x-webhook-id msg_ccc · corpo id evt_01J9X4TQ"] --> DB
  DB -->|"primeira vez"| AP["Aplica o efeito"]
  DB -->|"violação de chave única"| SK["Devolve 200 e não faz nada"]

Atenção. O header x-webhook-id muda a cada linha do desenho acima; o id do corpo não muda. Deduplicar pelo header nunca funciona.

js
// Guarde os IDs já processados. Chave única no banco resolve a corrida.
async function processar(evento) {
  try {
    await db.eventosProcessados.create({ id: evento.id, recebidoEm: new Date() })
  } catch (e) {
    if (e.code === 'P2002') return // já processado — devolva 200 e não faça nada
    throw e
  }
  await aplicarEfeito(evento)
}
// Guarde os IDs já processados. Chave única no banco resolve a corrida.
async function processar(evento) {
  try {
    await db.eventosProcessados.create({ id: evento.id, recebidoEm: new Date() })
  } catch (e) {
    if (e.code === 'P2002') return // já processado — devolva 200 e não faça nada
    throw e
  }
  await aplicarEfeito(evento)
}

Armadilhas.

  • Nunca use x-webhook-id como chave de idempotência. Ele é regenerado a cada tentativa, então a deduplicação nunca acontece (§15).
  • Não há garantia de ordem. documento.assinado pode chegar antes de documento.criado. Escreva o receptor para tolerar isso, ou busque o estado atual pela API em vez de derivá-lo da sequência de eventos.
  • Devolva 2xx rápido. O timeout padrão é 30 segundos, mas processar de forma síncrona significa que um pico de eventos vira um pico de carga. Enfileire e responda.

Configurar uma janela de retentativa longa

Objetivo. Aguentar uma indisponibilidade de horas, não de minutos.

O padrão (5 tentativas, base 1 s, multiplicador 2) tem janela nominal de ~15 segundos e, na prática, de cerca de 4 minutos por causa do tique de 60 s do job. Para uma janela de horas:

bash
curl -s -X PATCH $WH/subscriptions/$SUB_ID \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"retryConfig":{"maxRetries":10,"retryBackoffMs":60000,"retryBackoffMultiplier":3}}'
curl -s -X PATCH $WH/subscriptions/$SUB_ID \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"retryConfig":{"maxRetries":10,"retryBackoffMs":60000,"retryBackoffMultiplier":3}}'

Atrasos resultantes, com retryBackoffMs: 60000 e multiplicador 3:

TentativaAtraso calculadoAplicado
1ª falhou1 min1 min
2ª falhou3 min3 min
3ª falhou9 min9 min
4ª falhou27 min27 min
5ª falhou81 min60 min — o teto de 1 hora entra aqui
6ª a 9ª falharamacima do teto60 min cada
10ª falhou—maxRetries atingido → FAILED

Janela total de aproximadamente 6 horas em 10 tentativas.

Armadilhas.

  • Os limites são rígidos: maxRetries ≤ 10, retryBackoffMs ≤ 60000 e retryBackoffMultiplier ≤ 5. O teto absoluto de atraso é 1 hora por tentativa, independentemente da conta.
  • retryConfig é mesclado, não substituído: mandar só maxRetries preserva os outros dois campos.
  • Alterar a política não afeta entregas já em RETRYING — elas já têm o nextRetryAt calculado com a política antiga.

Reprocessar em lote as entregas que falharam

Objetivo. Depois que o servidor do cliente voltou, reenviar tudo que ficou para trás.

flowchart LR
  A["1 · Confirme que o endpoint voltou"] --> B["2 · Liste os logs FAILED da janela"]
  B --> C["3 · Reenvie um por um, com pausa entre as chamadas"]
  C --> D["4 · Repita a busca até esvaziar<br/>a página máxima é 100"]

1. Listar os logs que ficaram para trás

bash
curl -s "$WH/delivery-logs?filter[subscriptionId]=$SUB_ID&filter[status]=FAILED\
&filter[dateFrom]=2026-08-16T00:00:00Z&page[size]=100" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data[].id' | tee /tmp/logs-falhados.txt | wc -l
curl -s "$WH/delivery-logs?filter[subscriptionId]=$SUB_ID&filter[status]=FAILED\
&filter[dateFrom]=2026-08-16T00:00:00Z&page[size]=100" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data[].id' | tee /tmp/logs-falhados.txt | wc -l

Resposta esperada — quantos logs entraram na lista:

text
37
37

2. Reenviar cada um, com pausa entre as chamadas

bash
while read -r LOG_ID; do
  curl -s -X POST $WH/delivery-logs/$LOG_ID/retry \
    -H "Authorization: Bearer $TOKEN" | jq -r '.data.attributes.status'
  sleep 0.2
done < /tmp/logs-falhados.txt
while read -r LOG_ID; do
  curl -s -X POST $WH/delivery-logs/$LOG_ID/retry \
    -H "Authorization: Bearer $TOKEN" | jq -r '.data.attributes.status'
  sleep 0.2
done < /tmp/logs-falhados.txt

Resposta esperada — uma linha de status por reenvio:

text
DELIVERED
DELIVERED
FAILED
DELIVERED
DELIVERED
FAILED

Armadilhas.

  • Confirme que o endpoint voltou antes de disparar: cada reenvio de um log esgotado dá uma tentativa, e se falhar volta a FAILED sem novo agendamento.
  • Coloque a pausa entre as chamadas. Cem reenvios simultâneos contra um servidor que acabou de voltar é o segundo incidente.
  • Paginação máxima é 100 por página; repita a busca até esvaziar.

Pausar a entrega durante uma manutenção do cliente

Objetivo. Parar de bater no endpoint do cliente enquanto ele está em manutenção programada.

stateDiagram-v2
  ACTIVE: ACTIVE — recebe eventos novos
  PAUSED: PAUSED — não recebe eventos novos, e eles NÃO ficam guardados
  ACTIVE --> PAUSED: POST /subscriptions/:id/pause
  PAUSED --> ACTIVE: POST /subscriptions/:id/resume
  ACTIVE --> ACTIVE: retentativas já agendadas seguem rodando
  PAUSED --> PAUSED: retentativas já agendadas seguem rodando

1. Pausar antes da janela

bash
curl -s -X POST $WH/subscriptions/$SUB_ID/pause \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.attributes.status'
curl -s -X POST $WH/subscriptions/$SUB_ID/pause \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.attributes.status'

Resposta esperada:

text
PAUSED
PAUSED

2. Retomar quando o cliente voltar

bash
curl -s -X POST $WH/subscriptions/$SUB_ID/resume \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.attributes.status'
curl -s -X POST $WH/subscriptions/$SUB_ID/resume \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.attributes.status'

Resposta esperada:

text
ACTIVE
ACTIVE

Armadilhas.

  • Pausar impede entregas novas (o consumidor só considera subscriptions ACTIVE), mas não interrompe as retentativas já agendadas: o job de retentativa reprocessa logs em RETRYING sem consultar o status da subscription (§15).
  • Eventos que ocorrem enquanto a subscription está pausada não são enfileirados: eles simplesmente não geram entrega e não podem ser recuperados depois. Pausa não é buffer.
  • pause só aceita subscription ACTIVE e resume só aceita PAUSED; fora disso a resposta é 400.

12

Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token; fornece organizationId e as permissões WEBHOOKS_*Sim
Todos os demaisPublicam eventos no barramento; o consumidor decide quem recebeSim, como produtores
Audit TrailConsome o mesmo barramento para trilha interna; o Webhooks Engine leva para foraNão
WPP / WPP BusinessOs dispatchers criam e gerenciam subscriptions via IWebhooksEngineFacadeNão
SlackO SlackDispatcherService também consome a facadeNão
Feature FlagsPublica feature-flags.*; assine para saber quando uma flag mudouNão
flowchart TD
  IAM["IAM"]
  BIL["Billing"]
  PAY["Payments"]
  ESG["E-Signature"]
  FFL["Feature Flags"]
  ETC["… os demais, 31 BBs no total"]

  RS[("Redis Stream 'iam-events'")]

  WE["Webhooks Engine<br/>filtra por organização e por padrão<br/>assina RSA-SHA256<br/>entrega, retenta e registra"]

  ERP["ERP do cliente A<br/>filtro billing.*"]
  CRM["CRM do cliente B<br/>filtro e-signature.*"]

  IAM -->|"publish com metadata.organizationId"| RS
  BIL -->|"publish com metadata.organizationId"| RS
  PAY -->|"publish com metadata.organizationId"| RS
  ESG -->|"publish com metadata.organizationId"| RS
  FFL -->|"publish com metadata.organizationId"| RS
  ETC -->|"publish com metadata.organizationId"| RS
  RS --> WE
  WE --> ERP
  WE --> CRM

Este é o argumento comercial da Catalisa numa imagem. Um serviço externo de webhooks entra na figura depois do barramento: você ainda precisa escrever o consumidor, mapear o seu tenant para a "aplicação" dele e manter os dois em sincronia quando um cliente entra ou sai. Aqui, o organizationId já está no metadado do evento, e o cliente já é uma organização do IAM.

A facade compartilhada

IWebhooksEngineFacade, em src/shared/facades/webhooks-engine.facade.ts, é como outros BBs falam com este sem saber se ele está no mesmo processo ou atrás de HTTP:

ts
createSubscription(input, organizationId): ResultAsync<{ id: string }, AppError>
updateSubscription(id, input, organizationId): ResultAsync<void, AppError>
deleteSubscription(id, organizationId): ResultAsync<void, AppError>
pauseSubscription(id, organizationId) / resumeSubscription(id, organizationId)
getDeliveryLogs(filters, organizationId): ResultAsync<DeliveryLogPage, AppError>
retryDelivery(logId, organizationId): ResultAsync<void, AppError>
createSubscription(input, organizationId): ResultAsync<{ id: string }, AppError>
updateSubscription(id, input, organizationId): ResultAsync<void, AppError>
deleteSubscription(id, organizationId): ResultAsync<void, AppError>
pauseSubscription(id, organizationId) / resumeSubscription(id, organizationId)
getDeliveryLogs(filters, organizationId): ResultAsync<DeliveryLogPage, AppError>
retryDelivery(logId, organizationId): ResultAsync<void, AppError>
flowchart LR
  BB["WPP · WPP Business · Slack<br/>e qualquer outro BB"] --> IF["IWebhooksEngineFacade"]
  IF -->|"DEPLOYMENT_MODE monolith"| LO["LocalWebhooksEngineFacade<br/>chamada direta pelo TypeDI"]
  IF -->|"DEPLOYMENT_MODE standalone"| RE["RemoteWebhooksEngineFacade<br/>HTTP via ModuleClient para MODULE_WEBHOOKS_ENGINE_URL"]
  LO --> WE["Serviços do Webhooks Engine"]
  RE --> WE

O registerFacades() escolhe LocalWebhooksEngineFacade (chamada direta pelo TypeDI) em monolito e RemoteWebhooksEngineFacade (HTTP via ModuleClient) em standalone. Os dispatchers de WPP, WPP Business e Slack usam essa interface para expor "gerencie seus webhooks" dentro do próprio módulo, sem duplicar nem a assinatura nem a retentativa.

Eventos publicados pelo próprio BB

Também entregáveis por webhook:

EventoQuando
webhooks.subscription.created · .updated · .deletedCiclo de vida da subscription
webhooks.subscription.paused · .resumedPausa e retomada
webhooks.key.created · .rotated · .revokedCiclo de vida da chave
webhooks.delivery.succeededEntrega concluída com 2xx
webhooks.delivery.failedEntrega em estado terminal de falha

Não há evento para RETRYING — de propósito, para não gerar ruído a cada tentativa.


13

Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
WEBHOOK_MASTER_KEY64 caracteres hexadecimais (32 bytes). Cifra as chaves privadas em AES-256-GCM. Gere com openssl rand -hex 32. Sem ela, criar subscription e assinar falhamSim para operar—
WEBHOOK_DEFAULT_TIMEOUT_MSTimeout padrão de entrega quando a subscription não defineNão30000
WEBHOOK_KEY_GRACE_PERIOD_HOURSPeríodo de graça padrão da rotaçãoNão168 (7 dias)
DATABASE_URLPostgreSQL, schema webhooksSim—
REDIS_URLRedis. O main.ts faz preflight e encerra o processo se não conectarSim—
JWT_SECRETSegredo HS256 do IAM. Mínimo 44 caracteresSim—
DEPLOYMENT_MODEmonolith ou standaloneNãomonolith
MODULE_WEBHOOKS_ENGINE_URLEndereço deste BB, lido por outros BBs em standaloneSó em standalone—
PORTPorta no modo standaloneNão3000 (mapeada para 3013 no compose)

Três variáveis estão declaradas no schema de configuração mas não são lidas por nenhum código: WEBHOOK_CONSUMER_ENABLED, WEBHOOK_DEFAULT_MAX_RETRIES e WEBHOOK_RETRY_JOB_INTERVAL_MS. Definir qualquer uma delas não muda comportamento. Ver §15.

Dependências de infraestrutura

DependênciaPara quêSe cair
PostgreSQLSchema webhooksAPI e entrega param
Redis StreamsBarramento iam-events, grupo webhook-delivery-consumersEventos param de ser consumidos; o processo não sobe se o preflight falhar
IAMVerificação do token (assinatura local, sem chamada de rede)Nada muda com o JWT_SECRET correto
WEBHOOK_MASTER_KEYCifrar e decifrar chaves privadasCriar subscription e assinar falham com 500

Processos de segundo plano

Só sobem no main.ts do webhooks-engine:

ProcessoIntervaloO que faz
WebhookConsumerContínuo (BLOCK de 5 s)Lê o stream, filtra e dispara entregas. Reclama mensagens ociosas há mais de 30 s; descarta após 3 reprocessamentos
DeliveryRetryJob60 s (fixo no código)Busca até 100 logs RETRYING com nextRetryAt <= agora e reenvia
LogRetentionJob24 hApaga logs de entrega além de retentionDays, organização por organização

Limites e quotas

LimiteValorOnde
Subscriptions por organização50 (ajustável de 1 a 1000)OrganizationWebhookConfig.maxSubscriptions
Retenção do log de entrega30 dias (ajustável de 1 a 365)OrganizationWebhookConfig.retentionDays
Nome da subscription1 a 100 caracteres, único por organizaçãocreateSubscriptionSchema
DescriçãoAté 500 caracterescreateSubscriptionSchema
Timeout de entrega1000 a 60000 mscreateSubscriptionSchema
maxRetries0 a 10createSubscriptionSchema
retryBackoffMs100 a 60000 mscreateSubscriptionSchema
retryBackoffMultiplier1 a 5createSubscriptionSchema
Teto do atraso entre tentativas1 horaMAX_RETRY_DELAY_MS
Corpo de resposta gravado1024 bytes (truncado)MAX_RESPONSE_BODY_LENGTH
Período de graça na rotação1 a 720 horasrotateKeySchema
Lote do job de retentativa100 logs por tiquefindPendingRetries
Leitura do stream por ciclo10 mensagensWebhookConsumer
Página máxima em listagens100 itensPaginação compartilhada

Catálogo de erros

StatusCódigoSignificaO que fazer
400VALIDATIONURL insegura ou não-HTTPS em produção; padrão de filtro inválido; timeout ou retentativa fora da faixa; limite de subscriptions atingido; pause/resume em status errado; retry de log que não está FAILED/RETRYING; chave já revogadaA mensagem diz qual regra falhou. Para URL, ver §14
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove o token no IAM
403FORBIDDENPermissão faltando ou token sem organizationIdConfira permissions no token
404NOT_FOUNDSubscription, chave ou log inexistente, excluído, ou de outra organizaçãoRecurso de outro tenant também responde 404, por design
409CONFLICTname de subscription repetido na organizaçãoEscolha outro nome
500INTERNALWEBHOOK_MASTER_KEY ausente ou malformada; falha ao decifrar a chave privada; subscription sem chave ACTIVEConfira a variável de ambiente e rotacione a chave da subscription

O que o log de entrega registra

Cada linha guarda attemptCount, lastAttemptAt, nextRetryAt, responseStatusCode, responseBody (1 KB), responseTimeMs, errorMessage e o payload completo. É a base para responder as três perguntas do suporte: chegou? o que o servidor do cliente respondeu? quando é a próxima tentativa?

Observabilidade

  • GET /webhooks-engine/health responde com nome e versão do build. Não verifica banco, Redis nem o consumidor — 200 aqui não significa que eventos estão sendo entregues. Para saber isso, olhe se há logs de entrega recentes.
  • Tentativas de SSRF geram SSRF_BLOCKED no log de segurança, com organizationId, URL tentada e motivo.
  • Bloqueios por DNS rebinding aparecem como warn com hostname, resolvedIP e motivo.
  • O consumidor registra em info o resultado de cada ciclo, e em warn mensagens descartadas por excesso de reprocessamento.
  • Métrica de saúde mais útil na prática: contagem de logs em FAILED por subscription nas últimas 24 horas. Um endpoint de cliente que quebrou aparece aqui antes de virar chamado.

14

Segurança e compliance

Isolamento entre tenants

Os 17 endpoints aplicam authMiddleware → requirePermission(...) → requireOrganization. O organizationId sai do claim assinado do JWT e é passado explicitamente a cada método de repositório; nenhuma rota o lê do corpo ou da query.

flowchart TD
  REQ["GET /subscriptions/:subscriptionId/keys/:keyId"] --> A["authMiddleware<br/>verifica o JWT do IAM"]
  A --> P["requirePermission WEBHOOKS_KEYS_READ"]
  P --> O["requireOrganization<br/>403 sem organizationId no token"]
  O --> S["getSubscription(subscriptionId, organizationId)<br/>resolve a subscription PRIMEIRO"]
  S -->|"não é desta organização"| NF["404, não 403<br/>para não confirmar existência"]
  S -->|"é desta organização"| K["findBySubscriptionAndKeyId<br/>escopado nas duas dimensões"]
  K --> OK["Chave pública devolvida"]

As rotas de chave são as mais interessantes: mesmo sendo aninhadas em /subscriptions/:subscriptionId/keys, elas primeiro resolvem a subscription com getSubscription(subscriptionId, organizationId) e só depois tocam a chave — então não há caminho em que um keyId conhecido de outro tenant devolva material criptográfico. A busca é findBySubscriptionAndKeyId, escopada nas duas dimensões. Recurso de outra organização responde 404, não 403, para não confirmar existência.

Como o segredo de assinatura é guardado

Cada subscription tem um par RSA-2048 gerado na criação:

flowchart TD
  G["generateKeyPair — jose, RS256, modulusLength 2048"]
  G --> PUB["publicKey em PEM SPKI"]
  G --> PRI["privateKey em PEM PKCS8"]
  PUB --> COLP["coluna public_key, EM CLARO<br/>é público por definição e é servido pela API"]
  PRI --> ENC["encryptPrivateKey<br/>AES-256-GCM, IV de 12 bytes aleatório<br/>chave = WEBHOOK_MASTER_KEY, 32 bytes"]
  ENC --> ENV["envelope 'iv:authTag:ciphertext', tudo em hexadecimal"]
  ENV --> COLE["coluna encrypted_key"]

Três propriedades que importam para uma auditoria:

  1. A chave-mestra não está no banco. Ela é WEBHOOK_MASTER_KEY, 64 caracteres hexadecimais, injetada por ambiente e gerenciada como segredo de infraestrutura (SOPS). Um dump do banco entrega os textos cifrados e nenhuma capacidade de assinar.
  2. O modo é autenticado. GCM produz um authTag; adulterar o texto cifrado no banco faz a decifragem falhar em vez de devolver uma chave corrompida. O serviço responde 500 em vez de assinar com lixo.
  3. O IV é aleatório por operação. Doze bytes de randomBytes por cifragem, guardados junto ao texto cifrado. Não há reuso de IV, que é a falha clássica de AES-GCM.

A chave privada nunca sai do processo: signWebhookPayload decifra em memória, assina e descarta. Não há endpoint que a exponha, e nenhuma resposta da API a contém.

Como o cliente verifica a assinatura do nosso lado

O cliente busca a chave pública em GET /subscriptions/:id/keys — ele nunca recebe segredo compartilhado. Cada entrega leva:

HeaderExemplo
x-webhook-idmsg_5f9a3c1e8b7d4a2f9c0e1b3d5a7f9021
x-webhook-timestamp2026-08-16T14:32:07.451Z
x-webhook-key-idwhk_9jK2mQx7Rt4B
x-webhook-versionv1
x-webhook-signaturev1= seguido da assinatura RSA-SHA256 em base64

A string assinada é a concatenação exata destes três pedaços, nesta ordem:

text
x-webhook-id  "\n"  x-webhook-timestamp  "\n"  corpo cru
x-webhook-id  "\n"  x-webhook-timestamp  "\n"  corpo cru
sequenceDiagram
  participant WE as Webhooks Engine
  participant CL as Receptor do cliente
  WE->>CL: POST com os cinco headers x-webhook-*
  CL->>CL: 1 · escolhe a chave pública pelo x-webhook-key-id
  CL->>CL: 2 · confere o prefixo de versão v1=
  CL->>CL: 3 · remonta a string id, timestamp e corpo cru
  CL->>CL: 4 · verifica RSA-SHA256 com a chave pública
  CL->>CL: 5 · rejeita se o timestamp for antigo, tolerância dele
  CL-->>WE: 200 rápido, processamento assíncrono

Verificação mínima, em Node:

js
const crypto = require('crypto')

// pem = publicKey obtida em GET /subscriptions/:id/keys, escolhida por x-webhook-key-id
function assinaturaValida(pem, headers, rawBody) {
  const [versao, sigB64] = String(headers['x-webhook-signature']).split('=', 2)
  if (versao !== 'v1') return false

  const payload = `${headers['x-webhook-id']}\n${headers['x-webhook-timestamp']}\n${rawBody}`
  const verify = crypto.createVerify('RSA-SHA256')
  verify.update(payload)
  verify.end()
  return verify.verify(pem, sigB64, 'base64')
}

// Uso, com rejeição de payload antigo (proteção contra replay — responsabilidade do receptor)
function tratarWebhook(req, res, chavesPublicas) {
  // Buffer cru — configure express.raw({ type: 'application/json' }) nesta rota
  const rawBody = req.body
  const pem = chavesPublicas[req.headers['x-webhook-key-id']]

  const idadeMs = Math.abs(Date.now() - Date.parse(req.headers['x-webhook-timestamp']))
  if (!pem || idadeMs > 5 * 60 * 1000) return res.sendStatus(401)
  if (!assinaturaValida(pem, req.headers, rawBody.toString('utf8'))) return res.sendStatus(401)

  const evento = JSON.parse(rawBody.toString('utf8'))
  enfileirar(evento)                            // idempotência por evento.id — ver §11
  return res.sendStatus(200)                    // responda rápido; processe depois
}
const crypto = require('crypto')

// pem = publicKey obtida em GET /subscriptions/:id/keys, escolhida por x-webhook-key-id
function assinaturaValida(pem, headers, rawBody) {
  const [versao, sigB64] = String(headers['x-webhook-signature']).split('=', 2)
  if (versao !== 'v1') return false

  const payload = `${headers['x-webhook-id']}\n${headers['x-webhook-timestamp']}\n${rawBody}`
  const verify = crypto.createVerify('RSA-SHA256')
  verify.update(payload)
  verify.end()
  return verify.verify(pem, sigB64, 'base64')
}

// Uso, com rejeição de payload antigo (proteção contra replay — responsabilidade do receptor)
function tratarWebhook(req, res, chavesPublicas) {
  // Buffer cru — configure express.raw({ type: 'application/json' }) nesta rota
  const rawBody = req.body
  const pem = chavesPublicas[req.headers['x-webhook-key-id']]

  const idadeMs = Math.abs(Date.now() - Date.parse(req.headers['x-webhook-timestamp']))
  if (!pem || idadeMs > 5 * 60 * 1000) return res.sendStatus(401)
  if (!assinaturaValida(pem, req.headers, rawBody.toString('utf8'))) return res.sendStatus(401)

  const evento = JSON.parse(rawBody.toString('utf8'))
  enfileirar(evento)                            // idempotência por evento.id — ver §11
  return res.sendStatus(200)                    // responda rápido; processe depois
}

Por que assimétrica importa aqui: com HMAC — o que Stripe, Svix e GitHub usam —, o segredo que o cliente guarda para verificar é o mesmo que assina. Se ele vazar do lado do cliente (variável de ambiente, log, fornecedor terceiro), o atacante forja eventos que o cliente aceita como legítimos. Com RSA, o que o cliente guarda é a chave pública: vazá-la não dá capacidade nenhuma.

Proteção contra replay

O x-webhook-timestamp está dentro da string assinada, então não pode ser alterado sem invalidar a assinatura. O BB não impõe janela de validade — quem decide é o receptor, comparando o timestamp com o relógio dele. Recomendação: 5 minutos, o mesmo padrão das bibliotecas do Stripe (Stripe — *Webhooks*). Atenção: como cada retentativa gera timestamp novo, uma entrega que chega horas depois traz timestamp recente e passa na janela — a proteção é contra reprodução de tráfego capturado, não contra atraso de entrega.

Rotação de chave

O POST /keys/rotate cria uma chave nova em ACTIVE, move a anterior para ROTATING com validUntil, e as entregas passam a usar a nova imediatamente. O cliente mantém as duas públicas enquanto migra e escolhe pelo x-webhook-key-id. DELETE /keys/:keyId revoga na hora, para o caso de comprometimento.

Defesa contra SSRF

A URL de destino é entrada controlada pelo cliente e é tratada como hostil, em duas camadas.

Camada 1 — na criação e na atualização (isUrlSafe):

BloqueadoFaixa / valor
ProtocoloQualquer coisa que não seja https: em produção (http: liberado fora de produção)
Loopbacklocalhost, 127.0.0.0/8, ::1 e formas expandidas
"This network"0.0.0.0/8 — em Linux, 0.0.0.1 resolve para loopback
Privadas IPv410.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Link-local / metadados169.254.0.0/16 — inclui 169.254.169.254
Reservadas240.0.0.0/4 (Classe E) e broadcast
IPv6Link-local fe80::/10, ULA fc00::/7, e IPv4-mapeado ::ffff:x.x.x.x sobre faixa privada
Metadados de nuvem por nomemetadata.google.internal, metadata.azure.com

Reprovação registra SSRF_BLOCKED no log de segurança com organização, URL e motivo.

Camada 2 — no momento do envio (secureFetch): resolve o hostname por DNS, revalida o IP resolvido contra as mesmas faixas e só então requisita o IP diretamente, preservando o header Host. É o que fecha a janela de DNS rebinding — o domínio que resolve para um IP público na validação e para 169.254.169.254 na hora da entrega. Bloqueio nessa camada vira erro de entrega registrado no log, não exceção.

flowchart TD
  U["URL cadastrada pelo cliente"] --> L1["Camada 1 · isUrlSafe<br/>na criação e na atualização"]
  L1 -->|"reprovada"| B1["400 e registro SSRF_BLOCKED<br/>com organização, URL e motivo"]
  L1 -->|"aprovada"| SAVE["Subscription gravada"]
  SAVE --> ENV["Hora de entregar um evento"]
  ENV --> L2["Camada 2 · secureFetch<br/>resolve o DNS agora"]
  L2 --> CHK["Revalida o IP resolvido contra as mesmas faixas"]
  CHK -->|"IP proibido — DNS rebinding"| B2["Entrega bloqueada<br/>erro registrado no log de entrega"]
  CHK -->|"IP permitido"| REQ["Requisita o IP diretamente,<br/>preservando o header Host"]

Superfície residual

Duas coisas que quem faz revisão de segurança deve saber:

  • customHeaders é aplicado depois dos headers de assinatura no objeto de headers. Um header customizado com o mesmo nome de um x-webhook-* sobrescreve o valor real. Quem tem WEBHOOKS_SUBSCRIPTIONS_UPDATE consegue, com isso, degradar a assinatura da própria subscription — não a de outro tenant. Trate essa permissão como privilegiada.
  • customHeaders é guardado como JSON em texto claro na coluna custom_headers, apesar do comentário do schema Prisma dizer "encrypted". Não coloque segredo de longa duração ali; se o destino exige token, prefira token de escopo mínimo e rotacionável.

Dados no log de entrega

O WebhookDeliveryLog.payload guarda o payload completo do evento em JSONB, e responseBody guarda até 1 KB da resposta do cliente. Se um evento carrega dado pessoal, esse dado fica no log pelo período de retenção. Controles disponíveis:

  • PATCH /config ajusta retentionDays entre 1 e 365; o LogRetentionJob apaga o que passou do prazo, por organização, a cada 24 horas. Este é o mecanismo de expurgo para LGPD — configure-o de acordo com a sua política, não deixe no padrão por inércia.
  • Expurgo pontual de um evento específico não tem endpoint hoje.

Autenticação e permissões

Todas as rotas exigem JWT do IAM. As sete permissões são granulares de propósito: WEBHOOKS_KEYS_ROTATE e WEBHOOKS_KEYS_REVOKE são separadas de WEBHOOKS_KEYS_READ, e WEBHOOKS_DELIVERIES_RETRY é separada de WEBHOOKS_DELIVERIES_READ. Dá para entregar ao cliente final um papel que lê o próprio histórico e reenvia, sem poder criar subscription nem tocar em chave.

Exclusão lógica

Subscriptions usam deletedAt. A linha permanece para auditoria; o consumidor e as consultas filtram deletedAt: null, então a subscription excluída para de receber e some da API.


15

Limitações conhecidas

LimitaçãoImpactoSituação
Janela de retentativa curtaPadrão de 5 tentativas com backoff nominal de 1 s a 8 s; como o job roda a cada 60 s, a janela real é de cerca de 4 minutos. Svix tenta por ~24 h e Stripe por 3 dias. Uma indisponibilidade de meia hora do cliente esgota as tentativas.Ajuste retryConfig por subscription (§11); o teto absoluto é 10 tentativas com 1 h de intervalo, ~6 h de janela
x-webhook-id não é estável entre tentativasCada tentativa gera msg_<uuid> novo. O receptor não pode usar esse header para deduplicar — precisa usar o id do corpo. É diferente do Svix, onde svix-id é estável.Comportamento conhecido — documente para o seu cliente (§11)
Intervalo do job de retentativa é fixo em 60 sWEBHOOK_RETRY_JOB_INTERVAL_MS existe na configuração mas não é lido: DeliveryRetryJob.start() é chamado sem argumento e usa o padrão do código. Backoff configurado abaixo de 60 s não tem efeito prático.Variável declarada e não usada
WEBHOOK_CONSUMER_ENABLED e WEBHOOK_DEFAULT_MAX_RETRIES não são lidosDefinir qualquer uma não muda comportamento. O consumidor sobe sempre no main.ts do BB, e o máximo de tentativas vem de DEFAULT_RETRY_CONFIG no código.Variáveis declaradas e não usadas
Chave ROTATING nunca vira REVOKED sozinharevokeExpiredRotatingKeys() e DeliveryRetryJob.cleanupExpiredKeys() estão implementados mas nenhum agendador os chama. Passado o validUntil, a chave antiga continua aparecendo como ROTATING em GET /keys.Implementado, não agendado — revogue com DELETE /keys/:keyId
Pausar não interrompe retentativas em andamentoO consumidor respeita PAUSED para entregas novas, mas o job de retentativa reprocessa logs em RETRYING sem consultar o status da subscription.Comportamento conhecido
Pausar não enfileiraEventos ocorridos durante a pausa simplesmente não geram entrega e não são recuperáveis depois. Pausa não é buffer.Por design
Sem garantia de ordemEntregas para múltiplas subscriptions são paralelas, e retentativas saem da ordem original. Mesma escolha do Stripe, que documenta o mesmo.Por design — projete o receptor para tolerar
Sem fila de mensagens mortas de verdadeFAILED é o estado terminal e cumpre o papel. No consumidor há um caminho que loga "moving to dead letter" e apenas faz XACK — a mensagem é descartada, sem armazenamento. Só acontece quando o processamento da mensagem do stream falha mais de 3 vezes, antes de virar entrega.Gap conhecido
customHeaders sobrescreve headers de assinaturaSão aplicados depois dos x-webhook-* no objeto de headers. Quem tem WEBHOOKS_SUBSCRIPTIONS_UPDATE consegue degradar a assinatura da própria subscription.Sem validação de nome reservado
customHeaders não é cifradoGuardado como JSON em texto claro, apesar do comentário do schema Prisma indicar o contrário.Divergência entre comentário e código
Sem ingestão de webhooks de entradaO BB só entrega para fora. Receber webhook de terceiro é problema de cada BB integrador. As permissões WEBHOOKS_REQUESTS_CREATE e WEBHOOKS_REQUESTS_READ existem no vocabulário do IAM mas nenhuma rota deste BB as usa.Por design — ver Hookdeck/Convoy em §5
Sem portal para o cliente finalHá API completa, não há interface. Cadastrar endpoint, ver histórico e reenviar exige uma tela construída por você.Roadmap
Sem desativação automática de endpoint mortoUm endpoint que falha há semanas continua recebendo tentativa a cada evento. O Svix desabilita depois de 5 dias de falha contínua; nós não.Roadmap — monitore FAILED por subscription
/health não verifica dependênciasResponde 200 com nome e versão sem tocar banco, Redis ou consumidor. Não serve como sonda de "está entregando".Conhecido
Expurgo pontual de um evento não tem endpointA remoção só acontece pela retenção por organização. Atender a um pedido de eliminação de dado específico exige intervenção manual.Roadmap
Sem transformação de payloadO corpo entregue é o evento da plataforma. Não há mapeamento para o formato que o sistema do cliente espera — isso é adaptação do lado dele. Convoy e Hookdeck fazem transformação.Por design

16

Perguntas frequentes

Como meu cliente verifica que o webhook veio mesmo de vocês?

Ele busca a chave pública em GET /subscriptions/:id/keys, seleciona pelo header x-webhook-key-id, monta a string x-webhook-id\nx-webhook-timestamp\ncorpo cru e verifica a assinatura RSA-SHA256 que veio em x-webhook-signature (depois do prefixo v1=). Código pronto em Node e Python está em §11 e §14. O detalhe que mais quebra na prática: o corpo precisa ser o byte a byte recebido; framework que faz parse e re-serializa o JSON invalida a assinatura.

Por que RSA e não HMAC, como Stripe e Svix?

Porque com HMAC o segredo que verifica é o mesmo que assina. Se ele vazar do lado do cliente — e ele fica em variável de ambiente, em log, às vezes com um fornecedor terceiro —, o atacante consegue forjar eventos que o cliente aceita como legítimos. Com par assimétrico, o cliente guarda só a chave pública, e vazá-la não dá capacidade nenhuma. O custo é assinatura mais cara em CPU, que aceitamos.

Quantas vezes vocês tentam entregar, e por quanto tempo?

Por padrão, 5 tentativas com backoff exponencial (base 1 s, multiplicador 2, teto de 1 hora). Como o job de retentativa roda a cada 60 segundos, na prática as tentativas ficam espaçadas em cerca de um minuto e a janela total é de aproximadamente quatro minutos. Isso é bem menor que Svix (~24 h) e Stripe (3 dias), e é uma limitação declarada (§15). Se o seu cliente pode ficar fora por horas, ajuste retryConfig para maxRetries: 10, retryBackoffMs: 60000 e retryBackoffMultiplier: 3 — janela de cerca de 6 horas.

O que acontece quando as tentativas acabam?

O log vai para FAILED, o evento webhooks.delivery.failed é publicado, e a linha fica no banco com o payload completo, o código HTTP e o corpo da resposta do cliente. Não há fila de mensagens mortas separada: FAILED é o dead letter, e a recuperação é POST /delivery-logs/:id/retry, que dá exatamente uma tentativa a mais. O prazo para fazer isso é o retentionDays da organização (30 dias por padrão).

Como faço meu receptor idempotente? Qual identificador eu uso?

Use o campo id do corpo do evento, não o header x-webhook-id. O header é regenerado a cada tentativa e a cada reenvio manual, então deduplicar por ele nunca funciona. O id do corpo é estável. Guarde os IDs processados com chave única no banco e devolva 200 ao ver repetido (§11).

Os eventos chegam em ordem?

Não, e não prometemos que cheguem. Entregas para múltiplas subscriptions saem em paralelo, e uma retentativa naturalmente chega depois de eventos mais novos. É a mesma escolha do Stripe. Projete o receptor para tolerar: em vez de derivar o estado da sequência de eventos, use o evento como gatilho e busque o estado atual pela API.

Um cliente pode cadastrar uma URL que aponte para a rede interna de vocês?

Não. A URL é reprovada na criação se apontar para loopback, faixa privada, link-local (inclusive 169.254.169.254), Classe E reservada, endereços IPv6 equivalentes ou hostnames de metadados de nuvem — e HTTPS é obrigatório em produção. Para o caso do DNS que muda entre a validação e o envio, o secureFetch resolve o hostname no momento da entrega, revalida o IP e requisita o IP diretamente com o Host preservado. Toda tentativa bloqueada vira evento SSRF_BLOCKED no log de segurança.

Se eu pausar uma subscription, os eventos ficam guardados para depois?

Não. Pausa impede a entrega de eventos novos, e esses eventos não são enfileirados — eles simplesmente não geram entrega. E atenção: as retentativas que já estavam agendadas continuam rodando, porque o job de retentativa não consulta o status da subscription (§15). Se o objetivo é parar tudo durante uma manutenção do cliente, pause e confirme que não há logs em RETRYING.

Posso trocar a chave de assinatura sem derrubar a integração?

Pode, e é o desenho. POST /keys/rotate cria a chave nova em ACTIVE e move a antiga para ROTATING com um período de graça (7 dias por padrão, ajustável de 1 hora a 30 dias). As entregas passam a usar a nova imediatamente, e GET /keys devolve as duas — o cliente mantém ambas no verificador e escolhe pelo x-webhook-key-id. Não precisa de janela combinada.

Quantas subscriptions meu cliente pode ter, e por quanto tempo guardo o histórico?

50 subscriptions e 30 dias de histórico por padrão. Os dois são ajustáveis por organização em PATCH /webhooks-engine/api/v1/config — maxSubscriptions entre 1 e 1000, retentionDays entre 1 e 365. A retenção é também o seu mecanismo de expurgo de dado pessoal do log de entrega: configure conforme a sua política de LGPD em vez de deixar no padrão.

Vocês recebem webhooks de terceiros por mim?

Não. Este BB só entrega para fora. Receber webhook de provedor externo é responsabilidade do BB que integra aquele provedor (Payments, BaaS, Open Finance, E-Signature têm cada um o seu). Se você precisa de um gateway de entrada com validação, transformação e replay, Hookdeck e Convoy fazem isso e nós não (§5).

Preciso de tela para o meu cliente gerenciar os webhooks dele?

Sim, e você constrói. A API é completa — criar, pausar, testar, listar histórico, reenviar, rotacionar chave — mas não há interface pronta. Svix e Hookdeck entregam portal embalado; nós entregamos os 17 endpoints (§15).


Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md