Un webhook es un POST que nosotros le hacemos a tu servidor cuando algo pasa. Es la alternativa a preguntar cada minuto si hay novedades: te enterás en el momento y sin gastar cuota.
Cómo funciona
Registrá tu URL en el panel
En Configuración → Webhooks: pegás la URL pública (https://), elegís los eventos y guardás. El secreto se muestra una sola vez: copialo a tus variables de entorno.
Verificá la firma en cada request
Tu endpoint es público, así que cualquiera puede postearle. La firma es la única forma de saber que el evento salió de FlujosChat y que nadie lo modificó.
Respondé 2xx rápido
Cualquier 2xx cuenta como recibido. Encolá y procesá después: si tardás más de 5 segundos se corta y se cuenta como fallo.
Catálogo de eventos
Eventos disponibles
| Campo | Tipo | Descripción |
|---|---|---|
message.received | message | Un cliente te escribió por WhatsApp |
message.sent | message | Se envió un mensaje al cliente (agente, flujo, IA o API) |
message.status_updated | message | Meta reportó entregado / leído / fallido |
conversation.created | conversation | Se abrió una conversación con un número nuevo |
conversation.updated | conversation | Cambió el estado (cerrada, bot pausado, nombre) |
conversation.custom_field_updated | conversation | Se escribió un campo personalizado |
Te llegan solo los que pediste
Forma del evento
El cuerpo siempre tiene la misma estructura, cambia solo data:
{
"id": "evt_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c",
"type": "message.received",
"apiVersion": "2026-07-26",
"createdAt": "2026-07-26T14:31:10.000Z",
"companyId": "8d1f…",
"data": {
"object": "message",
"id": "9c1e…",
"conversationId": "3f2b…",
"direction": "inbound",
"type": "TEXT",
"text": "Hola, ¿ya salió mi pedido?",
"status": "DELIVERED",
"phone": "+51987654321",
"createdAt": "2026-07-26T14:31:09.000Z"
}
}id— identifica el evento. Es el mismo en todos los reintentos: usalo para deduplicar.apiVersion— la versión del formato. Si algún día cambia de forma incompatible, cambia este valor.data— un resumen del recurso, con los mismos nombres de campo que devuelve la API. Si necesitás más, pedí el recurso completo con suid.
Headers de cada entrega
| Campo | Tipo | Descripción |
|---|---|---|
X-FlujosChat-Signature | string | La firma: t=<unix>,v1=<hmac hex>. |
X-FlujosChat-Event | string | El tipo de evento (útil para rutear sin parsear el cuerpo). |
X-FlujosChat-Event-Id | string | El mismo id del cuerpo. |
X-FlujosChat-Attempt | number | Número de intento, empezando en 1. |
Verificar la firma
Se firma la cadena `${timestamp}.${cuerpo crudo}` con HMAC-SHA256 usando tu secreto. El timestamp va dentro de lo firmado, así que nadie puede reusar una firma vieja con una fecha nueva.
Firmá sobre el cuerpo crudo
express.raw(), en Flask request.get_data(), en PHP php://input.// Express — OJO: hay que firmar sobre el cuerpo CRUDO, no sobre el JSON ya parseado
import express from 'express';
import crypto from 'crypto';
const app = express();
const SECRET = process.env.FLUJOSCHAT_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
// express.raw, NO express.json: JSON.stringify(req.body) no reproduce byte a byte
// lo que se firmó (orden de claves, espacios) y la firma no coincidiría.
app.post('/webhooks/flujoschat', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-FlujosChat-Signature') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const raw = req.body.toString('utf8');
// 1) El timestamp tiene que ser reciente (corta los replays)
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return res.sendStatus(400);
// 2) La firma tiene que coincidir. v0 aparece solo durante una rotación.
const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${raw}`).digest('hex');
const ok = [parts.v1, parts.v0].filter(Boolean).some((sig) => {
const a = Buffer.from(sig, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual exige misma longitud y evita filtrar por tiempo
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(raw);
// 3) Respondé 2xx YA y procesá después: si tardás, se cuenta como fallo
res.sendStatus(200);
encolar(event); // dedupe por event.id dentro de la cola
});Los tres ejemplos hacen lo mismo: ventana de tiempo, comparación en tiempo constante, 2xx rápido.
Reintentos y duplicados
Se considera entregado con cualquier 2xx. Si no, se reintenta 6 veces con backoff creciente:
- Inmediato, 1 minuto, 5 minutos, 30 minutos, 2 horas y 8 horas (con algo de aleatoriedad).
- Tras 20 fallos consecutivos el endpoint se desactiva solo y lo ves en el panel. Un éxito reinicia el contador.
- En el panel podés ver cada entrega (estado, respuesta del receptor, error) y reintentar a mano.
La entrega es «al menos una vez»
id: guardá los ids procesados (24 h alcanza) y descartá los repetidos. Y no asumas orden: dos eventos casi simultáneos pueden llegar al revés.Rotar el secreto
Se puede cambiar el secreto sin perder eventos:
- Pulsás rotar en el panel y copiás el secreto nuevo.
- Durante la transición, cada entrega viaja firmada con los dos:
v1con el nuevo yv0con el anterior. Si tu código acepta ambos (como los ejemplos de arriba), no se cae nada. - Desplegás el secreto nuevo y cerrás la rotación en el panel: deja de enviarse
v0.
Checklist de producción
- Verificás la firma en cada request y rechazás con 401 si no coincide
- Rechazás timestamps con más de 5 minutos de desfase
- Comparás con hmac.compare_digest / timingSafeEqual / hash_equals, nunca con ==
- Respondés 2xx en menos de 5 segundos y procesás en una cola
- Deduplicás por el id del evento
- No asumís orden de llegada
- El secreto está en variables de entorno, no en el repositorio
- Tu URL es https y accesible desde internet (no localhost ni una IP privada)
Si además necesitás reconciliar (por ejemplo, tras una caída larga de tu lado), combinalo con la sincronización incremental: los webhooks te dan el tiempo real y updatedAfter te garantiza que no quedó nada atrás.
Preguntas frecuentes
¿Cómo verifico que un webhook viene realmente de FlujosChat?
Cada entrega trae el header X-FlujosChat-Signature con la forma t=<timestamp>,v1=<hmac>. Calculá HMAC-SHA256 sobre la cadena "<timestamp>.<cuerpo crudo>" usando tu secreto whsec_… y compará en tiempo constante con v1. Rechazá además los timestamps con más de 300 segundos de desfase.
¿Qué pasa si mi servidor está caído cuando ocurre un evento?
FlujosChat reintenta 6 veces con backoff exponencial: inmediato, 1 minuto, 5, 30, 2 horas y 8 horas. Si tu endpoint acumula 20 fallos consecutivos se desactiva y te avisa en el panel, para no seguir golpeando una URL muerta.
¿Un webhook puede llegar dos veces?
Sí. La entrega es at-least-once: si tu servidor responde tarde o corta la conexión después de procesar, el reintento vuelve a llegar. Deduplicá por el campo id del evento (evt_…), que es el mismo en todos los reintentos.
¿Los webhooks consumen mi cuota mensual de API?
No. Las entregas salientes no son requests tuyos, así que no cuentan en el límite mensual del plan. Lo que sí depende del plan es cuántos endpoints podés tener activos.