Segurança
Duas camadas protegem a comunicação: a assinatura HMAC (prova que o payload veio do Dunning e não foi adulterado) e a autenticação do request (credenciais que o Dunning apresenta ao seu endpoint). A primeira vai sempre; a segunda é opcional.
Headers e assinatura (padrão Kobana)
Toda entrega é um POST com Content-Type: application/json e os headers abaixo — os mesmos em todos os produtos Kobana, então um único receptor atende vários produtos e ambientes e roteia com segurança:
| Header | Valor |
|---|---|
X-Kobana-App | dunning — o produto Kobana que enviou a entrega. |
X-Kobana-Environment | development, sandbox ou production. Nunca deixe uma entrega sandbox tocar dados de produção. |
X-Kobana-Webhook-Id | Id do endpoint cadastrado a que a entrega pertence. |
X-Kobana-Event | Slug do evento (ex.: charge.paid). |
X-Kobana-Event-Id | Id do evento. Retries do mesmo evento repetem este valor — use-o para deduplicar. |
X-Kobana-Delivery-Id | Id único desta tentativa de entrega. Faz parte do conteúdo assinado. |
X-Kobana-Timestamp | Unix time (segundos) em que a entrega foi assinada. Faz parte do conteúdo assinado. |
X-Kobana-Signature | v1,<base64 do HMAC> — um ou mais tokens separados por espaço. |
webhook-id, webhook-timestamp, webhook-signature | Os mesmos três valores acima, no layout Standard Webhooks — verificáveis com uma biblioteca de prateleira. |
Os headers legados X-Webhook-Event, X-Webhook-Event-Id, X-Webhook-Delivery-Id, X-Webhook-Attempt, X-Webhook-Timestamp e X-Webhook-Signature (t=<unix>,v1=<hex> sobre "{t}.{body}") continuam sendo enviados, deprecados, para integrações existentes. Migre para os X-Kobana-*: eles serão removidos numa próxima versão do contrato.
Como a assinatura é calculada
chave = base64_decode(secret sem o prefixo "whsec_")
conteúdo = delivery_id + "." + timestamp + "." + body_cru
assinatura = "v1," + base64(HMAC_SHA256(chave, conteúdo))
body_crué o corpo da requisição exatamente como recebido, byte a byte. Não parseie e re-serialize: ordem de chaves, espaços ou escapes mudariam e a assinatura deixaria de bater.- Amarrar o
delivery_idao conteúdo assinado impede que uma requisição capturada seja reapresentada com outro id para furar a sua deduplicação; o timestamp limita o replay no tempo. - O secret tem a forma
whsec_+ base64 de 32 bytes aleatórios e é exibido uma única vez. Secrets antigos (sem o prefixo, ou com hexadecimal após ele) continuam válidos: a chave é o base64 decodificado após o prefixo, ou os bytes UTF-8 do secret quando não há prefixo. - Durante uma rotação de secret o header carrega um token por secret ainda válido (
v1,AAA… v1,BBB…). A entrega é válida se qualquer token casar com qualquer secret que você tenha.
Verificando uma entrega
- Leia
webhook-id,webhook-timestampewebhook-signature(ou os equivalentesX-Kobana-*). Rejeite a requisição se algum faltar. - Rejeite se
|agora − timestamp| > 300segundos: cinco minutos absorvem diferença de relógio e limitam replays. - Monte o conteúdo assinado com o body cru e calcule o HMAC esperado para cada secret que você confia.
- Compare com cada token
v1,em tempo constante — nunca com==, que devolve no primeiro byte diferente e vaza o digest por timing. - Deduplique por
X-Kobana-Event-Idpara processar cada evento uma vez, ou porwebhook-idpara registrar cada tentativa. - Responda
2xxrápido e faça o trabalho pesado de forma assíncrona. Qualquer outra resposta entra no ciclo de retry.
const crypto = require('node:crypto');
/**
* @param headers headers da requisição, com nomes em minúsculas
* @param rawBody body cru (string ou Buffer), ANTES de qualquer JSON.parse
* @param secrets secret atual (e o anterior, durante uma rotação)
*/
function verifyKobanaWebhook(headers, rawBody, secrets) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
if (!id || !timestamp || !signature) throw new Error('missing signature headers');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('timestamp out of tolerance');
const content = `${id}.${timestamp}.${rawBody}`;
const received = signature.split(' ').filter((t) => t.startsWith('v1,')).map((t) => Buffer.from(t.slice(3), 'base64'));
for (const secret of [].concat(secrets)) {
const key = secret.startsWith('whsec_') ? Buffer.from(secret.slice(6), 'base64') : Buffer.from(secret, 'utf8');
const expected = crypto.createHmac('sha256', key).update(content).digest();
if (received.some((r) => r.length === expected.length && crypto.timingSafeEqual(r, expected))) return true;
}
throw new Error('no matching signature');
}
import base64, hashlib, hmac, time
def verify_kobana_webhook(headers, raw_body: bytes, secrets) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature = headers.get("webhook-signature")
if not (msg_id and timestamp and signature):
raise ValueError("missing signature headers")
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("timestamp out of tolerance")
content = f"{msg_id}.{timestamp}.".encode() + raw_body
received = [base64.b64decode(t[3:]) for t in signature.split(" ") if t.startswith("v1,")]
for secret in ([secrets] if isinstance(secrets, str) else secrets):
key = base64.b64decode(secret[6:]) if secret.startswith("whsec_") else secret.encode()
expected = hmac.new(key, content, hashlib.sha256).digest()
if any(hmac.compare_digest(r, expected) for r in received):
return True
raise ValueError("no matching signature")
Com Express, mantenha o body cru montando express.raw({ type: 'application/json' }) na rota do webhook (não express.json()), verifique e só então faça JSON.parse. Com Django/Flask, use request.body/request.get_data() — nunca o objeto já parseado.
Autenticação do request
Se o seu endpoint fica atrás de um gateway que exige credencial, configure a autenticação no cadastro:
| Tipo | O que o Dunning envia |
|---|---|
Nenhuma (none) | Só a assinatura HMAC |
Basic (basic) | Authorization: Basic <usuário:senha> |
Bearer (bearer) | Authorization: Bearer <token> |
Header (header) | Um header custom com nome e valor definidos por você |
As credenciais são cifradas em repouso (AES-256-GCM) e nunca aparecem na trilha de entregas: na captura de request/response, a assinatura e os headers de autenticação são gravados como [REDACTED].
Rotação de secret
O botão de rotação (na linha do endpoint) gera um secret novo, exibido uma única vez, e invalida o anterior imediatamente (também disponível via POST /v1/webhook-endpoints/{id}/rotate-secret). Rotacione se houver suspeita de vazamento ou como higiene periódica; atualize o consumidor no mesmo momento, pois entregas assinadas com o secret antigo deixarão de validar.
Proteções do lado do Dunning
- Só HTTPS: URLs
http://são recusadas no cadastro. - Anti-SSRF: a URL é validada no cadastro e revalidada a cada entrega (DNS re-resolvido, IP fixado, redes internas bloqueadas). Redirects não são seguidos. Um destino que passa a resolver para rede interna é bloqueado permanentemente (
SSRF blocked, sem retry). - Respostas do seu endpoint são capturadas com headers sensíveis (
Set-Cookie,Authorization...) redigidos.