Recibir los mensajes de WhatsApp en tu sistema

Cada vez que un contacto le escribe a un número de tu cuenta, tu servidor recibe el mensaje firmado, sin consultar la API en bucle.

Vas a armar un receptor HTTPS que recibe el evento message.received cada vez que un contacto le escribe a uno de tus números de WhatsApp. Usa Crear un webhook para suscribirte, Enviar un evento de prueba para probar el receptor y Listar entregas de un webhook para revisar qué llegó.

Al terminar esta guía, cada mensaje entrante llega a tu servidor como un POST firmado con el texto, el tipo de mensaje, el contacto que escribió y el número de tu cuenta que lo recibió. Tu receptor verifica la firma, responde enseguida y procesa cada mensaje una sola vez.

Antes de empezar

  • Una clave con webhooks:write y conversations:read. webhooks:write crea, prueba y lee la suscripción y su historial de entregas; conversations:read es el permiso del área de message.received, que la suscripción pide para ese evento. Para ver el catálogo de eventos desde la API hace falta además accounts:read. La clave tiene que ser del titular de la cuenta. Si todavía no tenés una, seguí la guía rápida; los permisos están en Autenticación y permisos.
  • La clave en la variable FC_API_KEY. Lleva un |: en un archivo .env, escribila entre comillas.
  • Un servidor con una URL https pública. Una dirección interna no recibe entregas.

1. Armá el receptor

Tu receptor tiene que hacer cuatro cosas, en este orden:

  1. Leer el cuerpo crudo, tal como llegó, sin parsearlo.
  2. Verificar la firma de la cabecera X-Funnelchat-Signature con el secreto de la suscripción. Si no es válida, responder 400 y no hacer nada. La fórmula y el código probado en Node.js, Python y PHP están en Verificar la firma: copiá el de tu lenguaje a tu proyecto tal cual (los ejemplos de abajo lo importan como verificar-firma.js y verificar_firma.py).
  3. Responder 2xx enseguida. Si no respondés, la entrega cuenta como fallida y se reintenta. Procesá el mensaje después, en segundo plano.
  4. Descartar los repetidos por id. La entrega es «al menos una vez»: el mismo evento puede llegar más de una vez, siempre con el mismo id en el cuerpo.

Leé type e id del cuerpo ya verificado, no de las cabeceras: solo el cuerpo está firmado.

// receptor.js · Node 18+ con Express
const express = require('express');
const { verificarFirma } = require('./verificar-firma'); // el código de /docs/webhooks/#firma

const app = express();
const procesados = new Set(); // en producción, guardá los id en tu base de datos

app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), (req, res) => {
  const firma = req.get('X-Funnelchat-Signature');
  if (!verificarFirma(req.body, firma, process.env.FUNNELCHAT_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const evento = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // primero respondé; después procesá

  if (procesados.has(evento.id)) return; // repetido: ya lo procesaste
  if (evento.type !== 'message.received') return; // por ejemplo, webhook.test

  setImmediate(async () => {
    await guardarMensaje(evento.data); // en producción, mandalo a una cola
    procesados.add(evento.id);
  });
});

async function guardarMensaje({ message, contact, receiver, device_id }) {
  console.log(`${contact.phone} le escribió a ${receiver.phone} (número ${device_id}): [${message.type}] ${message.text ?? ''}`);
}

app.listen(3000);

FUNNELCHAT_WEBHOOK_SECRET es el secreto de la suscripción que vas a crear en el paso siguiente. Hasta tenerlo, tu receptor rechaza todo con 400, y está bien.

2. Suscribite a message.received

Con el receptor publicado, creá la suscripción. Hay un solo lugar para suscribirse a los eventos de las tres áreas, /api/v1/webhooks. Pide webhooks:write y, por ser un evento de conversations, conversations:read.

curl -X POST "https://api.funnelchat.com/api/v1/webhooks" \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://tu-servidor.example/hooks/funnelchat",
    "description": "Mensajes entrantes",
    "events": ["message.received"]
  }'

La respuesta (201) es la suscripción, sin envoltorio data. Estos son sus campos según la especificación; los valores son de ejemplo:

{
  "id": "00000000-0000-4000-8000-000000000001",
  "url": "https://tu-servidor.example/hooks/funnelchat",
  "description": "Mensajes entrantes",
  "events": ["message.received"],
  "filters": {},
  "status": "active",
  "disabled_reason": null,
  "last_delivery_at": null,
  "last_success_at": null,
  "secret_rotated_at": null,
  "created_at": "2026-10-01T12:00:00+00:00",
  "updated_at": "2026-10-01T12:00:00+00:00",
  "migration": null,
  "secret": "whsec_<secreto de firma>",
  "secret_notice": "Guardá este secreto ahora: es la única vez que se muestra. Con él validás la firma del header de cada entrega."
}

