Webhooks
Los webhooks permiten que Zelta Chat notifique a tus sistemas en tiempo real cada vez que ocurre un evento relevante (una nueva conversación, un mensaje, un contacto creado, etc.). En lugar de consultar la API periódicamente, configuras una URL y Zelta Chat envía una solicitud HTTP POST con los datos del evento en cuanto sucede.
URL base:
https://chat.zelta.dev/api/v1Esta página es la referencia técnica de los webhooks. Para la guía de uso desde la interfaz (Ajustes > Integraciones > Webhooks) consulta la sección de .
Salientes vs. el canal API
Conviene distinguir dos mecanismos:
| Mecanismo | Dirección | Cómo se configura |
|---|---|---|
| Webhook saliente (de cuenta) | Zelta Chat hacia tu servidor: se envían los eventos de toda la cuenta | Vía la API de webhooks documentada más abajo, o desde Ajustes > Integraciones > Webhooks |
| Webhook del canal API (entrante) | Zelta Chat hacia tu bot por cada mensaje de una bandeja de canal API | Es un campo (webhook_url) de una bandeja de tipo API, no se gestiona desde este endpoint |
Una aclaración importante
El webhook_url de un canal API también es una llamada saliente que Zelta Chat hace hacia ti cuando ocurre algo en esa bandeja específica. No es la URL a la que envías mensajes para crearlos. Para introducir mensajes en Zelta Chat desde un sistema externo, usa los de la API contra la bandeja de canal API; no necesitas registrar un webhook para eso.
Eventos disponibles
Puedes suscribir un webhook a cualquiera de estos 12 eventos:
| Evento | Se dispara cuando... |
|---|---|
conversation_created | Se crea una nueva conversación |
conversation_updated | Cambian atributos de una conversación (incluye changed_attributes) |
conversation_status_changed | Cambia el estado de una conversación: abierta, pendiente, pospuesta o resuelta (incluye changed_attributes) |
message_created | Se crea un mensaje entrante, saliente o de plantilla |
message_updated | Se actualiza un mensaje entrante, saliente o de plantilla |
contact_created | Se crea un nuevo contacto |
contact_updated | Se actualizan datos de un contacto (solo si hubo cambios reales) |
inbox_created | Se crea una nueva bandeja de entrada (canal) |
inbox_updated | Se actualiza la configuración de una bandeja de entrada (solo si hubo cambios reales) |
webwidget_triggered | Un visitante abre el widget web (incluye event_info) |
conversation_typing_on | Un participante comienza a escribir en una conversación |
conversation_typing_off | Un participante deja de escribir en una conversación |
Nota sobre el alcance
Los eventos de contacto y de bandeja (contact_*, inbox_*) solo se entregan a los webhooks de cuenta. Los eventos de conversación, mensaje, escritura y widget se entregan tanto a los webhooks de cuenta como al webhook del canal API correspondiente.
Formato del payload
Cada solicitud envía un payload en formato JSON. Todos los payloads incluyen la clave event con el nombre del evento. Los eventos de actualización (conversation_updated, conversation_status_changed, contact_updated, inbox_updated) también incluyen changed_attributes.
Atributos personalizados y adicionales
Los custom_attributes (atributos personalizados de la conversación y del contacto) y los additional_attributes sí viajan dentro del payload. Esto es clave para las integraciones: por ejemplo, un atributo monto_cotizado definido en la conversación llega directamente a tu sistema.
message_created
{
"event": "message_created",
"id": 102,
"content": "Hola, me interesa el plan Enterprise",
"content_type": "text",
"content_attributes": {},
"message_type": "incoming",
"created_at": "2025-01-15T10:30:00.000Z",
"private": false,
"source_id": null,
"additional_attributes": {},
"account": { "id": 1, "name": "Mi Empresa" },
"inbox": { "id": 3, "name": "WhatsApp Principal" },
"conversation": {
"id": 42,
"inbox_id": 3,
"status": "open",
"labels": ["ventas"],
"custom_attributes": { "monto_cotizado": 1500 },
"additional_attributes": {},
"meta": {
"sender": { "id": 7, "name": "María García", "type": "contact" },
"assignee": { "id": 5, "name": "Carlos López" }
}
},
"sender": {
"id": 7,
"name": "María García",
"email": "maria@empresa.com",
"type": "contact"
}
}La conversación va anidada
En message_created y message_updated, la clave conversation contiene el payload completo de la conversación (el mismo que se describe en conversation_status_changed), incluidos sus custom_attributes y labels.
conversation_status_changed
{
"event": "conversation_status_changed",
"id": 42,
"inbox_id": 3,
"status": "resolved",
"priority": "medium",
"vip": true,
"can_reply": true,
"labels": ["ventas", "cotizado"],
"custom_attributes": { "estado_cotizacion": "cotizado", "monto_cotizado": 1500 },
"additional_attributes": {},
"snoozed_until": null,
"unread_count": 0,
"meta": {
"sender": { "id": 7, "name": "María García", "type": "contact" },
"assignee": { "id": 5, "name": "Carlos López" },
"team": { "id": 2, "name": "Equipo de Ventas" }
},
"messages": [],
"created_at": 1736937000,
"updated_at": 1736939100.0,
"changed_attributes": [
{ "status": { "previous_value": "open", "current_value": "resolved" } }
]
}Formato de las marcas de tiempo
En el payload de conversación, created_at y updated_at se envían como marcas de tiempo Unix (segundos): created_at como entero y updated_at como decimal. En el payload de mensaje (message_created), en cambio, created_at se envía como cadena ISO‑8601.
contact_created
{
"event": "contact_created",
"id": 7,
"name": "María García",
"email": "maria@empresa.com",
"phone_number": "+50761234567",
"identifier": null,
"avatar": "",
"thumbnail": "",
"blocked": false,
"additional_attributes": {},
"custom_attributes": { "empresa": "Acme Corp" },
"account": { "id": 1, "name": "Mi Empresa" }
}Encabezados HTTP y firma HMAC
Cada solicitud saliente se envía como POST con Content-Type: application/json e incluye los siguientes encabezados:
| Encabezado | Descripción |
|---|---|
X-Zelta-Delivery | Identificador único (UUID) de esta entrega. Siempre presente. |
X-Zelta-Timestamp | Marca de tiempo Unix (en segundos) usada en la firma. Presente cuando el webhook tiene secreto. |
X-Zelta-Signature | sha256= seguido del HMAC-SHA256 calculado sobre la cadena <timestamp>.<cuerpo>. Presente cuando el webhook tiene secreto. |
Todo webhook creado por la API o desde la interfaz genera automáticamente un secreto, por lo que en la práctica X-Zelta-Timestamp y X-Zelta-Signature siempre acompañan a la entrega.
La firma se construye así:
- Toma el valor de
X-Zelta-Timestamp. - Construye la cadena a firmar concatenando
timestamp+.+ el cuerpo crudo (raw body) exacto de la solicitud, sin volver a serializar el JSON. - Calcula
HMAC-SHA256(secreto, "<timestamp>.<cuerpo>")en hexadecimal. - Antepón
sha256=y compáralo (en tiempo constante) conX-Zelta-Signature.
Verificación en Node.js (Express)
const crypto = require('crypto');
// IMPORTANTE: captura el rawBody, no el objeto JSON ya parseado.
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
function verifyZeltaWebhook(req, secret) {
const signatureHeader = req.get('X-Zelta-Signature'); // "sha256=<hex>"
const timestamp = req.get('X-Zelta-Timestamp'); // segundos Unix (string)
if (!signatureHeader || !timestamp) return false;
const rawBody = req.rawBody.toString('utf8'); // cuerpo crudo exacto
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`) // "<ts>.<cuerpo>"
.digest('hex');
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Verificación en Ruby
require 'openssl'
require 'rack/utils'
# raw_body = request.body.read # cuerpo crudo exacto, sin volver a serializar
def verify_zelta_webhook(signature_header, timestamp, raw_body, secret)
return false if signature_header.nil? || timestamp.nil?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")}"
Rack::Utils.secure_compare(signature_header, expected)
end
# En Rails:
# signature = request.headers['X-Zelta-Signature']
# timestamp = request.headers['X-Zelta-Timestamp']Tiempo de espera y reintentos
El tiempo de espera por solicitud (timeout) es de 5 segundos por defecto. Si tu servidor no responde dentro de ese plazo, la entrega se considera fallida.
Los webhooks de cuenta y de bandeja no se reintentan
Si tu endpoint falla, responde con un error o no responde a tiempo, ese evento no se vuelve a enviar: el fallo se registra y se descarta. No existe un calendario de reintentos para los webhooks de cuenta ni de bandeja de entrada.
Por eso, diseña tu receptor para:
- Responder con
2xxlo antes posible y procesar la lógica de forma asíncrona (encola y devuelve200de inmediato). - Ser idempotente usando
X-Zelta-Deliverypara descartar entregas duplicadas. - Tolerar entregas perdidas mediante reconciliación periódica contra la API cuando el dato sea crítico.
Gestionar webhooks vía API
Los webhooks de cuenta se administran con el siguiente CRUD bajo el ámbito de tu cuenta. Todas las peticiones usan el encabezado api_access_token (consulta la ).
Permiso de administrador
Estos endpoints requieren un token de un usuario con rol de administrador. Un token sin permisos suficientes recibe 403 Forbidden.
Listar webhooks
GET /accounts/{account_id}/webhooksEjemplo:
curl -X GET "https://chat.zelta.dev/api/v1/accounts/1/webhooks" \
-H "api_access_token: tu_token_aqui"Respuesta:
{
"payload": {
"webhooks": [
{
"id": 1,
"name": "Integración CRM",
"url": "https://ejemplo.com/hooks/zelta",
"account_id": 1,
"subscriptions": ["conversation_updated", "message_created"],
"secret": "Hx9...generado..."
}
]
}
}Crear webhook
POST /accounts/{account_id}/webhooksParámetros (dentro del objeto webhook):
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
url | string | Sí | URL http/https válida y única por cuenta. |
subscriptions | array de string | Sí | Lista no vacía de eventos a escuchar (de la tabla de eventos). |
name | string | No | Etiqueta descriptiva del webhook. |
Ejemplo:
curl -X POST "https://chat.zelta.dev/api/v1/accounts/1/webhooks" \
-H "api_access_token: tu_token_aqui" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"name": "Integración CRM",
"url": "https://ejemplo.com/hooks/zelta",
"subscriptions": ["conversation_updated", "message_created"]
}
}'Respuesta:
{
"payload": {
"webhook": {
"id": 1,
"name": "Integración CRM",
"url": "https://ejemplo.com/hooks/zelta",
"account_id": 1,
"subscriptions": ["conversation_updated", "message_created"],
"secret": "Hx9...generado..."
}
}
}El campo secret se genera automáticamente al crear el webhook y es el que se usa para firmar las entregas. Guárdalo de forma segura.
Actualizar webhook
PATCH /accounts/{account_id}/webhooks/{id}El array subscriptions reemplaza por completo a la lista anterior, así que debes enviar la lista completa de eventos que deseas mantener.
Ejemplo:
curl -X PATCH "https://chat.zelta.dev/api/v1/accounts/1/webhooks/1" \
-H "api_access_token: tu_token_aqui" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"url": "https://ejemplo.com/hooks/zelta-v2",
"subscriptions": ["conversation_status_changed", "message_created", "message_updated"]
}
}'Eliminar webhook
DELETE /accounts/{account_id}/webhooks/{id}Ejemplo:
curl -X DELETE "https://chat.zelta.dev/api/v1/accounts/1/webhooks/1" \
-H "api_access_token: tu_token_aqui"Devuelve HTTP 200 con un cuerpo vacío.
Alcance del webhook
Un webhook creado por esta API es siempre un webhook de cuenta: recibe los eventos de toda la cuenta a los que esté suscrito. No existe un alcance por bandeja a través de este endpoint; para reaccionar a una sola bandeja, filtra por inbox_id en el payload o usa el webhook_url del canal API.
Caso de uso: trazabilidad de leads hacia un CRM
Un escenario habitual (por ejemplo, una clínica que cotiza tratamientos) es marcar cuándo un contacto "cotizó", por cuánto, y empujar ese dato a un CRM propio. El diseño recomendado combina atributos personalizados, una etiqueta y una regla de automatización.
1. Define atributos personalizados en Ajustes > Atributos personalizados, a nivel de conversación:
monto_cotizado(tipo moneda o número).estado_cotizacion(tipo lista, con valores comocotizado,atendido,escalado).
2. Marca la conversación. El agente aplica una etiqueta (por ejemplo cotizado) y rellena monto_cotizado y estado_cotizacion. Estos atributos pueden establecerse desde la interfaz o vía la .
3. Crea un webhook de cuenta suscrito a conversation_updated (y/o conversation_status_changed), apuntando a la URL de tu CRM. Como el webhook tiene secreto, tu CRM puede verificar la firma X-Zelta-Signature con el esquema descrito arriba.
4. Recibe el payload. Cuando cambie la etiqueta o el estado, tu CRM recibirá la conversación completa, incluidos los custom_attributes:
{
"event": "conversation_updated",
"id": 42,
"status": "open",
"labels": ["ventas", "cotizado"],
"custom_attributes": {
"estado_cotizacion": "cotizado",
"monto_cotizado": 1500
},
"meta": {
"sender": { "id": 7, "name": "María García", "phone_number": "+50761234567" }
}
}El payload tiene forma fija
El webhook envía toda la información de la entidad; no es posible elegir, desde Zelta Chat, qué campos individuales se incluyen. Tu CRM debe filtrar del payload únicamente lo que necesite (por ejemplo custom_attributes.monto_cotizado, labels, status y el teléfono del contacto).
Entrega condicional con automatización
Si solo quieres notificar al CRM cuando la conversación llega a un estado concreto (por ejemplo etiqueta cotizado), puedes usar una regla de automatización con la acción de enviar webhook, en lugar de un webhook de cuenta que dispara en cada cambio. Ten en cuenta que el webhook enviado por una regla de automatización no incluye la firma HMAC; en ese caso protege la URL de destino (URL secreta o lista de IP permitidas).
Enlaces relacionados
- — guía de uso desde la interfaz.
- — referencia del resto de la API.
- — cómo obtener tu token de acceso.