# Webhooks de chat

Recibe en tu servidor los catorce eventos del Chat (mensajes, canales, aplicaciones del canal y agentes), firmados con HMAC, y responde sin entrar en bucle.

Con los webhooks de chat, UTILIA OS avisa a tu servidor cuando pasa algo en un canal donde está tu aplicación: un mensaje nuevo, una edición, un cambio en el canal o una mención. Así no tienes que consultar la API cada poco y puedes reaccionar al momento, por ejemplo contestando en el hilo.

Para recibirlos, tu aplicación necesita el scope `chat:events:subscribe` y una dirección de webhook con su secreto configurada en su ficha. Solo llegan novedades de los canales donde tiene una concesión activa y sigue siendo miembro: a diferencia de otros avisos de UTILIA OS, los del chat no se reparten entre todas las aplicaciones suscritas, sino solo entre las que están en ese canal.

**Un aviso, de principio a fin**

Participantes: UTILIA OS y Tu servidor.

1. UTILIA OS → Tu servidor: `POST a tu dirección de webhook`. CHAT_MESSAGE_CREATED con el messageId, firmado en X-Utilia-Signature.
2. Tu servidor (por su cuenta): Verifica la firma y descarta la entrega si ya procesaste su X-Utilia-Delivery-Id.
3. Tu servidor → UTILIA OS (respuesta): 200 enseguida. Cualquier 2xx da la entrega por buena. Un 5xx o un fallo de red se reintenta, hasta 5 intentos.
4. Tu servidor → UTILIA OS: `GET /api/external/v1/chat/messages/:messageId`. Con tu clave de API, para leer el texto.
5. UTILIA OS → Tu servidor (respuesta): El mensaje, ya filtrado. Si el autor es una aplicación, no respondas.

## Eventos

Todos usan la versión de contenido `2026-07`, que viaja en la cabecera `X-Utilia-Schema-Version`.

| Evento | Cuándo llega |
| - | - |
| `CHAT_MESSAGE_CREATED` | Se publica un mensaje visible en el canal. |
| `CHAT_MESSAGE_UPDATED` | Se edita un mensaje visible. |
| `CHAT_MESSAGE_DELETED` | Se elimina un mensaje. |
| `CHAT_CHANNEL_CREATED` | Se crea un canal en el que tu aplicación ya está. |
| `CHAT_CHANNEL_UPDATED` | Cambian el nombre, la descripción o la visibilidad en el directorio del canal. |
| `CHAT_CHANNEL_ARCHIVED` | Se archiva el canal. |
| `CHAT_MEMBER_ADDED` | Se da acceso a otra aplicación externa en un canal donde está la tuya. |
| `CHAT_MEMBER_REMOVED` | Otra aplicación externa deja de tener acceso a un canal compartido. |
| `CHAT_APP_MENTIONED` | Un mensaje visible menciona a tu aplicación. |
| `CHAT_AGENT_TASK_EXECUTED` | Un agente de IA del canal ejecuta una tarea programada (un boletín, un resumen...). |
| `CHAT_AGENT_PENDING_APPROVAL` | Un agente que propuso tu aplicación queda pendiente de aprobación. |
| `CHAT_AGENT_APPROVED` | Un administrador del canal aprueba ese agente: pasa a `ACTIVE`. |
| `CHAT_AGENT_REJECTED` | El agente se rechaza o caduca sin aprobación. |
| `CHAT_AGENT_BUDGET_EXCEEDED` | Los agentes de tu aplicación superan su tope mensual de coste de IA y se pausan. |

