Webhook personalizado — envía tus eventos a cualquier sistema
El Webhook personalizado envía los eventos de tus leads a cualquier URL HTTPS que tú controles. Cada vez que pasa algo relevante —llega un lead nuevo, se le asigna una etiqueta, cambia de etapa, responde a una de tus publicaciones— ZeroChats hace un POST con un JSON a tu endpoint.
Es la pieza que te permite conectar ZeroChats con lo que ya usas sin esperar a una integración dedicada: n8n, Make, Zapier, un canal interno de Slack o tu propio backend. Si la herramienta sabe recibir un webhook, ya está integrada.
Si lo que quieres es volcar tus leads en un CRM, mira antes Integraciones: GoHighLevel, HubSpot y Airtable ya están resueltos y no requieren escribir código. El webhook es para todo lo demás.
Cómo se configura
Sección titulada «Cómo se configura»-
Entra en Configuración del negocio. En la tarjeta Integraciones, pulsa el botón Integraciones.
-
Busca la tarjeta Webhook personalizado y pulsa Configurar webhook.
-
Pega la URL del endpoint. Tiene que ser una URL pública HTTPS que responda con un estado 2xx.
-
Elige los eventos que quieres recibir. Si no marcas ninguno, el webhook queda guardado pero no envía nada.
-
Genera la clave de firma (opcional pero recomendado) pulsando Generar clave. Cópiala en ese momento: por seguridad no se vuelve a mostrar una vez guardas.
-
Deja activado el interruptor Enviar eventos. Viene activado por defecto; desactívalo cuando quieras pausar los envíos sin perder la configuración.
-
Guarda.
-
Vuelve a abrir el diálogo y pulsa Enviar evento de prueba. ZeroChats manda un
pingde ejemplo, con la misma forma que un evento real, y te muestra al momento el estado HTTP y el tiempo de respuesta de tu endpoint.
Eventos disponibles
Sección titulada «Eventos disponibles»| Evento | Cuándo se dispara | Qué añade al contenido |
|---|---|---|
lead.created | Un lead contacta con el negocio por primera vez. | — |
lead.tagged | Se asignan una o varias etiquetas a un lead. | addedTags |
lead.state_changed | Un lead pasa a una etapa nueva dentro del embudo. | state, stateTag |
lead.media_replied | Un lead responde a una publicación del negocio o la comenta. | media, source |
En lead.tagged, el campo addedTags contiene solo las etiquetas que ha añadido ese evento, no todas las que tiene el lead. La lista completa la tienes siempre en tags.
Qué recibes
Sección titulada «Qué recibes»Cada envío es un POST con el mismo sobre; lo único que cambia entre eventos es data.
{ "id": "9f1c8a4e-2b77-4d0a-9d1c-3f2b6a5c7e10", "event": "lead.created", "createdAt": "2026-08-11T09:12:33.114Z", "businessId": "b7a2f1d9-5c34-4a61-8e2f-0d9c1b4a7e33", "data": { "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "IN_PROGRESS", "tags": ["interesado"], "createdAt": "2026-08-11T09:12:30.000Z" } }}Campos del sobre
Sección titulada «Campos del sobre»| Campo | Qué es |
|---|---|
id | Identificador único de este envío. Mismo valor que X-ZeroChats-Delivery. |
event | Nombre del evento: lead.created, lead.tagged, lead.state_changed o lead.media_replied. |
createdAt | Momento en que se generó el evento, en formato ISO 8601 (UTC). |
businessId | Identificador de tu negocio en ZeroChats. |
data | El contenido del evento. Siempre incluye lead. |
Campos del lead
Sección titulada «Campos del lead»| Campo | Qué es |
|---|---|
id | Identificador del lead en ZeroChats. Estable: úsalo como clave para no duplicar. |
name | Nombre del lead. |
username | Nombre de usuario en la plataforma de origen. |
platform | Canal de origen: INSTAGRAM, WHATSAPP, WASENDERAPI o MESSENGER. |
externalId | Identificador del lead en su plataforma: el id de Instagram, o los dígitos del teléfono en los canales de WhatsApp. null si no se conoce. |
email | Email del lead, o null si todavía no lo ha dado. |
phone | Teléfono del lead, o null si todavía no lo ha dado. |
state | Etapa del embudo de leads, por su nombre: IN_PROGRESS, QUALIFYING, LEAD, BOOKED, CLIENT… |
tags | Lista completa de etiquetas del lead en ese momento. |
createdAt | Fecha de creación del lead, en formato ISO 8601 (UTC). |
El evento lead.tagged
Sección titulada «El evento lead.tagged»Mismo sobre, con event a lead.tagged y un campo más dentro de data:
{ "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "LEAD", "tags": ["interesado", "precio"], "createdAt": "2026-08-11T09:12:30.000Z" }, "addedTags": ["precio"]}El lead ya llega con las etiquetas nuevas incorporadas en tags; addedTags te dice cuáles son las que acaban de entrar.
El evento lead.state_changed
Sección titulada «El evento lead.state_changed»Se dispara cada vez que un lead pasa a una etapa nueva del embudo de leads. Mismo sobre, con event a lead.state_changed y dos campos más dentro de data:
{ "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "BOOKED", "tags": ["interesado", "precio"], "createdAt": "2026-08-11T09:12:30.000Z" }, "state": "Booked", "stateTag": "Status: Booked"}| Campo | Qué es |
|---|---|
state | El nombre de la etapa a la que acaba de pasar: Discovering, Qualifying, Proposal, Booked, Paid… |
stateTag | Ese mismo nombre con el prefijo Status: , para los destinos que solo saben guardar etiquetas y no tienen etapas propias. |
stateTag está pensado para las herramientas que no tienen un concepto propio de etapa de lead —GoHighLevel, HubSpot o Airtable, por ejemplo—, que reciben el cambio convertido en esa etiqueta. Quien abre la ficha del contacto en el CRM lee Status: Booked, no un identificador interno. Si tu destino funciona igual, aplícala tal cual y quedará alineado con el resto de integraciones.
Esta es la correspondencia completa:
| Etapa en la app | state | stateTag |
|---|---|---|
| Descubriendo | Discovering | Status: Discovering |
| Cualificando | Qualifying | Status: Qualifying |
| Propuesta | Proposal | Status: Proposal |
| Agendado | Booked | Status: Booked |
| Pagado | Paid | Status: Paid |
| No cualificado | Unqualified | Status: Unqualified |
| Perdido | Lost | Status: Lost |
| Abierta | Open | Status: Open |
| Pospuesto | Postponed | Status: Postponed |
Los dos campos van siempre en inglés, trabajes en el idioma que trabajes dentro de ZeroChats: un negocio que ve Pagado en su embudo recibe igualmente state a Paid y stateTag a Status: Paid. Es deliberado. La etiqueta acaba en un CRM compartido, donde los filtros y las automatizaciones se construyen sobre su texto, así que una etapa tiene que producir exactamente el mismo valor en todas las cuentas. Si dependiera del idioma, la misma etapa se partiría en tres textos distintos y esos filtros dejarían de encajar.
Un detalle que verás al mirar el JSON: el state de primer nivel es el nombre legible (Booked), pero el state de dentro de lead sigue trayendo el identificador interno (BOOKED), porque ese objeto es el mismo que viaja en lead.created y en lead.tagged. Los alinearemos más adelante; hasta entonces, quédate con el de primer nivel.
El evento lead.media_replied
Sección titulada «El evento lead.media_replied»Se dispara cuando un lead responde a una publicación del negocio —una historia, un reel, un post del feed o un anuncio—, ya sea contestándola por mensaje directo o comentándola. Mismo sobre, con event a lead.media_replied y dos campos más dentro de data:
{ "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "IN_PROGRESS", "tags": ["interesado"], "createdAt": "2026-08-11T09:12:30.000Z" }, "media": { "id": "17900000000000123", "type": "STORY", "permalink": "https://www.instagram.com/p/abc/", "previewUrl": "https://.../preview.jpg" }, "source": "direct_message"}| Campo | Qué es |
|---|---|
media.id | Identificador de la publicación en Instagram. Viaja siempre, y como texto. |
media.type | Tipo de publicación: STORY, REELS, FEED o AD. |
media.permalink | Enlace a la publicación, o null si Instagram ya no la expone. |
media.previewUrl | Imagen de vista previa, o null si no hemos podido obtenerla. |
source | Por dónde ha llegado la respuesta: direct_message o comment. |
La dirección es siempre la misma: el lead reaccionando a una publicación tuya, nunca al revés. Si es el negocio quien responde a la historia de un lead, no se genera ningún evento. Y se emite una vez por interacción: si el mismo lead responde a tres publicaciones distintas, recibes tres eventos.
Por mensaje directo te llegan todas las respuestas a una publicación. Por comentario solo llegan los comentarios que el negocio procesa: los que coinciden con una palabra clave o, si tienes activado Responder al comentario y enviar por privado el contenido aunque no coincida la palabra clave, todos. Un comentario que el negocio ha decidido ignorar no genera evento — mismo criterio con el que se crea o no el lead, explicado en Reels y Posts.
Cabeceras
Sección titulada «Cabeceras»| Cabecera | Valor |
|---|---|
Content-Type | application/json |
X-ZeroChats-Event | Nombre del evento, por ejemplo lead.created. |
X-ZeroChats-Delivery | Identificador único de este envío. |
X-ZeroChats-Timestamp | Momento de la firma, en segundos Unix. |
X-ZeroChats-Signature | sha256=<hex>. Solo aparece si has configurado clave de firma. |
Verificar la firma
Sección titulada «Verificar la firma»Tu endpoint es una URL pública: cualquiera que la adivine puede mandarte un JSON inventado. La clave de firma es lo que te permite distinguir un envío nuestro de uno falso.
Firmamos con HMAC-SHA256 sobre el texto `${timestamp}.${cuerpo}` — el valor de X-ZeroChats-Timestamp, un punto, y el cuerpo de la petición tal cual llegó. No firmamos el cuerpo solo: incluir la marca de tiempo es lo que te permite rechazar una petición antigua que alguien haya capturado y esté reenviando.
Tu receptor tiene que hacer tres cosas:
- Rechazar peticiones viejas, fuera de una tolerancia razonable (5 minutos va bien).
- Calcular el HMAC sobre el cuerpo en crudo, antes de cualquier
JSON.parse. - Comparar en tiempo constante, no con
===.
const crypto = require('crypto');
const TOLERANCIA_SEGUNDOS = 300; // 5 minutos
/** * Comprueba que una petición viene realmente de ZeroChats. * * @param {string} rawBody Cuerpo de la petición TAL CUAL, sin parsear. * @param {object} headers Cabeceras de la petición, en minúsculas. * @param {string} secret La clave de firma que generaste en el diálogo. * @returns {boolean} true si la firma es válida y la petición es reciente. */function verificarWebhookZeroChats(rawBody, headers, secret) { const timestamp = Number(headers['x-zerochats-timestamp']); const firma = String(headers['x-zerochats-signature'] || '');
// 1. Fuera de tolerancia: descartada. if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCIA_SEGUNDOS) { return false; }
// 2. HMAC sobre "timestamp.cuerpo", con el cuerpo en crudo. const esperada = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
// 3. Comparación en tiempo constante. const recibida = Buffer.from(firma); const calculada = Buffer.from(esperada);
return recibida.length === calculada.length && crypto.timingSafeEqual(recibida, calculada);}Cómo obtener el cuerpo en crudo
Sección titulada «Cómo obtener el cuerpo en crudo»Express parsea el JSON antes de que llegue a tu ruta, así que hay que capturar el buffer con el hook verify:
app.use( express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); }, }));
app.post('/webhooks/zerochats', (req, res) => { const valido = verificarWebhookZeroChats( req.rawBody, req.headers, process.env.ZEROCHATS_WEBHOOK_SECRET );
if (!valido) return res.status(401).end();
res.status(200).end(); // responde ya procesarEvento(req.body); // y trabaja después});En un route handler, lee el cuerpo como texto con await request.text() y parsea tú después:
export async function POST(request: Request) { const rawBody = await request.text(); const headers = Object.fromEntries(request.headers);
const valido = verificarWebhookZeroChats( rawBody, headers, process.env.ZEROCHATS_WEBHOOK_SECRET! );
if (!valido) { return new Response('Firma inválida', { status: 401 }); }
const evento = JSON.parse(rawBody); procesarEvento(evento); // sin await: responde primero
return new Response(null, { status: 200 });}Entrega, reintentos y fallos
Sección titulada «Entrega, reintentos y fallos»- Éxito es cualquier 2xx. Cualquier otra respuesta cuenta como fallo.
- Responde rápido y trabaja después. Tienes 10 segundos por intento. Un 200 lento cuesta lo mismo que un error una vez se agota ese tiempo: encola el trabajo y contesta.
- Hasta 3 intentos cuando la respuesta es un
5xx, un408, un429o hay un error de red. La espera entre intentos es de 1 s y luego 5 s; si nos respondes un429, sube a 5 s y luego 15 s, porque un límite por minuto no se libera en un segundo. - Respetamos tu cabecera
Retry-After. Si contestas un429(o un503) incluyéndola, esperamos exactamente lo que pidas en lugar de usar nuestra espera, con un tope de 30 s. Es la forma más fiable de que un envío limitado acabe entrando en vez de perderse. - Un
4xxno se reintenta. Lo interpretamos como que tu endpoint ha rechazado el envío a propósito. - La entrega es «al menos una vez». Si tu servidor procesa el evento pero tarda demasiado en contestar, verás el reintento. Si duplicar te supone un problema, guarda el valor de
X-ZeroChats-Deliveryy descarta los repetidos.
Un webhook que falla nunca afecta a la conversación: el lead se crea, se etiqueta y avanza de etapa igual aunque tu endpoint esté caído.
Buenas prácticas
Sección titulada «Buenas prácticas»- Configura siempre la clave de firma. Sin ella, cualquiera que descubra tu URL puede inyectarte leads falsos.
- Responde 200 antes de procesar. Guarda el evento en una cola y devuelve el control de inmediato.
- Deduplica por
X-ZeroChats-Deliverysi tu flujo hace algo que no conviene repetir (cobrar, enviar un email, crear una fila). - Usa
lead.idcomo clave, no el email ni el usuario: el email puede llegar más tarde y el usuario puede cambiar. - Pausa en vez de borrar. Si tienes que parar los envíos durante un despliegue, desactiva Enviar eventos y vuelve a activarlo; así no pierdes la URL, los eventos ni la clave.
Problemas comunes
Sección titulada «Problemas comunes»No me llega nada
Sección titulada «No me llega nada»Revisa, en este orden:
- El interruptor Enviar eventos está activado. Si la tarjeta dice En pausa, ese es el motivo.
- Hay al menos un evento seleccionado. Un webhook sin eventos queda guardado, pero en silencio.
- La URL es pública y HTTPS. Una dirección
localhost, una IP privada o un certificado autofirmado no funcionan. Para desarrollar en local, expón tu servidor con un túnel (ngrok o similar) y usa la URL que te dé. - Ha ocurrido el evento.
lead.createdsolo se dispara con leads nuevos; los que ya existían no lo vuelven a disparar.
La firma no me cuadra
Sección titulada «La firma no me cuadra»Casi siempre es una de estas dos:
- Estás calculando el HMAC sobre el JSON reconstruido en vez del cuerpo en crudo.
- Estás firmando solo el cuerpo, sin anteponer
`${timestamp}.`.
Comprueba también que usas la clave completa, incluido el prefijo, y que no se ha colado un salto de línea al copiarla.
Mi endpoint responde, pero falla
Sección titulada «Mi endpoint responde, pero falla»La tarjeta del Webhook personalizado muestra el estado del último envío, así que un endpoint que ha empezado a fallar se ve de un vistazo. Pulsa Enviar evento de prueba para reproducirlo al momento sin esperar a que llegue un lead real: verás el código HTTP y el tiempo de respuesta, o el error exacto si no hemos podido conectar.