Desarrolladores
Webhooks Beta
Recibe notificaciones duraderas y firmadas cuando cambian contactos, conversaciones, mensajes, etiquetas o los miembros del equipo.
Abrir la referencia de la APICrea un endpoint
- 01
Abre Ajustes, selecciona Desarrolladores, busca Webhooks y selecciona Añadir endpoint.
- 02
Escribe un Nombre y una URL del endpoint pública con HTTPS, y elige al menos un elemento en Eventos.
- 03
Selecciona Añadir endpoint. También puedes crear uno con
POST /api/v1/webhooks.
Sonny rechaza las URL con credenciales, localhost, los rangos de IP privados o de enlace local y los nombres DNS que se resuelven a una dirección no pública.
Guarda el secreto de firma de inmediato
Empieza por whsec_ y solo se muestra una vez. Cópialo en tu gestor de secretos antes de seleccionar Ya lo he guardado.
Limita un endpoint a canales concretos
En Ajustes → Desarrolladores, cada endpoint puede escuchar todos los canales o solo los que elijas, tanto al añadirlo como al editarlo más tarde. Los endpoints creados antes de que existiera esta opción siguen en todos los canales hasta que los cambies.
Los clientes de la API y de MCP definen el mismo filtro con sourceIds al crear o actualizar un endpoint. Un array vacío significa todos los orígenes del espacio de trabajo. Si hay IDs, los eventos de conversaciones y mensajes solo se entregan cuando su conversación pertenece a uno de los orígenes seleccionados. Los eventos de nivel de espacio de trabajo, como los cambios de contactos o de miembros, no se envían a un endpoint filtrado por origen.
{
"name": "Product A agent",
"url": "https://agent.example.com/sonny",
"events": ["conversation.created", "message.created"],
"sourceIds": ["cm_source_id"]
}Deja de recibir lo que tu integración descarta
Un agente que responde a través de la API se activa con su propia respuesta, salvo que indiques lo contrario. Tres filtros opcionales acotan lo que recibe un endpoint; todos están desactivados por defecto, así que un endpoint existente no cambia.
agentGroupIds- Solo las conversaciones de los grupos de agentes indicados. Esto sigue la titularidad, no el canal por el que llegó la conversación, así que una conversación de una bandeja compartida llega al grupo al que pertenece. Las conversaciones sin grupo no se entregan y, como los grupos suelen asignarse después de que empiece una conversación,
conversation.createda menudo se emite antes de que haya un grupo con el que coincidir. customerMessagesOnly- Solo los mensajes escritos por clientes. Omite las respuestas de tu equipo, las notas internas y todo lo enviado mediante la API, MCP o el respondedor de IA, incluidas las respuestas de este mismo endpoint.
excludedSenderIds- Omite los mensajes enviados por los compañeros indicados. Dale a una integración su propia cuenta de compañero y exclúyela para que no se active con sus propias respuestas, pero sí se entere cuando una persona se hace cargo de la conversación: la señal que necesita un agente de IA para retirarse.
Los filtros de mensajes solo se aplican a los eventos message.*; para todo lo demás, el control son los tipos de evento. Un evento filtrado se descarta antes de convertirse en una entrega, así que no te cuesta nada y nunca aparece como fallo. La carga y la apiVersion no cambian en ningún caso.
Agrupa una ráfaga de mensajes en una sola entrega
Un consumidor que lee el hilo completo al activarse no gana nada con cuatro entregas separadas en diez segundos. Define coalesceSeconds y los mensajes de una conversación se reúnen durante ese tiempo y se envían en una sola entrega. Está desactivado por defecto; los endpoints sin esta opción siguen recibiendo cada mensaje por separado.
Una entrega agrupada llega como conversation.activity con la conversación y todos los IDs de mensaje reunidos, así que lee el hilo una vez en lugar de mensaje a mensaje. Es el único ajuste que cambia la forma de lo que recibes, por eso hay que activarlo expresamente. Tus otros filtros se siguen aplicando: un mensaje que excluyen nunca entra en un grupo. Cada conversación tiene su propia ventana, y los reintentos tratan el grupo como una sola entrega.
{
"type": "conversation.activity",
"data": {
"conversationId": "cm_conversation_id",
"messageIds": ["cm_first_message", "cm_second_message"]
}
}Endpoints protegidos con autenticación Bearer o por clave de API
Si tu receptor está detrás de una pasarela que exige una cabecera, añádela en Cabeceras personalizadas al crear o editar el endpoint, o envía headers desde la API. Los valores se guardan cifrados y nunca se devuelven: una cabecera guardada vuelve solo con su nombre, y reenviar ese nombre sin valor conserva el valor guardado. Envía un array vacío para quitar todas las cabeceras.
Cambiar un endpoint a otro host anula esa reutilización: hay que volver a introducir los valores guardados, para que una credencial nunca se reenvíe a un destino para el que no se emitió. Cambiar solo la ruta los conserva.
Las cabeceras propias de Sonny prevalecen sobre las tuyas, así que una cabecera personalizada nunca puede sustituir sonny-signature, content-type ni las cabeceras de identidad de la entrega. Siempre que puedas, verifica la firma: autentica cada carga, mientras que un token estático solo identifica a quien llama.
{
"name": "Gateway",
"url": "https://api.example.com/hooks/sonny",
"events": ["conversation.created"],
"headers": [{ "name": "Authorization", "value": "Bearer …" }]
}Seguridad y fiabilidad
- Cuerpo sin procesar firmado
- HMAC-SHA256 cubre la marca de tiempo Unix, un punto y el cuerpo de la petición en UTF-8 sin modificar.
- Protección contra repeticiones
- Rechaza las marcas de tiempo con más de cinco minutos de antigüedad o de adelanto, aunque el HMAC sea válido.
- Reintentos duraderos
- Las respuestas que no son 2xx se reintentan tras 1m, 5m, 30m, 2h, 6h. El intento 6 es el último.
Contrato de la petición
sonny-signature- t=<unix-seconds>,v1=<sha256-hex>
sonny-event- El tipo de evento, para enrutar rápido.
sonny-delivery-id- Un ID estable para idempotencia y soporte.
user-agent- Sonny-Webhooks/1.0
{
"id": "cm_event_id",
"type": "message.created",
"apiVersion": "2026-07-15",
"createdAt": "2026-07-15T12:00:00.000Z",
"data": {
"conversationId": "cm_conversation_id",
"messageId": "cm_message_id"
}
}Verifica la firma
Lee primero el cuerpo sin procesar. Analizar el JSON y volver a serializarlo cambia los espacios y hace que una firma válida falle.
import { createHmac, timingSafeEqual } from "node:crypto";
const rawBody = await request.text(); // do not parse/re-serialize first
const signature = request.headers.get("sonny-signature");
const secret = process.env.SONNY_WEBHOOK_SECRET;
if (!signature || !secret) throw new Error("Missing webhook signature or secret");
const { t, v1 } = Object.fromEntries(signature.split(",").map(p => p.split("=")));
if (!/^\d+$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1)) {
throw new Error("Malformed signature");
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) {
throw new Error("Stale webhook");
}
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
throw new Error("Invalid signature");
}Catálogo de eventos
webhook.heartbeatSe envía cada cinco minutos a los suscriptores activos (incluido *), aunque no haya mensajes nuevos. Usa la ruta normal de entrega firmada y reintentos, e ignora los filtros de conversación. Configura alertas si faltan latidos o están desactualizados; no busca conversaciones pendientes de respuesta.
{"intervalSeconds":300,"nextExpectedAt":"2026-09-29T16:05:00.000Z"}contact.createdSe ha creado un contacto.
{"contactId":"cm_contact_id"}contact.updatedSe ha actualizado o fusionado un contacto.
{"contactId":"cm_contact_id"}contact.deletedSe ha archivado un contacto o se ha fusionado con otro. Todavía se puede leer con archived=true.
{"contactId":"cm_contact_id"}contact.erasedSe ha borrado definitivamente un contacto (por ejemplo, por una solicitud de supresión del RGPD) con todas sus conversaciones, mensajes y adjuntos. Ya no se puede leer; elimina las copias que conserves.
{"contactId":"cm_contact_id"}conversation.createdSe ha creado una conversación.
{"conversationId":"cm_conversation_id"}conversation.updatedHa cambiado una conversación.
{"conversationId":"cm_conversation_id"}conversation.closedSe ha cerrado una conversación.
{"conversationId":"cm_conversation_id"}conversation.deletedUna conversación se ha movido a la papelera y ha salido de la API pública.
{"conversationId":"cm_conversation_id"}message.createdSe ha creado un mensaje o una nota interna. Se incluyen el contexto y una breve vista previa del mensaje cuando están disponibles; el texto de las notas internas nunca se incluye.
{"conversationId":"cm_conversation_id","messageId":"cm_message_id","context":{"sourceId":"cm_source_id","sourceName":"Shopstar Go","storeName":"Shiney Store","inboxId":"cm_inbox_id","inboxName":"Go support","agentGroupId":"cm_group_id","agentGroupName":"Support","identityVerified":true,"status":"open","lastRepliedAt":"2026-09-25T09:00:00.000Z"},"message":{"id":"cm_message_id","type":"incoming","senderType":"human","senderId":null,"senderName":"Shiney","textPreview":"Could you check my bill?","textTruncated":false}}message.updatedSe ha editado una respuesta enviada en la transcripción. Los correos ya entregados no cambian.
{"conversationId":"cm_conversation_id","messageId":"cm_message_id"}tag.createdSe ha creado una etiqueta.
{"tagId":"cm_tag_id"}tag.updatedSe ha actualizado una etiqueta.
{"tagId":"cm_tag_id"}tag.deletedSe ha eliminado una etiqueta.
{"tagId":"cm_tag_id"}member.invitedSe ha invitado a un miembro del espacio de trabajo.
{"invitationId":"cm_invitation_id"}member.updatedHa cambiado el rol o el estado de un miembro.
{"memberId":"cm_membership_id"}member.removedSe ha eliminado a un miembro.
{"memberId":"cm_membership_id"}invitation.acceptedSe ha aceptado una invitación al espacio de trabajo.
{"invitationId":"cm_invitation_id","memberId":"cm_membership_id"}invitation.cancelledSe ha cancelado una invitación pendiente al espacio de trabajo.
{"invitationId":"cm_invitation_id"}conversation.activityMensajes de una conversación agrupados en una sola entrega. Incluye una instantánea de cada mensaje cuando está disponible. Se envía en lugar de message.created a los endpoints con ventana de agrupación; no admite suscripción directa.
{"conversationId":"cm_conversation_id","messageIds":["cm_message_id","cm_other_message_id"]}webhook.testUn evento de prueba solicitado por un administrador.
{"message":"This is a test webhook from Sonny."}
Comportamiento de las entregas
- Devuelve cualquier estado 2xx en menos de 10 segundos para marcar la entrega como correcta.
- Haz que tus manejadores sean idempotentes. Usa el
iddel evento osonny-delivery-idpara ignorar duplicados. - No se siguen las redirecciones. Actualiza la URL del endpoint en Sonny.
- Los cuerpos de respuesta tienen un límite y solo se conservan los primeros 4 KiB para el diagnóstico.
- Selecciona Probar para enviar un evento
webhook.test. Selecciona Entregas para revisar los 50 eventos más recientes. Cuando una entrega llega a un fallo definitivo, selecciona Reintentar para volver a enviarla.
Detecta un feed silencioso
Añade webhook.heartbeat a los eventos de tu endpoint en los ajustes de Desarrolladores o mediante update_webhook. Los suscriptores activos (incluido *) reciben un heartbeat firmado cada cinco minutos, aunque no lleguen mensajes nuevos. Los heartbeats ignoran los filtros de canal, equipo y mensajes, y no contienen datos de conversaciones. Usan la misma vía de entrega y los mismos reintentos que los mensajes. Comprueba createdAt y nextExpectedAt para que un reintento antiguo no parezca un heartbeat reciente; ten en cuenta los retrasos de sondeo y de red antes de lanzar una alerta. Una acumulación de entregas puede retrasar o suprimir los heartbeats.
Usa list_webhook_deliveries con webhooks:read para revisar intentos y fallos. get_status comprueba la conectividad de la base de datos, no la entrega de webhooks. Programa por separado list_conversations con status=open, awaitingReply=true, sort=waitingSince y direction=asc para encontrar hilos antiguos sin responder aunque el feed esté en silencio.
Activa Payloads compactos en los ajustes de Desarrolladores o pon compact=true en el endpoint para omitir las etiquetas de contexto, manteniendo los IDs de enrutamiento, el remitente, la hora y vistas previas de los mensajes de 200 caracteres. También se aplica a las entregas agrupadas; el texto de las notas internas sigue excluido. Las cargas predeterminadas no cambian, salvo por la marca de tiempo del mensaje añadida. Edita los permisos de tu clave de API actual para conceder webhooks:read y contacts:read, necesarios para los registros de entregas y las búsquedas directas de contactos. Ambos permisos requieren una clave con acceso a todos los canales y un miembro con acceso a todos los canales; una clave limitada no se puede ampliar solo añadiendo permisos.
Documentación relacionada
- API públicaBeta
Clasifica y sincroniza conversaciones, comparte el contexto del cliente, lee informes y envía archivos con claves de API de permisos acotados.
- Bandeja de entrada
Entiende los estados, las prioridades, la asignación, posponer, las acciones en lote y los atajos de teclado.
- Contactos
Descubre cómo se crean, gestionan, etiquetan y fusionan los contactos en Sonny.
- Equipo y roles
Invita a usuarios, crea equipos y entiende los accesos de Propietario, Administrador, Agente y Observador.