Cómo funcionan
Cómo suscribirte, qué vas a recibir, cómo verificar la firma, los reintentos y qué hacer en tu receptor.
Los webhooks avisan a tu sistema, con un POST firmado, cuando algo pasa en tu cuenta. Registrás una URL https, elegís los tipos de evento y listo: no hace falta consultar la API en bucle.
Dónde te suscribís
Hay un solo sistema de suscripciones, en /api/v1/webhooks. Cada suscripción tiene una URL, un secreto y un historial de entregas, y puede recibir eventos de las tres áreas: mensajes, conversaciones y contactos (conversations); grupos, participantes, envíos programados y campañas (communities); y números y cuenta (accounts). El sobre, las cabeceras y la firma son los mismos para todos los eventos. Según su especificación (instantánea 300cc8435), la atiende accounts.
Las rutas de la tabla son completas: la URL es la URL base + la ruta (cómo armar la URL).
| Ruta | Para qué | Permiso |
|---|---|---|
GET /api/v1/webhooks | Listar webhooks | webhooks:read |
POST /api/v1/webhooks | Crear un webhook | webhooks:write |
GET /api/v1/webhooks/events | Listar tipos de evento | accounts:read |
GET /api/v1/webhooks/{id} | Ver un webhook | webhooks:read |
PUT /api/v1/webhooks/{id} | Actualizar un webhook | webhooks:write |
DELETE /api/v1/webhooks/{id} | Eliminar un webhook | webhooks:write |
GET /api/v1/webhooks/{id}/deliveries | Listar entregas de un webhook | webhooks:read |
POST /api/v1/webhooks/{id}/test | Enviar un evento de prueba | webhooks:write |
Generada de la instantánea de Accounts, el área a la que la URL base manda /api/v1/webhooks. Rutas completas: la URL es la URL base + la ruta, sin agregar nada.
Permisos. Listar, ver y leer el historial de entregas piden webhooks:read; crear, editar, borrar y probar, webhooks:write, que incluye webhooks:read. webhooks:* y accounts:* no se incluyen entre sí. Además, con una clave, cada tipo de evento pide el permiso de lectura de su área (conversations:read, communities:read o accounts:read; el de escritura o envío de esa misma área también sirve):
- Al crear o editar, si a la clave le falta el permiso del área de algún tipo de
events, la respuesta es422. - Para ver, editar, borrar, probar o leer las entregas de una suscripción, la clave necesita el permiso de lectura del área de cada tipo que la suscripción tiene ahora; si le falta alguno, responde
403conerror: "permission_denied", el mensaje nombra el permiso que falta y no se cambia, borra ni envía nada. GET /api/v1/webhookslista solo las suscripciones que la clave puede leer.
El catálogo (GET /api/v1/webhooks/events) pide accounts:read. La clave tiene que ser del titular de la cuenta.
El sistema anterior de accounts
/api/v1/webhooks/endpoints… es el sistema anterior: sigue funcionando con los permisos accounts:*, pero solo para los eventos de la cuenta y sus números. Para una integración nueva usá /api/v1/webhooks. Ver El sistema anterior de accounts.
Alta de una suscripción
{
"url": "https://tu-servidor.example/hooks/funnelchat",
"description": "Mi integración",
"events": ["message.received"]
}url: la dirección pública de tu receptor, hasta 2048 caracteres. Usáhttps. Una dirección interna no recibe entregas (la prueba respondedestination_not_allowed).events: de 1 a 50 tipos del catálogo, sin repetir, de cualquiera de las tres áreas. No hay un comodín para «todos»: una suscripción recibe solo los tipos que eligió.description(opcional, hasta 160 caracteres) te ayuda a reconocerla en la lista.filters(opcional) admitecampaign_ids; la especificación no describe más su efecto.- La respuesta (
201) es la suscripción, sin envoltoriodata:id,url,description,events,filters,status,disabled_reason, las fechas y, solo esta vez,secret(con un aviso ensecret_notice). Ninguna lectura posterior lo vuelve a mostrar. - El secreto se rota desde el panel de Funnelchat, no con la clave: no hay una ruta para rotarlo. Lo mismo para «Activar» una suscripción migrada desde el sistema anterior.
Tu suscripción y las de otros
Las suscripciones son de la cuenta, no de la integración: GET /api/v1/webhooks muestra todas las de la cuenta que tu clave puede leer, también las que crearon otras integraciones o personas.
- Guardá el
idal crearla. Es lo que identifica la tuya.descriptionayuda a reconocerla en la lista, pero no es única. - Para cambiar la URL, usá
PUT /api/v1/webhooks/{id}, por ejemplo cuando cambia tu túnel.PUTreemplazaurl,events,descriptionyfilters(mandá siempreurlyevents); el secreto no cambia y no se acumulan suscripciones. Si en cambio creás otra, recibís otro secreto y la anterior sigue recibiendo eventos. - Pausala en lugar de borrarla si querés dejar de recibir por un tiempo:
PUTconstatus: "paused", y"active"para volver. No se puede dejar endisableda mano. - No borres ni modifiques las que no reconocés sin confirmarlo: pueden ser de otra integración de la misma cuenta. Borrar una suscripción borra también su historial de entregas.
- Una lectura (
GET /api/v1/webhooks/{id}) no trae el secreto. Si lo perdiste, rotalo desde el panel.
Estados. status es active, paused o disabled; disabled_reason dice por qué quedó deshabilitada:
- La clave se revocó. Una suscripción queda atada a la clave que la creó o a la última clave que cambió su
url, aunque la haya creado el panel u otra clave. Si esa clave se revoca, deja de recibir eventos de inmediato y pasa adisabledcondisabled_reason: "key_revoked". No se puede volver a habilitar: creá una nueva. Una suscripción creada desde el panel y que ninguna clave redirigió es de la cuenta: revocar claves no la afecta. - Fallas seguidas. Una suscripción que acumula 5 entregas fallidas consecutivas (agotadas o con una respuesta que no se reintenta) se deshabilita sola. Arreglá tu receptor y reactivala con
PUTystatus: "active".
Qué vas a recibir
Cada entrega es un POST con un cuerpo JSON con esta forma:
| Campo | Qué es |
|---|---|
id | Identificador del evento (evt_…). Es el mismo en todos los reintentos: usalo para deduplicar. |
type | Tipo de evento, por ejemplo message.received. |
version | Versión del formato de data (hoy 1). |
occurred_at | Cuándo ocurrió, en UTC (ISO 8601). |
data | Los datos del evento. Depende del tipo: ver el catálogo. |
Ningún payload lleva identificadores internos de cuenta (user_id, account_id, owner_id). (C3)
| Cabecera | Contenido |
|---|---|
Content-Type | application/json |
X-Funnelchat-Signature | t=<unix>,v1=<hex>: la firma de esta entrega. |
X-Funnelchat-Event | El tipo de evento (igual a type). |
X-Funnelchat-Delivery | Identificador de este intento de entrega; cambia en cada reintento. |
Solo el cuerpo está firmado. X-Funnelchat-Event y X-Funnelchat-Delivery no: tomá type e id del cuerpo ya verificado.
Verificar la firma
X-Funnelchat-Signature: t=<unix>,v1=<hmac_sha256(secreto, "<t>.<cuerpo>")>- Tomá el cuerpo crudo, tal como llegó. No lo parsees y vuelvas a serializar: un espacio de diferencia cambia la firma.
- Armá el texto
t + "." + cuerpo. - Calculá su HMAC-SHA256 en hexadecimal con el secreto completo de la suscripción, tal como se mostró (si empieza con
whsec_, el prefijo es parte del secreto). - Comparalo con
v1en tiempo constante. - Rechazá la entrega si
tse aleja más de 300 segundos del reloj de tu servidor.
Estos snippets son los mismos archivos que prueba tests/test_firmas.py: firma un cuerpo de ejemplo y comprueba que cada uno acepta la firma correcta y rechaza las alteradas.
// Verifica la cabecera X-Funnelchat-Signature de una entrega de webhook (Node.js 16+).
// cuerpoCrudo: el cuerpo tal como llegó (Buffer o string), sin parsear ni volver a serializar.
// secreto: el secreto completo de la suscripción, tal como se mostró al crearla o rotarla.
const crypto = require('node:crypto');
function verificarFirma(cuerpoCrudo, cabecera, secreto, toleranciaSegundos = 300) {
if (typeof cabecera !== 'string' || typeof secreto !== 'string' || secreto === '') return false;
let t = null;
const firmas = [];
for (const parte of cabecera.split(',')) {
const i = parte.indexOf('=');
if (i === -1) continue;
const clave = parte.slice(0, i).trim();
const valor = parte.slice(i + 1).trim();
if (clave === 't') t = valor;
if (clave === 'v1') firmas.push(valor);
}
if (t === null || !/^\d+$/.test(t) || firmas.length === 0) return false;
// Rechazá entregas viejas o del futuro (protección contra repeticiones).
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranciaSegundos) return false;
const esperada = crypto
.createHmac('sha256', secreto)
.update(`${t}.`)
.update(cuerpoCrudo)
.digest('hex');
const a = Buffer.from(esperada, 'utf8');
return firmas.some((v1) => {
const b = Buffer.from(v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
module.exports = { verificarFirma };
// Uso con Express: leé el cuerpo crudo, verificá, y recién después parseá.
// app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verificarFirma(req.body, req.get('X-Funnelchat-Signature'), process.env.FUNNELCHAT_WEBHOOK_SECRET)) {
// return res.sendStatus(400);
// }
// const evento = JSON.parse(req.body.toString('utf8')); // tomá type e id de acá, no de las cabeceras
// res.sendStatus(200); // respondé rápido; procesá después
// });Último resultado de tests/test_firmas.py (2026-10-22 20:53 (+00:00)): pasó — Python 3.12.15, Node v24.21.0, PHP 8.3.6.
Reintentos
- Una entrega es exitosa si tu servidor responde con cualquier
2xx. - Hay hasta 4 intentos por evento y suscripción. Entre uno y el siguiente se espera 60 s, 5 min y 30 min.
- Se reintenta ante un error de red, un
5xxo un429. Un3xxo cualquier otro4xxes definitivo: no se reintenta. - 5 entregas fallidas seguidas deshabilitan la suscripción (estados).
GET /api/v1/webhooks/{id}/deliveries devuelve un registro por intento, lo más reciente primero. Pagina igual que el resto de la API: page y perPage (25 por defecto, 100 como máximo; también vale per_page). Cada ítem trae:
| Campo | Qué es |
|---|---|
id | El intento. |
event_id | El id del sobre: agrupa los intentos de un mismo evento y es el mismo valor que vio tu receptor. |
event_type | Tipo de evento. |
attempt | Número de intento. |
url | A dónde se envió. |
response_status | El código HTTP que respondió tu servidor, o null si no hubo respuesta. |
succeeded | true si respondió 2xx. |
error | Por qué falló, o null. |
duration_ms | Cuánto tardó. |
attempted_at | Cuándo se intentó. |
Con una clave, el historial muestra solo las entregas de tipos cuya área la clave puede leer; las de prueba (webhook.test) se muestran siempre.
Fechas: todas son ISO 8601 en UTC, pero el sufijo puede cambiar, incluso dentro de un mismo evento: occurred_at viene con Z (2026-10-05T03:00:00Z) y otras fechas de data con +00:00 (2026-10-05T03:00:00+00:00). Leelas con un parser ISO 8601, no comparando texto. (E40)
Si tenés varias suscripciones de la cuenta al mismo tipo de evento, cada una recibe su propia entrega, firmada con su propio secreto y con sus propios reintentos. El id del evento es el mismo en todas.
Qué hacer en tu receptor
- Respondé rápido. Devolvé
2xxapenas verificaste la firma y procesá el evento en segundo plano: una entrega que no recibe respuesta cuenta como fallo y se reintenta. - Sé idempotente. La entrega es «al menos una vez»: el mismo evento puede llegar más de una vez, por ejemplo si lo procesaste pero respondiste tarde. Guardá los
idya procesados y descartá los repetidos. - Tolerancia de tiempo. Rechazá entregas con un
ta más de 300 segundos de tu reloj: protege contra repeticiones. Mantené el reloj del servidor sincronizado. - Verificá antes de confiar. Sin firma válida, respondé
400y no hagas nada. Leétypeeiddel cuerpo verificado, no de las cabeceras. - Ojo con los 4xx. Un
4xxdistinto de429corta los reintentos: devolvelo solo cuando de verdad no querés ese evento. - Tolerá valores nuevos. Si un campo trae un valor que no conocés (por ejemplo, un
identity_typedistinto dephone), respondé2xxigual y no lo proceses: no es un error de la entrega. Ver el catálogo.
El teléfono en los eventos y en la API
Comportamiento de v0: lo que traen los eventos según los ejemplos de las especificaciones, y lo que hace la API de conversations, observado con llamadas reales.
- Los eventos traen el teléfono con
+y el código de país:"+5491100000000"encontact.phonedemessage.receivedy enparticipant.identityde los eventos de communities que traen un participante. - La API guarda el teléfono sin
+.POST /api/v1/contacts/update-or-createcon"+5491100000099"creó el contacto conphone: "5491100000099"(ycountry_codevacío); la misma llamada con"5491100000099"devolvió ese mismo contacto. - La búsqueda no quita el
+.GET /api/v1/contacts?phone=%2B5491100000099(el+codificado, como lo manda cualquier cliente HTTP) responde vacío;?phone=5491100000099encuentra el contacto. - La búsqueda por
phoneno es exacta: también devuelve contactos cuyo teléfono contiene el número buscado (?phone=1100000000encontró5491100000000).
Qué hacer: antes de buscar un contacto por el teléfono de un evento, quitale el + (+5491100000000 → 5491100000000) y, entre los resultados, quedate con el que tenga ese phone exacto. Para crear o actualizar, podés mandarlo con o sin +.
Evento de prueba
POST /api/v1/webhooks/{id}/test envía una única entrega de webhook.test a la URL de la suscripción, firmada igual que una real y sin reintentos, y la guarda en el historial. Sirve para probar tu receptor sin esperar un evento real. Funciona con suscripciones active o paused; una disabled responde 422 y no envía nada (si se deshabilitó por fallas, reactivala y probá después; si fue por key_revoked, creá una nueva). No se puede suscribir a webhook.test.
La respuesta (200) dice cómo le fue:
{
"succeeded": true,
"response_status": 200,
"error": null,
"duration_ms": 184,
"delivery_id": 9012
}La prueba llegó si succeeded es true. Si no, response_status y error dicen por qué; error es uno de non_2xx, timeout, connection_failed, tls_error, destination_not_allowed, secret_unreadable o internal_error. delivery_id es su registro en el historial de entregas.
El sistema anterior de accounts
Antes de las suscripciones unificadas, accounts tenía su propio sistema, en /api/v1/webhooks/endpoints…. Sigue funcionando con los permisos accounts:*, pero solo para los eventos de la cuenta y sus números (device.connected, device.disconnected, cobros, suscripción, contactos activos y cuota de IA). Para una integración nueva usá /api/v1/webhooks, que recibe esos mismos eventos y los de las otras dos áreas. Sus rutas están en Webhooks › Legado › Endpoints de webhooks (sistema anterior).
Lo que vale para los dos sistemas:
- El mismo sobre, las mismas cabeceras y la misma firma
t=…,v1=…enX-Funnelchat-Signature: los snippets de arriba sirven tal cual, con el secreto de cada uno. data.device.numberva solo con dígitos (5491100000000), sin+. Un número de WhatsApp Cloud avisadevice.connected, pero todavía nodevice.disconnected. (E29, E30, E37)
No confundir
- communities: las rutas
campaigns/{campaign}/webhooksson los webhooks legados de campaña, otro producto con otro formato. No reciben estos eventos. - conversations: los webhooks de alertas y el nodo «Petición HTTP» de las automatizaciones son otros productos, con otro formato y otra configuración.
- accounts: los destinos de alertas de tipo
webhook(/api/v1/alerts/destinations) son parte de las alertas operativas, no de estos webhooks.