Pantalla dividida de terminal oscura mostrando un manejador de webhooks en Node.js con verificación HMAC-SHA256 a la izquierda y un payload JSON entrante de WhatsApp a la derecha
En esta guía: Cómo funciona el pipeline de webhooks · Crear el endpoint (Node.js + Python) · Manejar el desafío de verificación · Verificación de firma HMAC-SHA256 · Analizar cada tipo de mensaje · Manejar descargas de medios · Patrón de colas para producción · SocialHook como capa administrada · FAQ

Cómo funciona el pipeline de mensajes entrantes de WhatsApp

La mayoría de los tutoriales empiezan con código antes de explicar la arquitectura. Por eso la mayoría de las implementaciones tienen errores. Aquí tienes el pipeline completo: cada paso que sigue un mensaje antes de llegar a la lógica de tu aplicación:

Cuatro cosas deben cumplirse para que este pipeline funcione de forma fiable:

  • Tu endpoint debe ser accesible públicamente mediante HTTPS. Nada de localhost ni HTTP. Meta rechaza URLs que no usan HTTPS y no puede acceder a IPs privadas.
  • Tu endpoint debe responder con 200 en menos de 20 segundos. Si tarda más, Meta vuelve a intentar la entrega. Si falla demasiadas veces, dejará de enviar mensajes a tu endpoint por completo.
  • Debes verificar la firma HMAC-SHA256 en cada solicitud. Sin esto, cualquier atacante que descubra la URL de tu webhook puede enviar mensajes falsos a tu sistema.
  • Primero confirma, después procesa. Devuelve 200 inmediatamente, envía el payload a una cola y procesa de forma asíncrona. Nunca hagas trabajo pesado de manera síncrona dentro del manejador del webhook.

Paso 1: Crear el endpoint del webhook

Tu endpoint debe manejar dos métodos HTTP en la misma URL: GET (el desafío único de verificación de Meta) y POST (eventos de mensajes en vivo). Aquí tienes la implementación mínima funcional tanto en Node.js como en Python.

Node.js + Express
webhook.js
const express = require('express'); const crypto = require('crypto'); const app = express(); // CRÍTICO: analizar el cuerpo raw ANTES de express.json() // Necesitas el buffer raw para la verificación HMAC app.use('/webhook', express.raw({ type: '*/*' })); const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN; // tu propia cadena const APP_SECRET = process.env.WHATSAPP_APP_SECRET; // desde Meta Developer Dashboard // GET — desafío de verificación de Meta app.get('/webhook', (req, res) => { const mode = req.query['hub.mode']; const token = req.query['hub.verify_token']; const challenge = req.query['hub.challenge']; if (mode === 'subscribe' && token === VERIFY_TOKEN) { console.log('Webhook verificado ✓'); res.status(200).send(challenge); // devolver el challenge como texto plano } else { res.sendStatus(403); } }); // POST — eventos de mensajes entrantes app.post('/webhook', (req, res) => { // 1. Verificar la firma ANTES de cualquier otra cosa if (!verifySignature(req)) { return res.sendStatus(403); } // 2. Confirmar inmediatamente — NUNCA procesar de forma síncrona res.sendStatus(200); // 3. Analizar y poner en cola para procesamiento asíncrono const body = JSON.parse(req.body.toString()); enqueue(body); // tu función de cola — ver Paso 6 }); app.listen(3000, () => console.log('Servidor webhook ejecutándose en :3000'));
Python + FastAPI
webhook.py
import hmac, hashlib, os, json from fastapi import FastAPI, Request, Response, HTTPException from fastapi.responses import PlainTextResponse app = FastAPI() VERIFY_TOKEN = os.environ["WEBHOOK_VERIFY_TOKEN"] APP_SECRET = os.environ["WHATSAPP_APP_SECRET"] # GET — desafío de verificación de Meta @app.get("/webhook", response_class=PlainTextResponse) async def verify(request: Request): params = request.query_params mode = params.get("hub.mode") token = params.get("hub.verify_token") challenge = params.get("hub.challenge") if mode == "subscribe" and token == VERIFY_TOKEN: return challenge # texto plano, 200 OK raise HTTPException(status_code=403) # POST — eventos entrantes @app.post("/webhook") async def receive(request: Request): raw_body = await request.body() # 1. Verificar HMAC antes de cualquier otra cosa if not verify_signature(request, raw_body): raise HTTPException(status_code=403) # 2. Confirmar inmediatamente payload = json.loads(raw_body) enqueue(payload) # enviar al worker asíncrono return Response(status_code=200)

