Segurança de webhooks
Toda entrega de webhook é assinada com um secret que só você e a Kobana conhecem. Verificar a assinatura antes de agir sobre o payload garante três coisas:
- Autenticidade — a requisição veio mesmo da Kobana, e não de alguém que descobriu a URL do seu endpoint.
- Integridade — o corpo não foi alterado no caminho.
- Atualidade — a requisição não é o replay de uma entrega antiga.
O esquema segue a especificação Standard Webhooks, então dá para verificar com uma biblioteca pronta ou com poucas linhas de código em qualquer linguagem.
O secret de assinatura
Cada webhook tem seu próprio secret no formato whsec_ + base64 de 32 bytes aleatórios, por exemplo:
whsec_c2VjcmV0LWJ5dGVzLWV4YW1wbGUtc2VjcmV0LWJ5dGVz
- Ele é retornado uma única vez, na resposta de
POST /webhookse dePOST /webhooks/{id}/regenerate-secret. Nunca é retornado emGET. - Guarde-o em um gerenciador de segredos ou variável de ambiente — nunca no código-fonte. O prefixo
whsec_permite que scanners de segredo (GitHub, gitleaks) o detectem caso vá parar em um repositório. - Secrets são por ambiente: um webhook de
sandboxe um deproductionnunca compartilham o mesmo. - Você pode usar um secret próprio enviando
signing_secretna criação ou na rotação. Ele precisa serwhsec_+ base64 padrão de 24–64 bytes aleatórios.
Headers
| Header | Valor |
|---|---|
X-Kobana-App | inbox — o produto Kobana que envia o webhook. |
X-Kobana-Environment | development, sandbox ou production. |
X-Kobana-Webhook-Id | O Webhook.id ao qual a entrega pertence. |
X-Kobana-Event | Slug do evento, ex.: email.agent.processed. |
X-Kobana-Event-Id | SystemEvent.id. Retentativas do mesmo evento compartilham o valor. |
X-Idempotency-Key | Mesmo valor de X-Kobana-Event-Id no disparo original e em toda retentativa automática, para que um endpoint que honra chaves de idempotência processe cada evento uma vez. Um replay manual envia um valor novo e aleatório, para que o destino trate como uma entrega nova. |
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,<HMAC em base64> — um ou mais tokens separados por espaço. |
webhook-id, webhook-timestamp, webhook-signature | Os mesmos valores dos três acima, no layout Standard Webhooks. |
X-Kobana-App e X-Kobana-Environment permitem que um único endpoint receba webhooks de vários produtos e ambientes Kobana e os roteie com segurança — por exemplo, nunca deixar uma entrega de sandbox tocar dados de produção.
Como a assinatura é calculada
key = base64_decode(secret sem o prefixo "whsec_")
signed_content = delivery_id + "." + timestamp + "." + raw_body
signature = "v1," + base64(HMAC_SHA256(key, signed_content))
raw_bodyé o corpo da requisição exatamente como recebido, byte a byte. Não faça parse e re-serialize: ordem de chaves, espaços ou escape de unicode mudariam e a assinatura deixaria de bater.- Incluir o
delivery_idno conteúdo assinado impede que uma requisição capturada seja reenviada com outro id para furar a sua deduplicação. - Durante uma rotação de secret o header traz um token por secret válido, ex.:
v1,AAA… v1,BBB…. A entrega é válida se qualquer token bater 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 ainda limitam replays. - Monte o
signed_contenta partir do corpo bruto e calcule o HMAC esperado para cada secret em que você confia no momento. - Compare com cada token
v1,usando comparação em tempo constante. Nunca use==: ele retorna assim que um byte difere e vaza o digest pelo tempo de resposta. - Deduplique por
X-Idempotency-Keyse quiser processar cada evento uma vez — retentativas automáticas do mesmo evento trazem a mesma chave e outro id de entrega, mas um replay manual traz deliberadamente uma chave nova, já que seu objetivo é ser processado de novo — ou porwebhook-id(o id da entrega) se quiser registrar cada tentativa. - Responda
2xxrápido e faça o trabalho pesado de forma assíncrona. Qualquer outra coisa (timeout de 10 s,3xx,4xx,5xx) é retentada com backoff.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/**
* @param headers request headers em minúsculas
* @param rawBody o corpo exatamente como recebido (string ou Buffer)
* @param secrets todos os secrets em que você confia: o atual e, durante uma rotação, o anterior
*/
export 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');
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
throw new Error('timestamp outside tolerance');
}
const content = Buffer.concat([Buffer.from(`${id}.${ts}.`), Buffer.from(rawBody)]);
const received = signature
.split(' ')
.filter((token) => token.startsWith('v1,'))
.map((token) => Buffer.from(token.slice(3), 'base64'));
for (const secret of [].concat(secrets)) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(content).digest();
if (received.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))) {
return true;
}
}
throw new Error('signature mismatch');
}
No Express, preserve o corpo bruto montando express.raw({ type: 'application/json' }) na rota do webhook (não express.json()) e chame verifyKobanaWebhook(req.headers, req.body, process.env.KOBANA_WEBHOOK_SECRET) antes de JSON.parse(req.body).
Python
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
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")
ts = int(timestamp)
if abs(time.time() - ts) > TOLERANCE_SECONDS:
raise ValueError("timestamp outside tolerance")
content = f"{msg_id}.{ts}.".encode() + raw_body
received = [
base64.b64decode(token[3:])
for token in signature.split(" ")
if token.startswith("v1,")
]
for secret in [secrets] if isinstance(secrets, str) else secrets:
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = hmac.new(key, content, hashlib.sha256).digest()
if any(hmac.compare_digest(sig, expected) for sig in received):
return True
raise ValueError("signature mismatch")
No Django use request.body; no Flask, request.get_data(); no FastAPI, await request.body(). Os três retornam os bytes brutos.
Ruby
require "base64"
require "openssl"
TOLERANCE_SECONDS = 300
def verify_kobana_webhook(headers, raw_body, secrets)
id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signature = headers["webhook-signature"]
raise "missing signature headers" if [id, timestamp, signature].any?(&:nil?)
ts = Integer(timestamp)
raise "timestamp outside tolerance" if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS
content = "#{id}.#{ts}.#{raw_body}"
received = signature.split(" ")
.select { |token| token.start_with?("v1,") }
.map { |token| Base64.strict_decode64(token[3..]) }
Array(secrets).each do |secret|
key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
expected = OpenSSL::HMAC.digest("SHA256", key, content)
return true if received.any? do |sig|
sig.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(sig, expected)
end
end
raise "signature mismatch"
end
No Rails o corpo bruto é request.raw_post e os headers ficam em request.headers["webhook-signature"] etc.
PHP
<?php
const TOLERANCE_SECONDS = 300;
function verifyKobanaWebhook(array $headers, string $rawBody, array|string $secrets): bool
{
$id = $headers['webhook-id'] ?? null;
$timestamp = $headers['webhook-timestamp'] ?? null;
$signature = $headers['webhook-signature'] ?? null;
if (!$id || !$timestamp || !$signature) {
throw new RuntimeException('missing signature headers');
}
$ts = (int) $timestamp;
if (abs(time() - $ts) > TOLERANCE_SECONDS) {
throw new RuntimeException('timestamp outside tolerance');
}
$content = "$id.$ts.$rawBody";
$received = [];
foreach (explode(' ', $signature) as $token) {
if (str_starts_with($token, 'v1,')) {
$received[] = base64_decode(substr($token, 3), true);
}
}
foreach ((array) $secrets as $secret) {
$key = base64_decode(substr($secret, strlen('whsec_')), true);
$expected = hash_hmac('sha256', $content, $key, true);
foreach ($received as $sig) {
if ($sig !== false && hash_equals($expected, $sig)) {
return true;
}
}
}
throw new RuntimeException('signature mismatch');
}
// $rawBody = file_get_contents('php://input');
Usando uma biblioteca Standard Webhooks
Como headers e algoritmo seguem a especificação, as bibliotecas oficiais standardwebhooks (JavaScript, Python, Ruby, PHP, Go, Java, C#, Rust, Elixir) verificam entregas da Kobana sem adaptação:
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.KOBANA_WEBHOOK_SECRET); // o valor whsec_
const payload = wh.verify(rawBody, headers); // lança exceção se falhar
from standardwebhooks import Webhook
wh = Webhook(os.environ["KOBANA_WEBHOOK_SECRET"])
payload = wh.verify(raw_body, headers) # lança exceção se falhar
As bibliotecas aplicam a tolerância de 5 minutos e a comparação em tempo constante por você. Durante uma rotação, verifique com o secret que você tem no momento: o header já traz um token para ele.
Rotacionando o secret
curl -X POST https://api.inbox.kobana.com.br/v1/webhooks/$WEBHOOK_ID/regenerate-secret \
--cert ./client.crt --key ./client.key \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: $(uuidgen)" \
-d '{ "previous_secret_grace_hours": 24 }'
{
"signing_secret": "whsec_…",
"previous_secret_expires_at": "2026-09-07T15:00:00.000Z"
}
- Por padrão o secret anterior continua assinando por 24 horas (
previous_secret_grace_hours, 0–168). Nessa janela toda entrega leva dois tokens, então você implanta o novo secret no seu ritmo sem perder nenhum evento. - Se o secret atual vazou, envie
previous_secret_grace_hours: 0— o antigo para de assinar imediatamente. - Nunca há mais de dois secrets válidos. Rotacionar de novo dentro da janela descarta o mais antigo.
GET /webhooks/{id}expõesigning_secret_rotated_atesigning_secret_previous_expires_atpara auditar rotações; os valores dos secrets nunca voltam a ser legíveis.- A resposta vai só para o primeiro chamador. Um replay com o mesmo
X-Idempotency-Keyretorna410em vez do secret.
Checklist de hardening
- Sirva o endpoint em HTTPS com certificado válido;
https://é obrigatório fora dedevelopment. - Verifique a assinatura sobre o corpo bruto antes de qualquer parse ou middleware do framework.
- Rejeite entregas cujo
X-Kobana-Environmentnão corresponda ao ambiente que o seu endpoint atende. - Opcionalmente configure
auth(basic,bearerou header customizado) no webhook como segundo fator e valide também. - Deduplique por
X-Idempotency-Key/X-Kobana-Event-Id(processar cada evento uma vez) ouX-Kobana-Delivery-Id(registrar cada tentativa); guarde os ids pelo menos pelo horizonte de retry (~33 h). - Nunca logue o secret e mascare
webhook-signature/X-Kobana-Signaturenos logs de requisição. - Rotacione periodicamente e imediatamente após qualquer suspeita de vazamento.
Diagnosticando uma assinatura inválida
| Sintoma | Causa provável |
|---|---|
| Toda entrega falha na verificação | Secret errado (sandbox vs. produção, ou um antigo após rotação com graça 0), ou o corpo foi re-serializado antes do hash. |
| Falha só atrás de proxy / API gateway | O gateway reescreveu o corpo (pretty-print, conversão de charset) ou removeu os headers webhook-*. Verifique sobre os bytes brutos que ele repassa, ou use os headers X-Kobana-*. |
timestamp outside tolerance | Relógio do servidor desalinhado. Sincronize com NTP; não alargue a janela além de poucos minutos. |
| Funciona e para após uma rotação | O novo secret nunca foi implantado e a janela de graça acabou. Rotacione de novo e publique o novo valor dentro da janela. |