Pular para o conteúdo principal

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 /webhooks e de POST /webhooks/{id}/regenerate-secret. Nunca é retornado em GET.
  • 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 sandbox e um de production nunca compartilham o mesmo.
  • Você pode usar um secret próprio enviando signing_secret na criação ou na rotação. Ele precisa ser whsec_ + base64 padrão de 24–64 bytes aleatórios.

Headers​

HeaderValor
X-Kobana-Appinbox — o produto Kobana que envia o webhook.
X-Kobana-Environmentdevelopment, sandbox ou production.
X-Kobana-Webhook-IdO Webhook.id ao qual a entrega pertence.
X-Kobana-EventSlug do evento, ex.: email.agent.processed.
X-Kobana-Event-IdSystemEvent.id. Retentativas do mesmo evento compartilham o valor.
X-Idempotency-KeyMesmo 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-IdId único desta tentativa de entrega. Faz parte do conteúdo assinado.
X-Kobana-TimestampUnix time (segundos) em que a entrega foi assinada. Faz parte do conteúdo assinado.
X-Kobana-Signaturev1,<HMAC em base64> — um ou mais tokens separados por espaço.
webhook-id, webhook-timestamp, webhook-signatureOs 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_id no 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​

  1. Leia webhook-id, webhook-timestamp e webhook-signature (ou os equivalentes X-Kobana-*). Rejeite a requisição se algum faltar.
  2. Rejeite se |agora − timestamp| > 300 segundos. Cinco minutos absorvem diferença de relógio e ainda limitam replays.
  3. Monte o signed_content a partir do corpo bruto e calcule o HMAC esperado para cada secret em que você confia no momento.
  4. 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.
  5. Deduplique por X-Idempotency-Key se 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 por webhook-id (o id da entrega) se quiser registrar cada tentativa.
  6. Responda 2xx rá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õe signing_secret_rotated_at e signing_secret_previous_expires_at para 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-Key retorna 410 em vez do secret.

Checklist de hardening​

  • Sirva o endpoint em HTTPS com certificado válido; https:// é obrigatório fora de development.
  • Verifique a assinatura sobre o corpo bruto antes de qualquer parse ou middleware do framework.
  • Rejeite entregas cujo X-Kobana-Environment não corresponda ao ambiente que o seu endpoint atende.
  • Opcionalmente configure auth (basic, bearer ou 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) ou X-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-Signature nos logs de requisição.
  • Rotacione periodicamente e imediatamente após qualquer suspeita de vazamento.

Diagnosticando uma assinatura inválida​

SintomaCausa provável
Toda entrega falha na verificaçãoSecret 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 gatewayO 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 toleranceRelógio do servidor desalinhado. Sincronize com NTP; não alargue a janela além de poucos minutos.
Funciona e para após uma rotaçãoO novo secret nunca foi implantado e a janela de graça acabou. Rotacione de novo e publique o novo valor dentro da janela.