Saltar al contenido principal

Webhooks

Los webhooks son notificaciones HTTP que envía la API de Dashboard Mágico cada vez que ocurre un evento relevante en tu Organización: un token de API creado, desactivado, rotado o revocado, etc.

En lugar de hacer polling, registras un endpoint HTTP público y recibes un POST con el payload del evento en cuanto sucede.

Registro​

Gestiona los endpoints en Dashboard → Configuración → API → Webhooks. Cada endpoint tiene:

  • URL: destino HTTPS público de tu servidor.
  • Eventos suscritos: los event types que deseas recibir. Si está vacío, recibe todos.
  • Estado: active o inactive.
  • Secret: cadena secreta usada para firmar cada entrega (HMAC-SHA256). Se genera automáticamente y puede rotarse.
  • Auth headers: opcionalmente, encabezados extra (Basic, Bearer, personalizados) inyectados en cada solicitud.

Entrega​

Cada evento dispara una solicitud POST a la URL registrada:

POST /tu-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Kobana-Webhooks/1.0
X-Kobana-App: dashboards
X-Kobana-Environment: production
X-Kobana-Webhook-Id: 7c1e9d2a-3b4f-4c5d-8e6f-0a1b2c3d4e5f
X-Kobana-Event: api_key.status_changed
X-Kobana-Event-Id: 3d1f0c9a-5b2e-4c7d-9e1f-8a6b4c2d0e9f
X-Kobana-Delivery-Id: 9b2e4a10-7c3f-4d8e-a1b2-c3d4e5f60718
X-Kobana-Timestamp: 1753272000
X-Kobana-Signature: v1,MEUCIQD3…
webhook-id: 9b2e4a10-7c3f-4d8e-a1b2-c3d4e5f60718
webhook-timestamp: 1753272000
webhook-signature: v1,MEUCIQD3…

{...payload JSON, en snake_case...}

Los encabezados se describen en Headers y firma, más abajo.

Éxito​

Cualquier respuesta 2xx en hasta 30 segundos marca la entrega como exitosa. Cualquier otra cosa (timeout, 4xx, 5xx) se trata como fallo.

Reintentos​

Los fallos se reenvían con backoff exponencial:

IntentoRetraso
1inmediato
2+1 min
3+5 min
4+30 min
5+2 h
6+6 h
7+24 h

Tras 7 intentos, la entrega queda marcada como failed de forma permanente. Las entregas fallidas siguen disponibles en el dashboard para reenvío manual.

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:

HeaderValor
X-Kobana-Appdashboards — el producto Kobana que envió la entrega.
X-Kobana-Environmentdevelopment, sandbox o production. Nunca dejes que una entrega sandbox toque datos de producción.
X-Kobana-Webhook-IdId del endpoint registrado al que pertenece la entrega.
X-Kobana-EventSlug del evento (ej.: api_key.status_changed).
X-Kobana-Event-IdId del evento. Los reintentos del mismo evento lo repiten — úsalo para deduplicar.
X-Kobana-Delivery-IdId único de este intento de entrega. Forma parte del contenido firmado.
X-Kobana-TimestampUnix time (segundos) en que se firmó la entrega. Forma parte del contenido firmado.
X-Kobana-Signaturev1,<base64 del HMAC> — uno o más tokens separados por espacio.
webhook-id, webhook-timestamp, webhook-signatureLos mismos tres valores, en el layout Standard Webhooks — verificables con una biblioteca estándar.

Los headers legados X-Signature (t=<unix>,v1=<hex> sobre "{t}.{body}"), X-Delivery-Id, X-Event-Id, X-Event-Type, X-Timestamp, X-App y X-Environment 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_crudo es 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_id al 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​

  1. Lee webhook-id, webhook-timestamp y webhook-signature (o los equivalentes X-Kobana-*). Rechaza la request si falta alguno.
  2. Rechaza si |ahora − timestamp| > 300 segundos: cinco minutos absorben la desviación de reloj y limitan los replays.
  3. Arma el contenido firmado con el body crudo y calcula el HMAC esperado para cada secret en el que confíes.
  4. Compara con cada token v1, en tiempo constante — nunca con ==, que devuelve en el primer byte distinto y filtra el digest por timing.
  5. Deduplica por X-Kobana-Event-Id para procesar cada evento una vez, o por webhook-id para registrar cada intento.
  6. Responde 2xx rá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.

Estructura del payload​

Todos los eventos comparten el mismo sobre:

{
"id": "evt_01HXYZABC123",
"type": "api_key.status_changed",
"created_at": "2026-05-20T12:00:00.000Z",
"environment": "production",
"version": "2026-03-01",
"data": { /* recurso completo */ },
"changes": { /* opcional, cambios desde el estado anterior */ }
}
CampoDescripción
idID único del evento.
typeTipo del evento (ej.: api_key.status_changed).
created_atTimestamp ISO 8601 (UTC) en que se generó el evento.
environmentproduction o sandbox.
versionVersión del schema del payload (string YYYY-MM-DD).
dataEl recurso completo en su estado actual.
changesEn eventos *.updated y *.status_changed, un mapa campo: {from, to}. Puede faltar en eventos de creación/eliminación.

Eventos disponibles​

RecursoEventos
Token de APIapi_key.db.created, api_key.db.updated, api_key.db.deleted, api_key.status_changed, api_key.rotated, api_key.revoked

Buenas prácticas​

  • Idempotencia: usa X-Kobana-Event-Id para no procesar duplicados en los reintentos.
  • Respuesta rápida: responde 2xx en hasta 30 s. Envía el trabajo pesado a una cola asíncrona.
  • Versionado: el campo version indica el schema. Trata los campos nuevos como aditivos.
  • Validación obligatoria: valida siempre la firma antes de confiar en el payload.
  • HTTPS: usa TLS en el endpoint. Los endpoints HTTP se rechazan en producción.
  • Lista de eventos: suscríbete solo a los eventos que procesas, no a *.