El secreto se ve una sola vez

Guardá secret en tu servidor, completo y tal como llegó (con el prefijo whsec_), en FUNNELCHAT_WEBHOOK_SECRET: con él se firma cada entrega. No se puede volver a consultar; si lo perdés, rotalo desde el panel de Funnelchat. Guardá también id: identifica tu suscripción entre las de la cuenta (detalle).

3. Probá la entrega

No hace falta esperar a que alguien escriba: Enviar un evento de prueba manda ya un evento webhook.test a tu URL, firmado igual que uno real, y te dice cómo le fue. {id} es el id del paso anterior (en el ejemplo de curl, la variable WEBHOOK_ID). Pide webhooks:write.

curl -X POST "https://api.funnelchat.com/api/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "Accept: application/json"

Respuesta de ejemplo (200, con los campos de la especificación):

{
  "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é (non_2xx, timeout, connection_failed, tls_error, destination_not_allowed…); el intento queda además en Listar entregas de un webhook, junto con todas las entregas reales. La prueba no se reintenta.

4. Leé el mensaje

Cuando un contacto le escribe a uno de tus números, llega esto (ejemplo del catálogo de eventos):

{
  "id": "evt_000000000000000000000001",
  "type": "message.received",
  "version": 1,
  "occurred_at": "2026-10-01T12:00:00Z",
  "data": {
    "message": {
      "id": 1001,
      "type": "text",
      "text": "Hola, quiero más información",
      "media": null
    },
    "contact": {
      "id": 2001,
      "name": "Contacto de ejemplo",
      "phone": "+5491100000000"
    },
    "receiver": {
      "phone": "+5491100000001"
    },
    "device_id": 4521
  }
}
CampoQué es
idIdentificador del evento. Es el mismo en todos los reintentos: con él descartás repetidos.
occurred_atCuándo ocurrió, en UTC (ISO 8601).
data.message.typeTipo de mensaje, como texto: text, image, video, document, sticker, contact, audio, note, action, invalid, interactive, location, voice_call, video_call, event, button, template, notification, option_list, template_button_reply, poll, whatsapp_form, call_permission_request, call_permission_reply o request_contact_info.
data.message.textEl texto del mensaje.
data.message.medianull en el ejemplo, que es un mensaje de texto.
data.contactQuién escribió: id, name y phone.
data.receiver.phoneEl teléfono del número de tu cuenta que recibió el mensaje.
data.device_idEl número de tu cuenta que recibió el mensaje: el mismo device_id que mandás para responder por ese número. Es null si el mensaje no tiene número asociado.
  • Los teléfonos vienen con + y el código de país. La API de contactos los guarda sin +: para buscar al contacto, quitáselo antes (El teléfono en los eventos y en la API).
  • Tolerá campos y valores nuevos. El catálogo anuncia datos planificados dentro de este evento que el ejemplo de hoy todavía no trae. Si llega algo que no conocés, respondé 2xx igual.
  • Para saber a cuál de tus números le escribieron, usá device_id: es el id de Listar números (pide accounts:read).

Si falla

Qué vesQué pasóQué hacer
401Falta la clave, no existe o fue revocada.Revisá la cabecera Authorization: Bearer con la clave completa.
403 con error: "permission_denied"A la clave le falta un permiso: webhooks:write para crear y probar, webhooks:read para ver la suscripción y sus entregas, o conversations:read, el del área de message.received.Creá una clave con ese permiso. message dice cuál falta.
403 sin errorLo rechazó la autorización propia de la ruta, no el portero.Leé message. Recordá que las rutas de webhooks piden una clave del titular de la cuenta.
422 al crearLa validación falló: por ejemplo, url no es una URL válida, events trae un tipo que no existe, o a la clave le falta conversations:read para message.received.errors dice qué campo corregir.
404 al probarNo existe una suscripción con ese id en la cuenta.Revisá el id que guardaste al crearla.
422 al probarLa suscripción está disabled: no se envía nada.Mirá disabled_reason en la suscripción (abajo).
La prueba vuelve con succeeded: falseTu receptor no respondió 2xx, o no se pudo llegar.Mirá response_status y error. Un 400 de tu receptor suele ser la firma: cuerpo re-serializado, secreto incompleto o reloj desincronizado más de 300 segundos.
No llegan los mensajes realesLa suscripción está pausada o deshabilitada.Mirá status y disabled_reason. Con key_revoked no se puede reactivar: creá una nueva. Si se deshabilitó por 5 entregas fallidas seguidas, arreglá el receptor y reactivala con PUT y status: "active".
503El portero no pudo evaluar la llamada (gate_unavailable).Esperá lo que indica Retry-After y reintentá.

Todos los códigos del portero están en Errores, y los reintentos de las entregas, en Webhooks.

Relacionado

En esta página