Seguridad de webhooks
Cada entrega de webhook se firma con un secreto que solo tú y Kobana conocen. Verificar la firma antes de actuar sobre el payload garantiza tres cosas:
- Autenticidad — la solicitud realmente vino de Kobana, no de alguien que descubrió la URL de tu endpoint.
- Integridad — el cuerpo no fue alterado en el camino.
- Frescura — la solicitud no es un replay de una entrega antigua.
El esquema sigue la especificación Standard Webhooks, así que puedes verificar con una biblioteca lista o con unas pocas líneas de código en cualquier lenguaje.
El secreto de firma
Cada webhook tiene su propio secreto con el formato whsec_ + base64 de 32 bytes aleatorios, por ejemplo:
whsec_c2VjcmV0LWJ5dGVzLWV4YW1wbGUtc2VjcmV0LWJ5dGVz
- Se devuelve una sola vez, en la respuesta de
POST /webhooksy dePOST /webhooks/{id}/regenerate-secret. Nunca se devuelve enGET. - Guárdalo en un gestor de secretos o variable de entorno — nunca en el código fuente. El prefijo
whsec_permite que los escáneres de secretos (GitHub, gitleaks) lo detecten si termina en un repositorio. - Los secretos son por entorno: un webhook de
sandboxy uno deproductionnunca comparten el mismo. - Puedes usar tu propio secreto enviando
signing_secretal crear o rotar. Debe serwhsec_+ base64 estándar de 24–64 bytes aleatorios.
Headers
| Header | Valor |
|---|---|
X-Kobana-App | inbox — el producto Kobana que envía el webhook. |
X-Kobana-Environment | development, sandbox o production. |
X-Kobana-Webhook-Id | El Webhook.id al que pertenece la entrega. |
X-Kobana-Event | Slug del evento, p. ej. email.agent.processed. |
X-Kobana-Event-Id | SystemEvent.id. Los reintentos del mismo evento comparten el valor. |
X-Idempotency-Key | Mismo valor que X-Kobana-Event-Id en el envío original y en cada reintento automático, para que un endpoint que respeta claves de idempotencia procese cada evento una vez. Un replay manual envía un valor nuevo y aleatorio, para que el destino lo trate como una entrega nueva. |
X-Kobana-Delivery-Id | Id único de este intento de entrega. Forma parte del contenido firmado. |
X-Kobana-Timestamp | Unix time (segundos) en que se firmó la entrega. Forma parte del contenido firmado. |
X-Kobana-Signature | v1,<HMAC en base64> — uno o más tokens separados por espacio. |
webhook-id, webhook-timestamp, webhook-signature | Los mismos valores de los tres anteriores, en el layout Standard Webhooks. |
X-Kobana-App y X-Kobana-Environment permiten que un solo endpoint reciba webhooks de varios productos y entornos Kobana y los enrute con seguridad — por ejemplo, nunca dejar que una entrega de sandbox toque datos de producción.
Cómo se calcula la firma
key = base64_decode(secreto sin el prefijo "whsec_")
signed_content = delivery_id + "." + timestamp + "." + raw_body
signature = "v1," + base64(HMAC_SHA256(key, signed_content))
raw_bodyes el cuerpo de la solicitud exactamente como se recibió, byte a byte. No lo parsees y vuelvas a serializar: el orden de claves, los espacios o el escape de unicode cambiarían y la firma dejaría de coincidir.- Incluir el
delivery_iden el contenido firmado impide que una solicitud capturada se reenvíe con otro id para saltarse tu deduplicación. - Durante una rotación de secreto el header trae un token por secreto válido, p. ej.
v1,AAA… v1,BBB…. La entrega es válida si cualquier token coincide con cualquier secreto que tengas.
Verificando una entrega
- Lee
webhook-id,webhook-timestampywebhook-signature(o los equivalentesX-Kobana-*). Rechaza la solicitud si falta alguno. - Rechaza si
|ahora − timestamp| > 300segundos. Cinco minutos absorben la desviación de reloj y aun así acotan los replays. - Construye el
signed_contenta partir del cuerpo crudo y calcula el HMAC esperado para cada secreto en el que confíes en ese momento. - Compara con cada token
v1,usando comparación en tiempo constante. Nunca uses==: retorna en cuanto un byte difiere y filtra el digest por el tiempo de respuesta. - Deduplica por
X-Idempotency-Keysi quieres procesar cada evento una vez — los reintentos automáticos del mismo evento traen la misma clave y otro id de entrega, pero un replay manual trae deliberadamente una clave nueva, ya que su objetivo es que se actúe sobre él de nuevo — o porwebhook-id(el id de entrega) si quieres registrar cada intento. - Responde
2xxrápido y haz el trabajo pesado de forma asíncrona. Cualquier otra cosa (timeout de 10 s,3xx,4xx,5xx) se reintenta con backoff.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/**
* @param headers request headers en minúsculas
* @param rawBody el cuerpo exactamente como se recibió (string o Buffer)
* @param secrets todos los secretos en los que confías: el actual y, durante una rotación, el 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');
}
En Express, conserva el cuerpo crudo montando express.raw({ type: 'application/json' }) en la ruta del webhook (no express.json()) y llama a 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")
En Django usa request.body; en Flask, request.get_data(); en FastAPI, await request.body(). Los tres devuelven los bytes crudos.
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
En Rails el cuerpo crudo es request.raw_post y los headers están en 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 una biblioteca Standard Webhooks
Como los headers y el algoritmo siguen la especificación, las bibliotecas oficiales standardwebhooks (JavaScript, Python, Ruby, PHP, Go, Java, C#, Rust, Elixir) verifican las entregas de Kobana sin adaptación:
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.KOBANA_WEBHOOK_SECRET); // el valor whsec_
const payload = wh.verify(rawBody, headers); // lanza excepción si falla
from standardwebhooks import Webhook
wh = Webhook(os.environ["KOBANA_WEBHOOK_SECRET"])
payload = wh.verify(raw_body, headers) # lanza excepción si falla
Las bibliotecas aplican la tolerancia de 5 minutos y la comparación en tiempo constante por ti. Durante una rotación, verifica con el secreto que tengas en ese momento: el header ya trae un token para él.
Rotando el secreto
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 defecto el secreto anterior sigue firmando durante 24 horas (
previous_secret_grace_hours, 0–168). En esa ventana cada entrega lleva dos tokens, así que despliegas el nuevo secreto a tu ritmo sin perder ningún evento. - Si el secreto actual se filtró, envía
previous_secret_grace_hours: 0— el antiguo deja de firmar de inmediato. - Nunca hay más de dos secretos válidos. Rotar de nuevo dentro de la ventana descarta el más antiguo.
GET /webhooks/{id}exponesigning_secret_rotated_atysigning_secret_previous_expires_atpara auditar rotaciones; los valores de los secretos nunca vuelven a ser legibles.- La respuesta se entrega solo al primer llamador. Un replay con el mismo
X-Idempotency-Keydevuelve410en lugar del secreto.
Checklist de hardening
- Sirve el endpoint por HTTPS con certificado válido;
https://es obligatorio fuera dedevelopment. - Verifica la firma sobre el cuerpo crudo antes de cualquier parseo o middleware del framework.
- Rechaza entregas cuyo
X-Kobana-Environmentno coincida con el entorno que atiende tu endpoint. - Opcionalmente configura
auth(basic,bearero header personalizado) en el webhook como segundo factor y valídalo también. - Deduplica por
X-Idempotency-Key/X-Kobana-Event-Id(procesar cada evento una vez) oX-Kobana-Delivery-Id(registrar cada intento); conserva los ids al menos durante el horizonte de reintentos (~33 h). - Nunca registres el secreto en logs y enmascara
webhook-signature/X-Kobana-Signatureen los logs de solicitudes. - Rota periódicamente e inmediatamente tras cualquier sospecha de filtración.
Diagnosticando una firma inválida
| Síntoma | Causa probable |
|---|---|
| Todas las entregas fallan la verificación | Secreto equivocado (sandbox vs. producción, o uno antiguo tras una rotación con gracia 0), o el cuerpo fue re-serializado antes del hash. |
| Falla solo detrás de un proxy / API gateway | El gateway reescribió el cuerpo (pretty-print, conversión de charset) o eliminó los headers webhook-*. Verifica sobre los bytes crudos que reenvía, o usa los headers X-Kobana-*. |
timestamp outside tolerance | Desviación del reloj del servidor. Sincroniza con NTP; no amplíes la ventana más allá de unos minutos. |
| Funciona y deja de hacerlo tras una rotación | El nuevo secreto nunca se desplegó y la ventana de gracia terminó. Rota de nuevo y publica el nuevo valor dentro de la ventana. |