Skip to content

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/v1

Esta 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:

MecanismoDirecciónCómo se configura
Webhook saliente (de cuenta)Zelta Chat hacia tu servidor: se envían los eventos de toda la cuentaVí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 APIEs 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:

EventoSe dispara cuando...
conversation_createdSe crea una nueva conversación
conversation_updatedCambian atributos de una conversación (incluye changed_attributes)
conversation_status_changedCambia el estado de una conversación: abierta, pendiente, pospuesta o resuelta (incluye changed_attributes)
message_createdSe crea un mensaje entrante, saliente o de plantilla
message_updatedSe actualiza un mensaje entrante, saliente o de plantilla
contact_createdSe crea un nuevo contacto
contact_updatedSe actualizan datos de un contacto (solo si hubo cambios reales)
inbox_createdSe crea una nueva bandeja de entrada (canal)
inbox_updatedSe actualiza la configuración de una bandeja de entrada (solo si hubo cambios reales)
webwidget_triggeredUn visitante abre el widget web (incluye event_info)
conversation_typing_onUn participante comienza a escribir en una conversación
conversation_typing_offUn 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 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

json
{
  "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

json
{
  "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

json
{
  "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:

EncabezadoDescripción
X-Zelta-DeliveryIdentificador único (UUID) de esta entrega. Siempre presente.
X-Zelta-TimestampMarca de tiempo Unix (en segundos) usada en la firma. Presente cuando el webhook tiene secreto.
X-Zelta-Signaturesha256= 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í:

  1. Toma el valor de X-Zelta-Timestamp.
  2. Construye la cadena a firmar concatenando timestamp + . + el cuerpo crudo (raw body) exacto de la solicitud, sin volver a serializar el JSON.
  3. Calcula HMAC-SHA256(secreto, "<timestamp>.<cuerpo>") en hexadecimal.
  4. Antepón sha256= y compáralo (en tiempo constante) con X-Zelta-Signature.

Verificación en Node.js (Express)

js
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

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 2xx lo antes posible y procesar la lógica de forma asíncrona (encola y devuelve 200 de inmediato).
  • Ser idempotente usando X-Zelta-Delivery para 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

http
GET /accounts/{account_id}/webhooks

Ejemplo:

bash
curl -X GET "https://chat.zelta.dev/api/v1/accounts/1/webhooks" \
  -H "api_access_token: tu_token_aqui"

Respuesta:

json
{
  "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

http
POST /accounts/{account_id}/webhooks

Parámetros (dentro del objeto webhook):

ParámetroTipoObligatorioDescripción
urlstringURL http/https válida y única por cuenta.
subscriptionsarray de stringLista no vacía de eventos a escuchar (de la tabla de eventos).
namestringNoEtiqueta descriptiva del webhook.

Ejemplo:

bash
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:

json
{
  "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

http
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:

bash
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

http
DELETE /accounts/{account_id}/webhooks/{id}

Ejemplo:

bash
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 como cotizado, 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:

json
{
  "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.

Documentación oficial de Zelta