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.
Actualizado el . 5 min de lectura
Incluido en todos los planes. Ver precios
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
Participan: UTILIA OS, Tu servidor.
De UTILIA OS a Tu servidor:
POST a tu dirección de webhook
CHAT_MESSAGE_CREATED con el messageId, firmado en X-Utilia-Signature.Nota en Tu servidor:
Verifica la firma y descarta la entrega si ya procesaste su X-Utilia-Delivery-Id.De Tu servidor a UTILIA OS:
200 enseguida (respuesta)
Cualquier 2xx da la entrega por buena. Un 5xx o un fallo de red se reintenta, hasta 5 intentos.De Tu servidor a UTILIA OS:
GET /api/external/v1/chat/messages/:messageId
Con tu clave de API, para leer el texto.De UTILIA OS a Tu servidor:
El mensaje, ya filtrado (respuesta)
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.
Al configurar el webhook, declara los eventos con estos nombres en mayúsculas. Los nombres internos con puntos (chat.message.created y similares) no valen ahí.
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, 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:
- eventstring
- Nombre del evento, por ejemplo CHAT_MESSAGE_CREATED.
- timestampstring
- Momento de emisión (ISO 8601).
- appobject
- Tu aplicación: { id, slug }.
- dataobject
- Datos del evento. En los de mensaje, siempre messageId y channelId.
- metaobject
- Datos de la entrega: { schemaVersion }.
{
"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:
- X-Utilia-Signaturestring
- Firma HMAC-SHA256, con el prefijo sha256=.
- X-Utilia-Timestampstring
- Momento de emisión en milisegundos. Entra en la firma y en la protección contra reenvíos.
- X-Utilia-Eventstring
- Nombre del evento.
- X-Utilia-Delivery-Idstring
- Identificador de la entrega. Es el mismo en todos los reintentos: úsalo para no procesar dos veces un aviso.
- X-Utilia-Schema-Versionstring
- 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. En resumen:
- Es
sha256=seguido del HMAC-SHA256 de la cadena${timestamp}.${cuerpo}, con el secreto de tu aplicación como clave. Eltimestampes el de la cabeceraX-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
timestampse 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):
- 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.
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);Siguiente paso
El ejemplo del termómetro completa este receptor: publica una lectura, recibe la respuesta del equipo y contesta en el hilo.