Paso 2: Manejar correctamente el desafío de verificación

Cuando registras la URL de tu webhook en Meta Developer Dashboard, Meta envía una solicitud GET única para verificar que controlas el endpoint. Este es el paso donde casi todas las primeras implementaciones fallan — normalmente porque los desarrolladores devuelven JSON en lugar de texto plano, o devuelven el objeto completo de parámetros en lugar de solo el valor challenge.

La solicitud GET de Meta incluye tres parámetros de consulta:

  • hub.mode — siempre la cadena subscribe
  • hub.verify_token — la cadena que configuraste en Meta Dashboard. Tú eliges este valor — haz que sea un secreto aleatorio, no una palabra predecible
  • hub.challenge — un número aleatorio que Meta quiere recibir de vuelta como texto plano

Tu endpoint debe: (1) comprobar que hub.mode === 'subscribe', (2) comprobar que hub.verify_token coincida con el valor esperado, (3) devolver hub.challenge como cuerpo de la respuesta con Content-Type: text/plain y estado 200. Si devuelves JSON, el valor incorrecto o un estado distinto de 200, la verificación falla y Meta marcará tu webhook como no verificado.

Error común: Usar res.json({ challenge }) en Express en lugar de res.send(challenge). Meta espera texto plano. Envolver el challenge en JSON hace que la verificación falle incluso si tu servidor devuelve 200.

Paso 3: Verificación de firma HMAC-SHA256 — el paso que nadie omite en producción

La URL de tu webhook es pública. Cualquiera que la encuentre puede enviar payloads falsos a tu servidor mediante POST. Sin verificación de firma, tu agente de IA podría recibir conversaciones fabricadas, tu CRM podría contaminarse con contactos inventados y tu lógica podría ser activada por un atacante cuando quiera.

Meta firma cada solicitud POST usando tu App Secret (ubicado en Meta Developer Dashboard → App Settings → Basic). La firma está en el encabezado X-Hub-Signature-256, con formato sha256=<hex_digest>. El digest es un HMAC-SHA256 de los bytes raw del cuerpo de la solicitud usando tu App Secret como clave.

