Ir al contenido

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.

  1. Entra en Configuración del negocio. En la tarjeta Integraciones, pulsa el botón Integraciones.

  2. Busca la tarjeta Webhook personalizado y pulsa Configurar webhook.

  3. Pega la URL del endpoint. Tiene que ser una URL pública HTTPS que responda con un estado 2xx.

  4. Elige los eventos que quieres recibir. Si no marcas ninguno, el webhook queda guardado pero no envía nada.

  5. 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.

  6. Deja activado el interruptor Enviar eventos. Viene activado por defecto; desactívalo cuando quieras pausar los envíos sin perder la configuración.

  7. Guarda.

  8. Vuelve a abrir el diálogo y pulsa Enviar evento de prueba. ZeroChats manda un ping de 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.

EventoCuándo se disparaQué añade al contenido
lead.createdUn lead contacta con el negocio por primera vez.
lead.taggedSe asignan una o varias etiquetas a un lead.addedTags
lead.state_changedUn lead pasa a una etapa nueva dentro del embudo.state, stateTag
lead.media_repliedUn 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.

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",
"email": "[email protected]",
"phone": null,
"state": "IN_PROGRESS",
"tags": ["interesado"],
"createdAt": "2026-08-11T09:12:30.000Z"
}
}
}
CampoQué es
idIdentificador único de este envío. Mismo valor que X-ZeroChats-Delivery.
eventNombre del evento: lead.created, lead.tagged, lead.state_changed o lead.media_replied.
createdAtMomento en que se generó el evento, en formato ISO 8601 (UTC).
businessIdIdentificador de tu negocio en ZeroChats.
dataEl contenido del evento. Siempre incluye lead.
CampoQué es
idIdentificador del lead en ZeroChats. Estable: úsalo como clave para no duplicar.
nameNombre del lead.
usernameNombre de usuario en la plataforma de origen.
platformCanal de origen: INSTAGRAM, WHATSAPP, WASENDERAPI o MESSENGER.
externalIdIdentificador 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.
emailEmail del lead, o null si todavía no lo ha dado.
phoneTeléfono del lead, o null si todavía no lo ha dado.
stateEtapa del embudo de leads, por su nombre: IN_PROGRESS, QUALIFYING, LEAD, BOOKED, CLIENT
tagsLista completa de etiquetas del lead en ese momento.
createdAtFecha de creación del lead, en formato ISO 8601 (UTC).

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",
"email": "[email protected]",
"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.

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",
"email": "[email protected]",
"phone": null,
"state": "BOOKED",
"tags": ["interesado", "precio"],
"createdAt": "2026-08-11T09:12:30.000Z"
},
"state": "Booked",
"stateTag": "Status: Booked"
}
CampoQué es
stateEl nombre de la etapa a la que acaba de pasar: Discovering, Qualifying, Proposal, Booked, Paid
stateTagEse 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 appstatestateTag
DescubriendoDiscoveringStatus: Discovering
CualificandoQualifyingStatus: Qualifying
PropuestaProposalStatus: Proposal
AgendadoBookedStatus: Booked
PagadoPaidStatus: Paid
No cualificadoUnqualifiedStatus: Unqualified
PerdidoLostStatus: Lost
AbiertaOpenStatus: Open
PospuestoPostponedStatus: 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.

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",
"email": "[email protected]",
"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"
}
CampoQué es
media.idIdentificador de la publicación en Instagram. Viaja siempre, y como texto.
media.typeTipo de publicación: STORY, REELS, FEED o AD.
media.permalinkEnlace a la publicación, o null si Instagram ya no la expone.
media.previewUrlImagen de vista previa, o null si no hemos podido obtenerla.
sourcePor 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.

CabeceraValor
Content-Typeapplication/json
X-ZeroChats-EventNombre del evento, por ejemplo lead.created.
X-ZeroChats-DeliveryIdentificador único de este envío.
X-ZeroChats-TimestampMomento de la firma, en segundos Unix.
X-ZeroChats-Signaturesha256=<hex>. Solo aparece si has configurado clave de 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:

  1. Rechazar peticiones viejas, fuera de una tolerancia razonable (5 minutos va bien).
  2. Calcular el HMAC sobre el cuerpo en crudo, antes de cualquier JSON.parse.
  3. 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);
}

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
});
  • É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, un 408, un 429 o hay un error de red. La espera entre intentos es de 1 s y luego 5 s; si nos respondes un 429, 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 un 429 (o un 503) 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 4xx no 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-Delivery y 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.

  • 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-Delivery si tu flujo hace algo que no conviene repetir (cobrar, enviar un email, crear una fila).
  • Usa lead.id como 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.

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.created solo se dispara con leads nuevos; los que ya existían no lo vuelven a disparar.

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.

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.