Los cuatro últimos se entregan solo a la aplicación que creó el agente, aunque ya no tenga acceso al canal: describen su propio recurso. Detalle en [Gestionar agentes de IA](https://os.utilia.ai/docs/developers/chat-platform/managed-agents).

Al configurar el webhook, declara los eventos con estos nombres en mayúsculas, y compara con ellos en tu receptor: es lo que viaja en `event`. Los nombres con puntos (`chat.message.created` y similares) no llegan nunca ni valen al suscribirse. Hasta la 4.5, el tipo `ChatWebhookEvent` del SDK de JavaScript declaraba esos nombres con puntos, así que un receptor que comparaba con él no reaccionaba a ningún aviso; desde la 4.6.0 declara los reales.

> **Nota: El aviso no lleva el texto del mensaje**
>
> Nunca llega un aviso por un mensaje interno del equipo, privado o una respuesta privada de la IA. Y los avisos de mensajes no traen el contenido: traen el `messageId`, y tu aplicación lo lee con [`GET /api/external/v1/chat/messages/:messageId`](https://os.utilia.ai/docs/developers/chat-platform/read-endpoints), que vuelve a aplicar el filtro de visibilidad. Así el texto solo viaja por una petición autenticada.

## Contenido del aviso

Cada aviso llega como un `POST` con este cuerpo:

| Nombre | Tipo | Descripción |
| - | - | - |
| `event` | `string` | Nombre del evento, por ejemplo CHAT_MESSAGE_CREATED. |
| `timestamp` | `string` | Momento de emisión (ISO 8601). |
| `app` | `object` | Tu aplicación: { id, slug }. |
| `data` | `object` | Datos del evento. En los de mensaje, siempre messageId y channelId. |
| `meta` | `object` | Datos de la entrega: { schemaVersion }. |

```json
{
  "event": "CHAT_MESSAGE_CREATED",
  "timestamp": "2026-10-08T09:00:03.512Z",
  "app": { "id": "app_termometro", "slug": "termometro" },
  "data": { "messageId": "msg_a1b2c3d4", "channelId": "cnv_8f3a21" },
  "meta": { "schemaVersion": "2026-07" }
}
```

Y con estas cabeceras:

| Nombre | Tipo | Descripción |
| - | - | - |
| `X-Utilia-Signature` | `string` | Firma HMAC-SHA256, con el prefijo sha256=. |
| `X-Utilia-Timestamp` | `string` | Momento de emisión en milisegundos. Entra en la firma y en la protección contra reenvíos. |
| `X-Utilia-Event` | `string` | Nombre del evento. |
| `X-Utilia-Delivery-Id` | `string` | Identificador de la entrega. Es el mismo en todos los reintentos: úsalo para no procesar dos veces un aviso. |
| `X-Utilia-Schema-Version` | `string` | Versión del contenido: 2026-07. |

## Verificar la firma

La firma se calcula igual que en el resto de avisos de UTILIA OS; el detalle está en [Firma HMAC de los avisos](https://os.utilia.ai/docs/developers/webhooks/hmac-signature). En resumen:

- Es `sha256=` seguido del HMAC-SHA256 de la cadena `${timestamp}.${cuerpo}`, con el secreto de tu aplicación como clave. El `timestamp` es el de la cabecera `X-Utilia-Timestamp`.
- Verifícala siempre sobre el cuerpo tal como llega, sin volver a serializarlo, y con una comparación de tiempo constante.
- Rechaza el aviso si su `timestamp` se aleja más de 300 segundos de tu hora.

Los SDK lo hacen por ti con `sdk.webhooks.verify`, sin llamadas de red.

## Entregas y reintentos

Funcionan como en el resto de la plataforma ([Entregas y reintentos de los avisos](https://os.utilia.ai/docs/developers/webhooks/delivery-and-retries)):

- Hasta 5 intentos por aviso, con esperas crecientes de 5, 10, 20 y 40 segundos.
- Se reintentan los errores 5xx, los fallos de red y los códigos 408, 425 y 429. Cualquier otro 4xx se da por definitivo en el primer intento.
- El historial de entregas se consulta en la ficha de la aplicación, desde donde también puedes reintentar una entrega a mano.

Por eso, si tu receptor responde 401 porque la firma no cuadra, ese aviso no se volverá a enviar solo. Si la causa era un secreto mal configurado, corrígelo y reintenta la entrega desde la ficha.

## Un receptor que no entra en bucle

Un buen receptor hace tres cosas: verifica la firma, descarta duplicados y no reacciona a los mensajes de aplicaciones, para no acabar contestándose a sí mismo. UTILIA OS ya no envía a tu aplicación los avisos de sus propios mensajes, pero si en el canal hay varias aplicaciones, dos de ellas podrían responderse sin fin: comprueba siempre `author.kind`.

### Node.js (Express)

```javascript
import express from 'express';
import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: process.env.UTILIA_API_KEY,
});
const WEBHOOK_SECRET = process.env.UTILIA_WEBHOOK_SECRET;

const app = express();
// Cuerpo tal como llega: la firma se comprueba byte a byte.
app.use(express.raw({ type: 'application/json' }));

const procesados = new Set(); // en producción, guárdalo en tu base de datos

app.post('/webhooks/chat', async (req, res) => {
  let aviso;
  try {
    aviso = sdk.webhooks.verify(req.body.toString('utf8'), req.headers, WEBHOOK_SECRET);
  } catch {
    return res.status(401).send('firma no válida');
  }

  const entrega = req.header('X-Utilia-Delivery-Id');
  if (procesados.has(entrega)) return res.status(200).send('duplicado');
  procesados.add(entrega);

  // Contesta enseguida y haz el trabajo después.
  res.status(200).send('ok');

  if (aviso.event === 'CHAT_MESSAGE_CREATED') {
    const mensaje = await sdk.chat.getMessage(aviso.data.messageId);
    if (mensaje.author.kind === 'APP') return; // nunca respondas a una aplicación
    // ...tu lógica aquí...
  }
});

app.listen(3000);
```

### Python (Flask)

```python
import os
from flask import Flask, request
from utilia_sdk import UtiliaSDKSync

app = Flask(__name__)
sdk = UtiliaSDKSync(
    base_url="https://os.utilia.ai/api",
    api_key=os.environ["UTILIA_API_KEY"],
)
WEBHOOK_SECRET = os.environ["UTILIA_WEBHOOK_SECRET"]

procesados: set[str] = set()  # en producción, guárdalo en tu base de datos


@app.post("/webhooks/chat")
def recibir_aviso():
    cuerpo = request.get_data(as_text=True)  # tal como llega, sin reserializar
    try:
        aviso = sdk.webhooks.verify(cuerpo, request.headers, WEBHOOK_SECRET)
    except Exception:
        return "firma no válida", 401

    entrega = request.headers["X-Utilia-Delivery-Id"]
    if entrega in procesados:
        return "duplicado", 200
    procesados.add(entrega)

    if aviso["event"] == "CHAT_MESSAGE_CREATED":
        mensaje = sdk.chat.get_message(aviso["data"]["messageId"])
        if mensaje.author.kind != "APP":  # nunca respondas a una aplicación
            pass  # ...tu lógica aquí...

    return "ok", 200
```

## Siguiente paso

El [ejemplo del termómetro](https://os.utilia.ai/docs/developers/chat-platform/example-thermometer) completa este receptor: publica una lectura, recibe la respuesta del equipo y contesta en el hilo.
