Webhook
Un Webhook es una suscripción a uno o más slugs de SystemEvent. Cuando se emite un evento que coincide, la plataforma hace POST de un payload firmado a tu URL.
Propósito
- Enviar cambios de estado (nuevos correos, resultados de agentes, rechazos) a tus sistemas en tiempo real.
- Reemplazar el polling. Combínalo con
GET /system-eventspara backfill. - Reenviar a ERPs, sistemas de tickets, colas internas, relays de Slack, etc.
Schema
| Campo | Tipo | Descripción |
|---|---|---|
id | UUID | ID interno. |
workspace_id | UUID | Workspace propietario. |
inbox_id | UUID | null | Si está establecido, solo se entregan eventos de este inbox. |
status | enum | active, inactive. Cambia automáticamente a inactive tras N fallos consecutivos. |
label | string | Etiqueta legible. |
url | string | URL de destino. Se requiere https:// fuera de development. |
signing_secret | string | whsec_…. Devuelto solo en la creación / rotación. Firma cada entrega — ver Seguridad de webhooks. |
signing_secret_rotated_at | datetime | null | Última rotación; null si nunca se rotó. |
signing_secret_previous_expires_at | datetime | null | Hasta cuándo el secreto anterior sigue firmando entregas tras una rotación. |
auth | enum | none, basic, bearer, header. |
auth_basic_user | string | null | Cuando auth=basic. |
auth_basic_password | encrypted | Cuando auth=basic. Nunca se devuelve. |
auth_bearer_token | encrypted | Cuando auth=bearer. Nunca se devuelve. |
auth_header_key | string | null | Cuando auth=header — nombre del header personalizado. |
auth_header_value | encrypted | Cuando auth=header. Nunca se devuelve. |
subscribed_events | string[] | Array de valores de SystemEvent.slug. |
consecutive_failures | int | Contador interno — cambia status a inactive al superar el umbral. |
created_at / updated_at / deleted_at | datetime |
Las respuestas de GET sustituyen los campos de secreto/credencial por auth_configured: true. Para rotar el secreto de firma usa POST /webhooks/{id}/regenerate-secret (el anterior sigue firmando durante una ventana de gracia, 24 h por defecto); para cambiar credenciales de auth usa PATCH con un nuevo payload.
Eventos suscritos
subscribed_events acepta cualquier SystemEvent.slug.
Eventos de fila — emitidos automáticamente en cada create/update de la fila (updateMany/upsert masivos no emiten; dentro de una transacción se registran tras el commit):
workspace.db.created,workspace.db.updatedinbox.db.created,inbox.db.updatedagent.db.created,agent.db.updated,agent.version_updated— versión del prompt incrementada;data.version: { from, to }.agent_run.db.created,agent_run.db.updated,agent_run.status_changed—data.status: { from, to }.webhook.db.created,webhook.db.updatedemail.db.created,email.db.updated,email.status_changed—data.status: { from, to }.workflow_run.db.created,workflow_run.db.updated,workflow_run.status_changed—data.status: { from, to }.
Todo *.db.updated trae data.changed_fields (columnas modificadas, snake_case). Un soft delete aparece como *.db.updated con changed_fields: ["deleted_at"].
Eventos de dominio — emitidos explícitamente:
email.rejected— correo rechazado;data.reason.email.agent.processed— el agente terminó;data.cost_limit_breached, ydata.workflow_run_idcuando el agente devolvió un workflow.workflow.run.succeeded/workflow.run.failed— un workflow del agente terminó; el fallo llevadata.failed_step(el primer paso que falló),data.failed_steps(todos los pasos que fallaron) ydata.error(el error del primer fallo).workspace.cost_limit_breached— el workspace superómonthly_cost_limit.webhook.delivery.failed— una entrega agotó los reintentos.webhook.ping— evento sintético desde/webhooks/{id}/test.
Se pueden agregar nuevos slugs sin previo aviso. Los slugs no suscritos se ignoran silenciosamente.
Contrato de entrega
Headers del POST saliente:
Content-Type: application/json
User-Agent: Kobana-Inbox-Webhook/1.0
X-Kobana-App: inbox
X-Kobana-Environment: production
X-Kobana-Webhook-Id: <Webhook.id>
X-Kobana-Event: email.db.created
X-Kobana-Event-Id: <SystemEvent.id>
X-Idempotency-Key: <SystemEvent.id>
X-Kobana-Delivery-Id: <WebhookDelivery.id>
X-Kobana-Timestamp: 1747490400
X-Kobana-Signature: v1,BASE64(HMAC_SHA256(key, "<delivery_id>.<timestamp>.<raw_body>"))
webhook-id: <WebhookDelivery.id>
webhook-timestamp: 1747490400
webhook-signature: v1,BASE64(HMAC_SHA256(key, "<delivery_id>.<timestamp>.<raw_body>"))
Más cualquier header Authorization (u otro personalizado) configurado. X-Kobana-App y X-Kobana-Environment (development, sandbox, production) identifican al emisor cuando un mismo endpoint recibe webhooks de varios productos o entornos Kobana. Los headers webhook-* repiten id de entrega, timestamp y firma en el layout Standard Webhooks, así que cualquier verificador listo funciona. X-Idempotency-Key lleva el id del evento en el envío original y en cada reintento automático, para que un endpoint que respeta claves de idempotencia lo procese una vez. Un replay manual lleva una clave nueva y aleatoria — ver Replay.
Body:
{
"event": { "slug": "email.agent.processed", "id": "uuid" },
"delivered_at": "2026-05-17T12:00:00Z",
"workspace": { "id": "uuid", "external_id": "string | null", "slug": "acme" },
"data": {
"organization": { "public_id": "uuid", "external_id": "string | null", "slug": "acme-org" },
"agent": { "id": "uuid", "external_id": "string | null", "version": 3 },
"inbox": { "id": "uuid", "external_id": "string | null", "slug": "support" },
"email": {
"id": "uuid",
"message_id": "<...@mail>",
"status": "processed",
"rejection_reason": null,
"metadata": null,
"agent_metadata": {},
"custom_metadata": null,
"aws_ses_metadata": {},
"download_url": "https://app.inbox.kobana.com.br/eml/uuid/eyJhbGciOi..."
},
"agent_run": {
"id": "uuid",
"status": "success",
"llm_model": "anthropic/claude-sonnet-5",
"agent_version": 3,
"input_tokens": 1200,
"output_tokens": 300,
"cache_read": 0,
"cache_write": 0,
"total_cost": 0.0123,
"duration_ms": 4200
},
"cost_limit_breached": false
}
}
data trae snapshots de los recursos al momento de la entrega (organization, agent, inbox, email, agent_run) más las claves específicas del evento, todo en snake_case. Los eventos de e-mail traen las cinco secciones, null cuando el evento no está ligado a ese recurso; los demás eventos traen el subconjunto listado abajo. agent.version es la versión del prompt que realmente se ejecutó cuando existe agent_run; de lo contrario, la versión actual del agente. Los registros con soft-delete conservan el id y tienen los demás campos en null.
email.download_url es un enlace listo para usar hacia el .eml original, válido por 30 minutos desde la entrega — ver Descargando el .eml desde un webhook para entender en qué se diferencia de GET /v1/emails/{id}/eml.
Secciones y claves específicas por evento:
| Evento | Secciones en data | Claves extra |
|---|---|---|
email.db.created | workspace, organization, agent, inbox, email, agent_run | — |
email.db.updated | ídem | changed_fields |
email.status_changed | ídem | status: { from, to } |
email.rejected | ídem | reason |
email.agent.processed | ídem | cost_limit_breached, workflow_run_id |
workflow.run.succeeded | workspace, organization, agent, inbox, email, agent_run (resuelto por la ejecución de agente que produjo el workflow) | workflow_run_id, summary (total, succeeded, error), duration_ms |
workflow.run.failed | ídem | ídem, más failed_step, failed_steps y error |
agent_run.db.created / agent_run.db.updated | workspace, organization, agent, inbox, email, agent_run | changed_fields (updated) |
agent_run.status_changed | workspace, organization, agent, inbox, email, agent_run | status: { from, to } |
workspace.cost_limit_breached | workspace, organization, agent, inbox, email, agent_run (inbox resuelto a través del run) | — |
workspace.db.created / workspace.db.updated | workspace, organization | changed_fields (updated) |
inbox.db.created / inbox.db.updated | workspace, organization, inbox, agent | changed_fields (updated) |
agent.db.created / agent.db.updated | workspace, organization, agent | changed_fields (updated) |
agent.version_updated | workspace, organization, agent | version: { from, to } |
webhook.db.created / webhook.db.updated | workspace, organization, inbox, webhook: { id, label } | changed_fields (updated) |
webhook.delivery.failed | workspace, organization, agent, inbox, email, agent_run, webhook, event: { id, slug } | — |
webhook.ping | workspace, organization, webhook: { id, label } | — |
Verificando la firma
Recalcula HMAC_SHA256(key, "{delivery_id}.{timestamp}.{raw_request_body}"), donde key es la parte del secreto tras whsec_ decodificada de base64, codifícalo en base64 y compáralo en tiempo constante con el/los token(s) v1,… de X-Kobana-Signature (o webhook-signature). Durante una rotación el header trae un token por secreto válido. Rechaza todo aquello donde |now - timestamp| > 300s y deduplica por X-Kobana-Delivery-Id. Guía paso a paso y ejemplos de código: Seguridad de webhooks.
Política de reintentos
- Éxito: HTTP
2xxdentro de 10 s. - Fallo: timeout / no-2xx.
- Backoff:
1m, 5m, 30m, 2h, 6h, 24h— 6 intentos en ~33 h. - Tras agotarse:
WebhookDelivery.status=failedyconsecutive_failures++. - Al superar el umbral (configurable, default 10):
Webhook.statuscambia ainactive; se notifica al dueño del workspace.
Ejemplo
{
"id": "f6a7b8c9-0d1e-4f2a-3b4c-5d6e7f8a9b0c",
"workspace_id": "a8b2c1d4-9e7f-4a3b-8c5d-1f2e3a4b5c6d",
"inbox_id": "b3c4d5e6-7a89-4b0c-9d1e-2f3a4b5c6d7e",
"status": "active",
"label": "Triage → ERP",
"url": "https://erp.acme.com/inbox/hook",
"auth": "bearer",
"auth_configured": true,
"subscribed_events": ["email.db.created", "email.agent.processed"],
"consecutive_failures": 0,
"created_at": "2026-01-04T12:00:00.000Z",
"updated_at": "2026-05-17T08:12:00.000Z",
"deleted_at": null
}