Seguridad
Dos capas protegen la comunicación: la firma HMAC (prueba que el payload vino del Dunning y no fue adulterado) y la autenticación del request (credenciales que el Dunning presenta a tu endpoint). La primera va siempre; la segunda es opcional.
Headers y firma (estándar Kobana)
Toda entrega es un POST con Content-Type: application/json y los headers de abajo — los mismos en todos los productos Kobana, así un único receptor atiende varios productos y ambientes y enruta con seguridad:
| Header | Valor |
|---|---|
X-Kobana-App | dunning — el producto Kobana que envió la entrega. |
X-Kobana-Environment | development, sandbox o production. Nunca dejes que una entrega sandbox toque datos de producción. |
X-Kobana-Webhook-Id | Id del endpoint registrado al que pertenece la entrega. |
X-Kobana-Event | Slug del evento (ej.: charge.paid). |
X-Kobana-Event-Id | Id del evento. Los reintentos del mismo evento lo repiten — úsalo para deduplicar. |
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,<base64 del HMAC> — uno o más tokens separados por espacio. |
webhook-id, webhook-timestamp, webhook-signature | Los mismos tres valores, en el layout Standard Webhooks — verificables con una biblioteca estándar. |
Los headers legados X-Webhook-Event, X-Webhook-Event-Id, X-Webhook-Delivery-Id, X-Webhook-Attempt, X-Webhook-Timestamp y X-Webhook-Signature (t=<unix>,v1=<hex> sobre "{t}.{body}") se siguen enviando, deprecados, para integraciones existentes. Migra a los X-Kobana-*: se eliminarán en una próxima versión del contrato.
Cómo se calcula la firma
clave = base64_decode(secret sin el prefijo "whsec_")
contenido = delivery_id + "." + timestamp + "." + body_crudo
firma = "v1," + base64(HMAC_SHA256(clave, contenido))
body_crudoes el cuerpo de la request exactamente como se recibió, byte a byte. No lo parsees y re-serialices: el orden de claves, espacios o escapes cambiarían y la firma dejaría de coincidir.- Atar el
delivery_idal contenido firmado impide que una request capturada se reenvíe con otro id para saltarse tu deduplicación; el timestamp limita el replay en el tiempo. - El secret tiene la forma
whsec_+ base64 de 32 bytes aleatorios y se muestra una sola vez. Los secrets antiguos (sin prefijo, o con hexadecimal tras él) siguen siendo válidos: la clave es el base64 decodificado tras el prefijo, o los bytes UTF-8 del secret cuando no hay prefijo. - Durante una rotación de secret el header lleva un token por cada secret aún válido (
v1,AAA… v1,BBB…). La entrega es válida si cualquier token coincide con cualquier secret que tengas.
Verificando una entrega
- Lee
webhook-id,webhook-timestampywebhook-signature(o los equivalentesX-Kobana-*). Rechaza la request si falta alguno. - Rechaza si
|ahora − timestamp| > 300segundos: cinco minutos absorben la desviación de reloj y limitan los replays. - Arma el contenido firmado con el body crudo y calcula el HMAC esperado para cada secret en el que confíes.
- Compara con cada token
v1,en tiempo constante — nunca con==, que devuelve en el primer byte distinto y filtra el digest por timing. - Deduplica por
X-Kobana-Event-Idpara procesar cada evento una vez, o porwebhook-idpara registrar cada intento. - Responde
2xxrápido y haz el trabajo pesado de forma asíncrona. Cualquier otra respuesta entra en el ciclo de reintentos.
const crypto = require('node:crypto');
/**
* @param headers headers de la request, con nombres en minúsculas
* @param rawBody body crudo (string o Buffer), ANTES de cualquier JSON.parse
* @param secrets secret actual (y el anterior, durante una rotación)
*/
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")
Con Express, conserva el body crudo montando express.raw({ type: 'application/json' }) en la ruta del webhook (no express.json()), verifica y solo entonces haz JSON.parse. Con Django/Flask usa request.body/request.get_data() — nunca el objeto ya parseado.
Autenticación del request
Si tu endpoint está detrás de un gateway que exige credenciales, configura la autenticación en el registro:
| Tipo | Qué envía el Dunning |
|---|---|
Ninguna (none) | Solo la firma HMAC |
Basic (basic) | Authorization: Basic <usuário:senha> |
Bearer (bearer) | Authorization: Bearer <token> |
Header (header) | Un header custom con nombre y valor definidos por ti |
Las credenciales se cifran en reposo (AES-256-GCM) y nunca aparecen en la pista de entregas: en la captura de request/response, la firma y los headers de autenticación se graban como [REDACTED].
Rotación del secret
El botón de rotación (en la fila del endpoint) genera un secret nuevo, mostrado una única vez, e invalida el anterior de inmediato (también disponible vía POST /v1/webhook-endpoints/{id}/rotate-secret). Rota el secret si sospechas de una filtración o como higiene periódica; actualiza el consumidor en ese mismo momento, porque las entregas firmadas con el secret anterior dejarán de validar.
Protecciones del lado del Dunning
- Solo HTTPS: las URLs
http://se rechazan en el registro. - Anti-SSRF: la URL se valida en el registro y se revalida en cada entrega (DNS re-resuelto, IP fijada, redes internas bloqueadas). Los redirects no se siguen. Un destino que pasa a resolver hacia una red interna se bloquea permanentemente (
SSRF blocked, sin reintento). - Las respuestas de tu endpoint se capturan con los headers sensibles (
Set-Cookie,Authorization...) redactados.