Documentación de la API
Eventos 9 min

Webhooks: recibir eventos de WhatsApp en tiempo real

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

1

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.

2

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

3

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

CampoTipoDescripción
message.receivedmessageUn cliente te escribió por WhatsApp
message.sentmessageSe envió un mensaje al cliente (agente, flujo, IA o API)
message.status_updatedmessageMeta reportó entregado / leído / fallido
conversation.createdconversationSe abrió una conversación con un número nuevo
conversation.updatedconversationCambió el estado (cerrada, bot pausado, nombre)
conversation.custom_field_updatedconversationSe escribió un campo personalizado

Te llegan solo los que pediste

No existe un comodín «todos»: si mañana agregamos un evento, tu endpoint no empieza a recibir datos que nunca pediste. Para sumarlo, lo marcás en el panel.

Forma del evento

El cuerpo siempre tiene la misma estructura, cambia solo data:

Respuesta · 200
{
  "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 su id.

Headers de cada entrega

CampoTipoDescripción
X-FlujosChat-SignaturestringLa firma: t=<unix>,v1=<hmac hex>.
X-FlujosChat-EventstringEl tipo de evento (útil para rutear sin parsear el cuerpo).
X-FlujosChat-Event-IdstringEl mismo id del cuerpo.
X-FlujosChat-AttemptnumberNú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

Si tu framework parsea el JSON y después lo volvés a serializar, los bytes cambian (orden de claves, espacios) y la firma no va a coincidir. En Express usá 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»

Si tu servidor procesa el evento pero la respuesta se pierde, el reintento vuelve a llegar. Deduplicá por 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: v1 con el nuevo y v0 con 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.

Monta tu primer flujo en una tarde

Conecta tu número de WhatsApp, arma el flujo con botones y listas, y publícalo. Sin código y sin depender de nadie.

  • 7 días gratis, sin tarjeta
  • Cancela desde tu panel
  • Credenciales cifradas