Node.js
verifySignature.js
function verifySignature(req) { const sigHeader = req.headers['x-hub-signature-256']; if (!sigHeader) return false; // Eliminar el prefijo 'sha256=' const receivedSig = sigHeader.slice(7); // todo después de 'sha256=' // req.body DEBE ser el buffer raw — ver configuración express.raw() arriba const expectedSig = crypto .createHmac('sha256', APP_SECRET) .update(req.body) // Buffer raw, NO JSON analizado .digest('hex'); // timingSafeEqual previene ataques de timing try { return crypto.timingSafeEqual( Buffer.from(receivedSig, 'hex'), Buffer.from(expectedSig, 'hex') ); } catch { return false; // longitudes incompatibles lanzan error — tratar como inválido } } module.exports = { verifySignature };
Python
verify.py
import hmac, hashlib def verify_signature(request: Request, raw_body: bytes) -> bool: sig_header = request.headers.get("X-Hub-Signature-256", "") if not sig_header.startswith("sha256="): return False received_sig = sig_header[7:] # eliminar prefijo 'sha256=' expected_sig = hmac.new( APP_SECRET.encode(), raw_body, # bytes raw, NO cadena decodificada hashlib.sha256 ).hexdigest() # hmac.compare_digest previene ataques de timing return hmac.compare_digest(received_sig, expected_sig)
Crítico: usa bytes raw, no JSON analizado. Si llamas a JSON.parse() antes de calcular el HMAC, el digest nunca coincidirá — la serialización JSON puede alterar espacios en blanco y el orden de las claves. Debes calcular el HMAC sobre los bytes exactos que llegaron en el cuerpo de la solicitud. En Express, esto significa configurar express.raw() en tu ruta webhook antes de cualquier otro body parser. En FastAPI, llama a await request.body() antes de cualquier deserialización JSON.

Paso 4: Desarrollo local — exponer localhost a Meta

Meta no puede acceder a http://localhost:3000. Durante el desarrollo, necesitas una URL HTTPS pública que redirija el tráfico a tu servidor local. Dos opciones fiables:

Terminal
opciones de túnel
# Opción A: ngrok (la más común, requiere cuenta gratuita) ngrok http 3000 # → https://a1b2c3d4.ngrok-free.app ← pega esto en Meta Dashboard # Opción B: Cloudflare Tunnel (gratis, sin límite de tiempo, requiere cloudflared) cloudflared tunnel --url http://localhost:3000 # → https://random-words.trycloudflare.com ← pega esto # URL completa de tu webhook para registrar en Meta Dashboard: # https://your-tunnel-url.ngrok-free.app/webhook

Registra la URL del túnel en Meta Developer Dashboard (WhatsApp → Configuration → Webhooks). Cuando reinicias ngrok, se genera una nueva URL — debes actualizar el dashboard cada vez. Cloudflare Tunnel mantiene la URL entre reinicios en túneles con nombre. Cambia a la URL de tu dominio de producción antes de lanzar en vivo.

Paso 5: Registrar el webhook en Meta Developer Dashboard

Con tu endpoint funcionando y accesible públicamente:

  1. Abre developers.facebook.com → tu App → WhatsApp → Configuration
  2. En Webhooks, haz clic en Edit
  3. Pega la URL HTTPS de tu webhook (por ejemplo https://yourdomain.com/webhook)
  4. Introduce tu Verify Token — exactamente la misma cadena que configuraste en VERIFY_TOKEN
  5. Haz clic en Verify and Save — Meta lanza inmediatamente el desafío GET. Tu servidor debe responder en aproximadamente 5 segundos.
  6. Después de guardar, haz clic en Manage junto al webhook y habilita la suscripción al campo messages. Sin esto, Meta no enviará eventos de mensajes entrantes a tu endpoint.

Paso 6: Analizar el payload del webhook de Cloud API

Una vez completada la verificación, cada mensaje entrante del cliente activa un POST hacia tu endpoint. Aquí tienes la estructura completa de un evento de mensaje de texto de Cloud API — y cómo navegarla:

JSON
meta-cloud-api-webhook-payload.json
{ "object": "whatsapp_business_account", "entry": [{ "id": "WHATSAPP_BUSINESS_ACCOUNT_ID", "changes": [{ "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "+44 7700 900 123", // tu número "phone_number_id": "PHONE_NUMBER_ID" }, "contacts": [{ "profile": { "name": "Alice" }, "wa_id": "447700900456" // remitente — sin prefijo + }], "messages": [{ "from": "447700900456", // teléfono del remitente — sin prefijo + "id": "wamid.HBgL...", // ID único del mensaje "timestamp": "1747231892", // cadena Unix, no entero "type": "text", "text": { "body": "Hola, ¿esto está funcionando?" } }] }, "field": "messages" }] }] }

El mensaje que te interesa está oculto en body.entry[0].changes[0].value.messages[0]. Este nivel de anidación toma a todos por sorpresa. Un evento de actualización de estado (confirmación de entrega) llega en el mismo contenedor, pero tiene un array statuses en lugar de messages. Aquí está el patrón de extracción:

Node.js
parseWebhook.js
function parseWebhookEvent(body) { const value = body?.entry?.[0]?.changes?.[0]?.value; if (!value) return null; const phoneNumberId = value.metadata?.phone_number_id; // Mensajes entrantes if (value.messages?.length) { const msg = value.messages[0]; return { kind: 'message', phoneNumberId, // cuál de tus números lo recibió from: msg.from, // remitente — sin prefijo + messageId: msg.id, timestamp: parseInt(msg.timestamp, 10), // convertir string→int type: msg.type, // 'text'|'image'|'audio'|etc raw: msg, // objeto completo del mensaje }; } // Actualizaciones de estado de entrega/lectura if (value.statuses?.length) { const s = value.statuses[0]; return { kind: 'status', messageId: s.id, status: s.status, // 'sent'|'delivered'|'read'|'failed' recipient: s.recipient_id, }; } return null; }

Paso 7: Manejar todos los tipos de mensajes de WhatsApp

El campo type te indica qué propiedad debes leer. Cada tipo tiene una estructura diferente. Aquí tienes el mapa completo:

text
Mensaje de texto estándar del cliente
Leer: msg.text.body
image
Foto enviada por el cliente. Puede incluir una descripción opcional.
Leer: msg.image.id → descargar
audio
Nota de voz o archivo de audio. voice: true si fue grabado en la app.
Leer: msg.audio.id → descargar
document
PDF, Word, Excel u otro archivo. Incluye nombre de archivo.
Leer: msg.document.id, .filename
video
Archivo de video. Puede incluir descripción.
Leer: msg.video.id → descargar
location
Ubicación compartida por el cliente. Incluye lat/lng y nombre opcional.
Leer: msg.location.latitude, .longitude
contacts
Contacto(s) vCard compartidos por el cliente.
Leer: msg.contacts[0].name
reaction
Reacción emoji a uno de tus mensajes.
Leer: msg.reaction.emoji, .message_id
interactive
Pulsación de botón o selección de lista desde un mensaje WhatsApp Flow.
Leer: msg.interactive.button_reply.id
Node.js
handleMessage.js
async function handleMessage(msg) { switch (msg.type) { case 'text': await processText(msg.from, msg.text.body); break; case 'image': case 'audio': case 'video': case 'document': { const mediaId = msg[msg.type].id; const filename = msg[msg.type].filename; // solo en documentos const url = await getMediaUrl(mediaId); await downloadAndStore(url, mediaId, filename); break; } case 'location': await processLocation({ lat: msg.location.latitude, lng: msg.location.longitude, name: msg.location.name, address: msg.location.address, }); break; case 'reaction': await processReaction(msg.reaction.emoji, msg.reaction.message_id); break; case 'interactive': { const type = msg.interactive.type; // 'button_reply' | 'list_reply' const reply = msg.interactive[type]; await processInteractive(type, reply.id, reply.title); break; } default: console.warn(`Tipo de mensaje no manejado: ${msg.type}`); } }

Paso 8: Descargar medios entrantes — la obtención en dos pasos

Cuando un cliente envía una imagen, nota de voz o documento, el payload del webhook no contiene el archivo — contiene un ID de medio. Debes hacer una llamada adicional a la API para resolver el ID en una URL temporal de descarga y luego obtener el archivo real. La URL de descarga expira después de 5 minutos — descarga inmediatamente y almacena el archivo en tu propia infraestructura.

Node.js
downloadMedia.js
const GRAPH_URL = 'https://graph.facebook.com/v21.0'; const ACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN; // Paso 1: resolver ID de medio → URL temporal de descarga async function getMediaUrl(mediaId) { const res = await fetch( `${GRAPH_URL}/${mediaId}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const data = await res.json(); return data.url; // expira en 5 minutos — descarga AHORA } // Paso 2: obtener los bytes reales del archivo async function downloadMedia(mediaUrl) { const res = await fetch(mediaUrl, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } }); return Buffer.from(await res.arrayBuffer()); } // Composición: resolver ID → descargar → almacenar en S3/GCS/tu servidor async function downloadAndStore(mediaId, filename) { const mediaUrl = await getMediaUrl(mediaId); const buffer = await downloadMedia(mediaUrl); await uploadToStorage(buffer, filename ?? mediaId); // guarda el mediaId o la URL de almacenamiento en tu BD para el registro de conversación }

Paso 9: Arquitectura de colas — nunca proceses dentro del manejador del webhook

Este es el patrón de producción que separa una implementación de juguete de una que sobrevive tráfico real. La regla es absoluta: tu manejador de webhook debe devolver 200 en menos de 20 segundos. Si llamas a un LLM, consultas un CRM o descargas medios de forma síncrona dentro del manejador, tarde o temprano tendrás timeouts bajo carga, Meta volverá a intentar la entrega y procesarás eventos duplicados.

El patrón correcto: confirmar inmediatamente, enviar el payload bruto a una cola y procesarlo asíncronamente en un worker. Aquí tienes un patrón mínimo con BullMQ (basado en Redis):

Node.js + BullMQ
queue.js
const { Queue, Worker } = require('bullmq'); const redis = { host: '127.0.0.1', port: 6379 }; const whatsappQueue = new Queue('whatsapp', { connection: redis }); // llamado dentro de tu manejador POST — inmediato y ligero async function enqueue(payload) { await whatsappQueue.add('process-event', payload, { attempts: 3, // reintentar hasta 3 veces si falla el worker backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: 100, // mantener los últimos 100 trabajos completados para depuración removeOnFail: 200, }); } // El worker se ejecuta en un proceso separado const worker = new Worker('whatsapp', async (job) => { const event = parseWebhookEvent(job.data); if (!event) return; if (event.kind === 'message') { await handleMessage(event.raw); // tu lógica de IA / CRM aquí } if (event.kind === 'status') { await updateMessageStatus(event.messageId, event.status); } }, { connection: redis, concurrency: 10 });
Deduplicación: Meta puede entregar el mismo evento más de una vez si tu servidor tarda en confirmar. Guarda los valores procesados de msg.id en Redis con un TTL corto (por ejemplo, 24h). Antes de procesar un trabajo, verifica si msg.id ya está en tu conjunto de deduplicación. Si está, omítelo. Si no, agrégalo y procésalo. Esto evita respuestas dobles de tu agente de IA y registros duplicados en el CRM.

Alternativa: omite todo lo anterior con SocialHook

Todo lo anterior — manejo del desafío de verificación, firma HMAC-SHA256, extracción del payload anidado en entry[0].changes[0].value, resolución de IDs de medios, lógica de reintentos, deduplicación — es trabajo de infraestructura. No es tu producto. Es el andamiaje sobre el que corre tu producto.

SocialHook reemplaza toda esta capa. Conectas tu número de WhatsApp Business a SocialHook, pegas la URL webhook de tu servidor en el panel de SocialHook y SocialHook:

  • Maneja automáticamente el desafío de verificación de Meta
  • Verifica la firma HMAC-SHA256 en cada evento entrante
  • Extrae el mensaje del contenedor anidado de Cloud API
  • Normaliza el payload a un formato consistente entre WhatsApp, Facebook Messenger e Instagram DMs
  • Reintenta la entrega a tu endpoint hasta 3 veces con backoff exponencial si tu servidor devuelve un estado distinto de 200
  • Registra cada intento de entrega con timestamp, código de estado y latencia
  • Entrega a tu endpoint en menos de 50ms

Tu endpoint recibe esto en lugar del payload bruto y anidado de Meta:

JSON
socialhook-normalized-payload.json
{ "platform": "whatsapp", "event": "message.received", "timestamp": 1747231892, // entero — ya procesado "from": "+44 7700 900 456", // E.164 — prefijo + agregado "conversation_id": "conv_8j3k...", "message": { "type": "text", "body": "Hola, ¿esto funciona?", "id": "wamid.HBgL..." }, "signature_verified": true, "delivery": { "attempt": 1, "latency_ms": 41 } }

No hay recorrido anidado. No más parseInt(timestamp). No más ausencia del prefijo + en el número del remitente. No más declaraciones switch específicas de cada plataforma — la misma estructura de payload llega tanto si el cliente te escribió por WhatsApp, Facebook o Instagram. Un solo manejador de webhooks, tres canales. El costo total es una tarifa fija de $50/mes — menos de una hora de tiempo de ingeniería para construir y mantener el equivalente desde cero.

Preguntas comunes

¿Cómo recibo mensajes de WhatsApp en mi servidor?
Necesitas un número de WhatsApp Business en la Cloud API, un endpoint HTTPS accesible públicamente y ese endpoint registrado como tu webhook en el Meta Developer Dashboard. Meta enviará entonces un HTTP POST a tu endpoint con cada mensaje entrante. Tu servidor debe responder con 200 dentro de los 20 segundos. Consulta la configuración completa de 9 pasos arriba.
¿Por qué sigue fallando mi verificación HMAC?
Casi siempre porque estás calculando el HMAC sobre el JSON parseado en lugar de los bytes sin procesar del cuerpo de la solicitud. En Express, debes configurar express.raw() en tu ruta webhook antes de cualquier otro body parser. En FastAPI, llama a await request.body() antes de deserializar. También verifica que estés usando tu App Secret (desde App Settings → Basic) y no tu access token como clave HMAC — son valores distintos.
¿Cómo pruebo mi webhook localmente antes de desplegarlo?
Usa una herramienta de túnel. ngrok (ngrok http 3000) es la más común — genera una URL HTTPS pública que redirige a tu puerto local. Cloudflare Tunnel (cloudflared tunnel --url http://localhost:3000) es una alternativa gratuita sin límites de tiempo de sesión. Pega la URL generada en el campo webhook del Meta Developer Dashboard. Recuerda actualizarla cuando reinicies ngrok, ya que la URL cambia.
Meta sigue reintentando mi webhook — ¿por qué?
Hay tres causas: (1) tu servidor devolvió un estado distinto de 200 — los errores de verificación de firma devuelven 403 y las excepciones de procesamiento devuelven 500. (2) tu servidor tardó más de 20 segundos en responder — mueve el procesamiento a una cola asíncrona y devuelve 200 inmediatamente. (3) tu endpoint estaba caído cuando Meta envió el evento — asegúrate de que tu servidor siempre esté en funcionamiento o usa la entrega administrada de SocialHook con reintentos automáticos.
¿Cuál es la diferencia entre el payload bruto de Meta y el payload normalizado de SocialHook?
La Cloud API de Meta encapsula cada evento en una estructura anidada: body.entry[0].changes[0].value.messages[0]. El timestamp es una cadena, el remitente no tiene el prefijo +, y el formato difiere de los webhooks de Facebook Messenger e Instagram. SocialHook extrae el mensaje, normaliza el remitente al formato E.164, convierte el timestamp a entero y entrega la misma estructura JSON plana en los tres canales de Meta. Escribes un solo parser y funciona para WhatsApp, Facebook e Instagram.
¿Cómo manejo medios entrantes (imágenes, notas de voz, documentos)?
El payload del webhook contiene un media ID, no el archivo en sí. Debes: (1) llamar a GET https://graph.facebook.com/v21.0/{media_id} con tu access token para obtener una URL temporal de descarga, (2) descargar el archivo desde esa URL (también con tu access token en el encabezado Authorization), (3) almacenar el archivo en tu propio servidor o almacenamiento en la nube. La URL temporal expira en aproximadamente 5 minutos — descárgala inmediatamente. Consulta el código completo en Node.js en la sección Media arriba.

Conecta tu número.
Primer webhook en 5 minutos.

Ya has leído la implementación completa. Si prefieres no mantener por tu cuenta la verificación HMAC, la lógica de reintentos y la normalización de payloads — SocialHook se encarga de todo. Pega la URL de tu endpoint. Recibe JSON limpio. Tarifa fija de $50/mes.

No se requiere tarjeta de crédito · $50/mes después del período de prueba · Cancela cuando quieras