Webhooks Engine
ProduçãoEntrega eventos aos sistemas dos seus clientes com assinatura, retentativa e histórico
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.
- 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
- 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"
- 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)
19 endpoints em 6 recursos.
/webhooks-engine/api/v1/subscriptions/webhooks-engine/api/v1/subscriptions/webhooks-engine/api/v1/public-keys/webhooks-engine/api/v1/delivery-logs/webhooks-engine/api/v1/config/webhooks-engine/healthResumo 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.
| Atributo | Valor |
|---|---|
| Identificador | webhooks-engine |
| Categoria | Plataforma |
| Escopo | Tenant (exige organizationId no token) |
| Porta (standalone) | 3013 |
| Path alias | @webhooks-engine |
| Prefixo HTTP | /webhooks-engine |
| Schema no banco | webhooks |
| Status | Produção desde 2025-11 |
| Depende de | PostgreSQL, Redis (Streams), IAM, WEBHOOK_MASTER_KEY |
O problema
negócioO 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
fetche 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.
Proposta de valor
negócio| Antes | Depois |
|---|---|
fetch no handler, sem retentativa | Fila com backoff exponencial e limite configurável por subscription |
| Payload sem assinatura, ou HMAC feito às pressas | RSA-SHA256 com chave de 2048 bits, uma por subscription |
| Cliente cadastra qualquer URL | Validação anti-SSRF na criação e na hora do envio, contra DNS rebinding |
| "O webhook não chegou" é investigação | GET /delivery-logs com código HTTP, corpo da resposta e tempo de cada tentativa |
| Trocar o segredo derruba a integração do cliente | Rotação de chave com período de graça (7 dias por padrão) |
| Cada BB novo reimplementa entrega | Facade 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.
Casos de uso reais
negócioCaso 1 — O ERP do cliente cai de madrugada e nenhum contrato se perde Cenário ilustrativo
Plataforma de crédito que assina contratos digitalmente. O ERP do cliente precisa registrar cada contrato assinado para liberar o pagamento.
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.
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
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
Plataforma multi-tenant onde qualquer cliente com WEBHOOKS_SUBSCRIPTIONS_CREATE cadastra a própria URL de destino.
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.
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
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
Integração em produção há dois anos. Política interna do cliente exige rotação anual do material criptográfico.
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.
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"]
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
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 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.
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"]
endSuperioridade clara no eixo de assinatura, desvantagem clara no eixo de persistência da retentativa. Quem compra precisa saber os dois.
Mercado e diferenciais
negócioPanorama
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ério | Catalisa Webhooks Engine | Svix | Hookdeck | Convoy | AWS EventBridge |
|---|---|---|---|---|---|
| Dimensão de cobrança | Eventos entregues + subscriptions | Por mensagem | Por evento + throughput | Licença (Premium) | Por milhão de eventos |
| Preço público de entrada | Em definição | US$ 0 (50k msgs/mês) | US$ 0 (10k eventos/mês) | US$ 0 (self-host) | US$ 1,00/milhão publicado |
| Assinatura do payload | RSA-SHA256 assimétrica | HMAC-SHA256 | HMAC | HMAC | Não assina |
| Rotação com período de graça | Sim (1h a 30 dias) | Sim | Sim | Sim | — |
| Janela de retentativa | ~4 min (5 tentativas) | ~24h (8 tentativas) | Configurável, longa | Configurável | Retentativa do EventBridge |
| Reenvio manual por API | Sim | Sim | Sim | Sim | Não |
| Log de entrega consultável pelo cliente | Sim, por API | Sim, com portal | Sim, com painel | Sim | Não |
| Proteção anti-SSRF com revalidação de DNS | Sim | Não documentado | Não documentado | Não documentado | Não se aplica |
| Ingestão de webhook de entrada | Não | Sim (Ingest) | Sim | Sim | Sim |
| Portal pronto para o cliente final | Não (ver §15) | Sim | Sim | Parcial | Não |
| Isolamento multi-tenant nativo | Sim, do IAM | Por aplicação | Por projeto | Por projeto | Não |
| Self-host | Sim (é seu deploy) | Só no Enterprise | Não | Sim | Nã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
- 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.
- A chave privada é cifrada em repouso com chave-mestra externa.
encryptedKeyguardaiv:authTag:ciphertextde AES-256-GCM, e aWEBHOOK_MASTER_KEY(32 bytes) não está no banco. Um dump não dá poder de assinar. - 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
Hostpreservado é o que fecha a janela entre validação e envio. Está emsecureFetch, e é a diferença entre bloquear o ingênuo e bloquear o determinado. - Já nasce dentro da plataforma. O consumidor lê o barramento de eventos onde os 32 building blocks publicam, e o
organizationIdvem 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 é | Escolha | Por quê |
|---|---|---|
| Entrega persistente por horas ou dias — o cliente pode ficar fora a manhã inteira e ainda assim receber tudo | Svix ou Hookdeck | Eles 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 replay | Hookdeck ou Convoy | Cobrem 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ê | Svix | Entrega isso embalado; nós entregamos a API para você construir a tela |
| Bilhões de eventos internos entre serviços da AWS | AWS 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.
Modelo de cobrança e ROI
negócioUnidade de cobrança
Precificação em definição. Os drivers estão definidos:
| Driver | Por que importa |
|---|---|
| Volume de eventos entregues | Cada entrega é uma assinatura RSA mais uma requisição HTTP de saída |
| Número de subscriptions ativas | Um evento que casa com 5 subscriptions vira 5 entregas, 5 assinaturas e 5 linhas de log |
| Dias de retenção do log de entrega | O 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.
| Catalisa | Svix | Hookdeck | AWS EventBridge (API Dest.) | |
|---|---|---|---|---|
| Base de cálculo | Eventos + subscriptions | US$ 0,0001 por mensagem entregue | Por evento, com degrau de throughput | US$ 1,00/milhão publicado + US$ 0,20/milhão invocado |
| Conta do cenário | Em definição | 2 M × US$ 0,0001 | Plano Growth + excedente | 2 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étrica | Sim, HMAC | Sim, HMAC | Não |
| Retenção de 30 dias inclusa? | Sim, configurável 1–365 | Conforme plano | Só no Growth | Não se aplica |
| Cliente consulta o próprio histórico? | Sim, por API | Sim, com portal | Sim, com painel | Nã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.
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 --> PGDecisõ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. OmessageIde otimestampentram 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:ciphertextem hexadecimal. 4xx(exceto429) não é retentado. Insistir contra um400ou um404gasta 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
XACKmesmo quando a entrega falha. A retentativa é responsabilidade doDeliveryLog, não do stream. Segurar a mensagem no stream criaria duas camadas de retentativa competindo, com semântica diferente — e oDeliveryLogé 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
organizationIdnos metadados é descartado. Sem tenant não há como decidir a quem entregar, e adivinhar seria vazamento entre clientes. O consumidor fazXACKe segue. - O consumidor desembrulha eventos
wpp-biz.dispatch.*. Eventos do dispatcher chegam num envelope comoriginalEventTypeeoriginalPayload; o consumidor entrega o evento original, para que o cliente vejawpp-business.message.receivede 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.
Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
| Subscription | O contrato de entrega: URL de destino, filtros de evento, timeout e política de retentativa. Uma por par (organização, nome). |
| Event filter | Padrão glob que decide quais eventos aquela subscription recebe: *, namespace.* ou o tipo exato. Combinados com OR. |
| Signing key | Par RSA-2048 da subscription. A pública é exposta por API; a privada é cifrada no banco. |
| Key ID | Identificador da chave, no formato whk_<random>. Vai no header x-webhook-key-id de cada entrega. |
| Delivery log | Uma tentativa de entrega de um evento a uma subscription, com status, contagem, resposta e erro. |
| Message ID | msg_<uuid> gerado a cada tentativa. Entra na assinatura. Não é estável entre retentativas — ver §15. |
| Event ID | O id dentro do payload. É o identificador estável do evento; use este para idempotência. |
| Retry config | maxRetries, retryBackoffMs e retryBackoffMultiplier, por subscription. |
| Grace period | Janela em que a chave antiga continua listada como válida após uma rotação (padrão 7 dias). |
| Retention days | Quantos dias os logs de entrega da organização são mantidos (padrão 30). |
Modelo de dados
Schema webhooks no PostgreSQL.
| Modelo Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
WebhookSubscription | webhooks.webhook_subscriptions | Contrato de entrega | Único (organizationId, name), endpointUrl, eventFilters (array), status, timeoutMs, retryConfig (JSONB), customHeaders, deletedAt |
WebhookSigningKey | webhooks.webhook_signing_keys | Par RSA da subscription | keyId (único), publicKey (PEM SPKI), encryptedKey (AES-256-GCM), status, validFrom, validUntil |
WebhookDeliveryLog | webhooks.webhook_delivery_logs | Cada tentativa | eventId, eventType, payload (JSONB), status, attemptCount, nextRetryAt, responseStatusCode, responseBody, responseTimeMs |
OrganizationWebhookConfig | webhooks.organization_webhook_configs | Limites por organização | organizationId (ú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
| Enum | Valores |
|---|---|
WebhookSubscriptionStatus | ACTIVE · PAUSED · DISABLED |
WebhookDeliveryStatus | PENDING · DELIVERED · FAILED · RETRYING |
WebhookSigningKeyStatus | ACTIVE · 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ída | Atraso nominal (padrão) | Quando de fato ocorre |
|---|---|---|
| 1ª falhou | 1 s | No próximo tique do job (até 60 s) |
| 2ª falhou | 2 s | No próximo tique do job (até 60 s) |
| 3ª falhou | 4 s | No próximo tique do job (até 60 s) |
| 4ª falhou | 8 s | No 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).
| Evento | Vai para |
|---|---|
Evento de uma subconta (subaccountId na metadata ou no payload, como os biometrics.*) | Endpoints daquela subconta e endpoints da organização |
| Evento sem subconta | Só 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.
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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /webhooks-engine/api/v1/subscriptions | Cria subscription e gera o primeiro par de chaves | WEBHOOKS_SUBSCRIPTIONS_CREATE |
GET | /webhooks-engine/api/v1/subscriptions | Lista, paginado, filtros filter[status] e filter[subaccountId] | WEBHOOKS_SUBSCRIPTIONS_READ |
GET | /webhooks-engine/api/v1/subscriptions/:id | Busca por UUID | WEBHOOKS_SUBSCRIPTIONS_READ |
PATCH | /webhooks-engine/api/v1/subscriptions/:id | Atualiza URL, filtros, timeout, retentativa, headers | WEBHOOKS_SUBSCRIPTIONS_UPDATE |
DELETE | /webhooks-engine/api/v1/subscriptions/:id | Exclusão lógica. Responde 204 | WEBHOOKS_SUBSCRIPTIONS_DELETE |
POST | /webhooks-engine/api/v1/subscriptions/:id/pause | ACTIVE → PAUSED. Para de receber eventos novos | WEBHOOKS_SUBSCRIPTIONS_UPDATE |
POST | /webhooks-engine/api/v1/subscriptions/:id/resume | PAUSED → ACTIVE | WEBHOOKS_SUBSCRIPTIONS_UPDATE |
POST | /webhooks-engine/api/v1/subscriptions/:id/test | Envia um webhook.test assinado, síncrono. Não grava log de entrega | WEBHOOKS_SUBSCRIPTIONS_READ |
Chaves de assinatura — /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys | Lista chaves ACTIVE e ROTATING com as públicas em PEM | WEBHOOKS_KEYS_READ |
GET | /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys/:keyId | Busca uma chave pelo keyId (whk_...) | WEBHOOKS_KEYS_READ |
POST | /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys/rotate | Cria chave nova; antiga vira ROTATING. Responde 201 | WEBHOOKS_KEYS_ROTATE |
DELETE | /webhooks-engine/api/v1/subscriptions/:subscriptionId/keys/:keyId | Revoga imediatamente. Responde 200 com a chave revogada | WEBHOOKS_KEYS_REVOKE |
Chaves públicas do escopo — /webhooks-engine/api/v1/public-keys
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /webhooks-engine/api/v1/public-keys | Chaves ACTIVE e ROTATING de todos os endpoints do escopo (a subconta vê só as dela), com keyId, subscriptionId e a pública em PEM | WEBHOOKS_KEYS_READ |
Logs de entrega — /webhooks-engine/api/v1/delivery-logs
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /webhooks-engine/api/v1/delivery-logs | Lista, paginado, com filtros | WEBHOOKS_DELIVERIES_READ |
GET | /webhooks-engine/api/v1/delivery-logs/:id | Busca um log, com payload e resposta | WEBHOOKS_DELIVERIES_READ |
POST | /webhooks-engine/api/v1/delivery-logs/:id/retry | Reenvia. Só aceita FAILED ou RETRYING | WEBHOOKS_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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /webhooks-engine/api/v1/config | Lê a configuração; cria com os padrões se não existir | WEBHOOKS_CONFIG_MANAGE |
PATCH | /webhooks-engine/api/v1/config | Altera retentionDays (1–365) e maxSubscriptions (1–1000) | WEBHOOKS_CONFIG_MANAGE |
Saúde
| Método | Rota | Descrição |
|---|---|---|
GET | /webhooks-engine/health | Sonda 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
{
"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" }
}| Campo | Tipo | Obrigatório | Regra |
|---|---|---|---|
name | string | Sim | 1 a 100 caracteres. Único por organização |
endpointUrl | string (URL) | Sim | HTTPS obrigatório em produção. Reprovado se resolver para faixa privada, loopback, link-local ou metadados de nuvem |
eventFilters | string[] | Sim | Ao menos 1. Cada item: *, namespace.* ou tipo exato |
description | string | Não | Até 500 caracteres |
timeoutMs | int | Não | 1000 a 60000. Padrão WEBHOOK_DEFAULT_TIMEOUT_MS (30000) |
retryConfig.maxRetries | int | Não | 0 a 10. Padrão 5 |
retryConfig.retryBackoffMs | int | Não | 100 a 60000. Padrão 1000 |
retryConfig.retryBackoffMultiplier | number | Não | 1 a 5. Padrão 2.0 |
customHeaders | object | Não | Mapa string→string, enviado em toda entrega. Guardado em texto claro e aplicado depois dos headers de assinatura — ver §14 e §15 |
Resposta 201
{
"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
| Status | Quando |
|---|---|
400 | VALIDATION — 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 |
403 | Sem WEBHOOKS_SUBSCRIPTIONS_CREATE, ou token sem organizationId |
409 | name 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
{
"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:
{ "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
{
"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
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]=50GET /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]=50Resposta 200 (um item)
{
"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.
| Status | Quando |
|---|---|
200 | Reenviado; o corpo traz o log com o status resultante |
400 | VALIDATION — o log não está em FAILED nem em RETRYING |
404 | Log 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:
| Header | Exemplo | Descrição |
|---|---|---|
x-webhook-id | msg_5f9a3c1e8b7d4a2f... | Identificador desta tentativa. Muda a cada retentativa (§15) |
x-webhook-timestamp | 2026-08-16T14:32:07.451Z | ISO 8601. Use para rejeitar payload antigo |
x-webhook-signature | v1=Zm9vYmFy... | v1= seguido da assinatura RSA-SHA256 em base64 |
x-webhook-key-id | whk_9jK2mQx7Rt4B | Qual chave assinou. Selecione a chave pública por este valor |
x-webhook-version | v1 | Versão do esquema de assinatura |
Corpo entregue — sempre esta forma:
{
"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.
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
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/v1TOKEN=$(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/v1Resposta esperada — o token na variável e a base da API exportada:
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.
export DESTINO="https://webhook.site/SEU-UUID-AQUI"export DESTINO="https://webhook.site/SEU-UUID-AQUI"3. Criar a subscription
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:
d41c9e10-7b3a-4a55-9f2e-2c8b1a0d5511d41c9e10-7b3a-4a55-9f2e-2c8b1a0d5511Atenção. A resposta da criação não traz a chave. Ela vem no passo seguinte.
4. Pegar a chave pública
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}'{ "keyId": "whk_9jK2mQx7Rt4B", "status": "ACTIVE" }{ "keyId": "whk_9jK2mQx7Rt4B", "status": "ACTIVE" }5. Disparar um envio de teste
curl -s -X POST $WH/subscriptions/$SUB_ID/test -H "Authorization: Bearer $TOKEN" | jqcurl -s -X POST $WH/subscriptions/$SUB_ID/test -H "Authorization: Bearer $TOKEN" | jq{ "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:
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}'{ "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.
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
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
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 Falseimport 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 FalseArmadilhas.
- 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-idcom oiddo 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.
// 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-idcomo chave de idempotência. Ele é regenerado a cada tentativa, então a deduplicação nunca acontece (§15). - Não há garantia de ordem.
documento.assinadopode chegar antes dedocumento.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
2xxrá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:
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:
| Tentativa | Atraso calculado | Aplicado |
|---|---|---|
| 1ª falhou | 1 min | 1 min |
| 2ª falhou | 3 min | 3 min |
| 3ª falhou | 9 min | 9 min |
| 4ª falhou | 27 min | 27 min |
| 5ª falhou | 81 min | 60 min — o teto de 1 hora entra aqui |
| 6ª a 9ª falharam | acima do teto | 60 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 eretryBackoffMultiplier≤ 5. O teto absoluto de atraso é 1 hora por tentativa, independentemente da conta. retryConfigé mesclado, não substituído: mandar sómaxRetriespreserva os outros dois campos.- Alterar a política não afeta entregas já em
RETRYING— elas já têm onextRetryAtcalculado 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
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 -lcurl -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 -lResposta esperada — quantos logs entraram na lista:
37372. Reenviar cada um, com pausa entre as chamadas
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.txtwhile 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.txtResposta esperada — uma linha de status por reenvio:
DELIVERED
DELIVERED
FAILEDDELIVERED
DELIVERED
FAILEDArmadilhas.
- Confirme que o endpoint voltou antes de disparar: cada reenvio de um log esgotado dá uma tentativa, e se falhar volta a
FAILEDsem 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
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:
PAUSEDPAUSED2. Retomar quando o cliente voltar
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:
ACTIVEACTIVEArmadilhas.
- Pausar impede entregas novas (o consumidor só considera subscriptions
ACTIVE), mas não interrompe as retentativas já agendadas: o job de retentativa reprocessa logs emRETRYINGsem 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.
pausesó aceita subscriptionACTIVEeresumesó aceitaPAUSED; fora disso a resposta é400.
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token; fornece organizationId e as permissões WEBHOOKS_* | Sim |
| Todos os demais | Publicam eventos no barramento; o consumidor decide quem recebe | Sim, como produtores |
| Audit Trail | Consome o mesmo barramento para trilha interna; o Webhooks Engine leva para fora | Não |
| WPP / WPP Business | Os dispatchers criam e gerenciam subscriptions via IWebhooksEngineFacade | Não |
| Slack | O SlackDispatcherService também consome a facade | Não |
| Feature Flags | Publica feature-flags.*; assine para saber quando uma flag mudou | Nã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 --> CRMEste é 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:
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:
| Evento | Quando |
|---|---|
webhooks.subscription.created · .updated · .deleted | Ciclo de vida da subscription |
webhooks.subscription.paused · .resumed | Pausa e retomada |
webhooks.key.created · .rotated · .revoked | Ciclo de vida da chave |
webhooks.delivery.succeeded | Entrega concluída com 2xx |
webhooks.delivery.failed | Entrega em estado terminal de falha |
Não há evento para RETRYING — de propósito, para não gerar ruído a cada tentativa.
Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
WEBHOOK_MASTER_KEY | 64 caracteres hexadecimais (32 bytes). Cifra as chaves privadas em AES-256-GCM. Gere com openssl rand -hex 32. Sem ela, criar subscription e assinar falham | Sim para operar | — |
WEBHOOK_DEFAULT_TIMEOUT_MS | Timeout padrão de entrega quando a subscription não define | Não | 30000 |
WEBHOOK_KEY_GRACE_PERIOD_HOURS | Período de graça padrão da rotação | Não | 168 (7 dias) |
DATABASE_URL | PostgreSQL, schema webhooks | Sim | — |
REDIS_URL | Redis. O main.ts faz preflight e encerra o processo se não conectar | Sim | — |
JWT_SECRET | Segredo HS256 do IAM. Mínimo 44 caracteres | Sim | — |
DEPLOYMENT_MODE | monolith ou standalone | Não | monolith |
MODULE_WEBHOOKS_ENGINE_URL | Endereço deste BB, lido por outros BBs em standalone | Só em standalone | — |
PORT | Porta no modo standalone | Não | 3000 (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_RETRIESeWEBHOOK_RETRY_JOB_INTERVAL_MS. Definir qualquer uma delas não muda comportamento. Ver §15.
Dependências de infraestrutura
| Dependência | Para quê | Se cair |
|---|---|---|
| PostgreSQL | Schema webhooks | API e entrega param |
| Redis Streams | Barramento iam-events, grupo webhook-delivery-consumers | Eventos param de ser consumidos; o processo não sobe se o preflight falhar |
| IAM | Verificação do token (assinatura local, sem chamada de rede) | Nada muda com o JWT_SECRET correto |
WEBHOOK_MASTER_KEY | Cifrar e decifrar chaves privadas | Criar subscription e assinar falham com 500 |
Processos de segundo plano
Só sobem no main.ts do webhooks-engine:
| Processo | Intervalo | O que faz |
|---|---|---|
WebhookConsumer | Contí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 |
DeliveryRetryJob | 60 s (fixo no código) | Busca até 100 logs RETRYING com nextRetryAt <= agora e reenvia |
LogRetentionJob | 24 h | Apaga logs de entrega além de retentionDays, organização por organização |
Limites e quotas
| Limite | Valor | Onde |
|---|---|---|
| Subscriptions por organização | 50 (ajustável de 1 a 1000) | OrganizationWebhookConfig.maxSubscriptions |
| Retenção do log de entrega | 30 dias (ajustável de 1 a 365) | OrganizationWebhookConfig.retentionDays |
| Nome da subscription | 1 a 100 caracteres, único por organização | createSubscriptionSchema |
| Descrição | Até 500 caracteres | createSubscriptionSchema |
| Timeout de entrega | 1000 a 60000 ms | createSubscriptionSchema |
maxRetries | 0 a 10 | createSubscriptionSchema |
retryBackoffMs | 100 a 60000 ms | createSubscriptionSchema |
retryBackoffMultiplier | 1 a 5 | createSubscriptionSchema |
| Teto do atraso entre tentativas | 1 hora | MAX_RETRY_DELAY_MS |
| Corpo de resposta gravado | 1024 bytes (truncado) | MAX_RESPONSE_BODY_LENGTH |
| Período de graça na rotação | 1 a 720 horas | rotateKeySchema |
| Lote do job de retentativa | 100 logs por tique | findPendingRetries |
| Leitura do stream por ciclo | 10 mensagens | WebhookConsumer |
| Página máxima em listagens | 100 itens | Paginação compartilhada |
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | URL 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á revogada | A mensagem diz qual regra falhou. Para URL, ver §14 |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove o token no IAM |
403 | FORBIDDEN | Permissão faltando ou token sem organizationId | Confira permissions no token |
404 | NOT_FOUND | Subscription, chave ou log inexistente, excluído, ou de outra organização | Recurso de outro tenant também responde 404, por design |
409 | CONFLICT | name de subscription repetido na organização | Escolha outro nome |
500 | INTERNAL | WEBHOOK_MASTER_KEY ausente ou malformada; falha ao decifrar a chave privada; subscription sem chave ACTIVE | Confira 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/healthresponde com nome e versão do build. Não verifica banco, Redis nem o consumidor —200aqui não significa que eventos estão sendo entregues. Para saber isso, olhe se há logs de entrega recentes.- Tentativas de SSRF geram
SSRF_BLOCKEDno log de segurança, comorganizationId, URL tentada e motivo. - Bloqueios por DNS rebinding aparecem como
warncomhostname,resolvedIPe motivo. - O consumidor registra em
infoo resultado de cada ciclo, e emwarnmensagens descartadas por excesso de reprocessamento. - Métrica de saúde mais útil na prática: contagem de logs em
FAILEDpor subscription nas últimas 24 horas. Um endpoint de cliente que quebrou aparece aqui antes de virar chamado.
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:
- 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. - 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 responde500em vez de assinar com lixo. - O IV é aleatório por operação. Doze bytes de
randomBytespor 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:
| Header | Exemplo |
|---|---|
x-webhook-id | msg_5f9a3c1e8b7d4a2f9c0e1b3d5a7f9021 |
x-webhook-timestamp | 2026-08-16T14:32:07.451Z |
x-webhook-key-id | whk_9jK2mQx7Rt4B |
x-webhook-version | v1 |
x-webhook-signature | v1= seguido da assinatura RSA-SHA256 em base64 |
A string assinada é a concatenação exata destes três pedaços, nesta ordem:
x-webhook-id "\n" x-webhook-timestamp "\n" corpo crux-webhook-id "\n" x-webhook-timestamp "\n" corpo crusequenceDiagram 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:
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):
| Bloqueado | Faixa / valor |
|---|---|
| Protocolo | Qualquer coisa que não seja https: em produção (http: liberado fora de produção) |
| Loopback | localhost, 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 IPv4 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| Link-local / metadados | 169.254.0.0/16 — inclui 169.254.169.254 |
| Reservadas | 240.0.0.0/4 (Classe E) e broadcast |
| IPv6 | Link-local fe80::/10, ULA fc00::/7, e IPv4-mapeado ::ffff:x.x.x.x sobre faixa privada |
| Metadados de nuvem por nome | metadata.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 umx-webhook-*sobrescreve o valor real. Quem temWEBHOOKS_SUBSCRIPTIONS_UPDATEconsegue, 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 colunacustom_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 /configajustaretentionDaysentre 1 e 365; oLogRetentionJobapaga 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.
Limitações conhecidas
| Limitação | Impacto | Situação |
|---|---|---|
| Janela de retentativa curta | Padrã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 tentativas | Cada 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 s | WEBHOOK_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 lidos | Definir 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 sozinha | revokeExpiredRotatingKeys() 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 andamento | O 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 enfileira | Eventos 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 ordem | Entregas 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 verdade | FAILED é 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 assinatura | Sã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 é cifrado | Guardado 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 entrada | O 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 final | Há 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 morto | Um 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ências | Responde 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 endpoint | A 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 payload | O 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 |
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