Cómo recibir mensajes de WhatsAppen tu servidor —Guía completa 2026
21 de mayo de 2026
·
14 min de lectura
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:
📱
El clienteenvía un mensaje
Red de WhatsApp
☁️
Meta Cloud APIgraph.facebook.com
HTTP POST + firma HMAC
🔗
Tu WebhookEndpoint HTTPS
verificar → cola → 200 OK
⚙️
Tu lógicaIA, CRM, BD
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: '*/*' }));
constVERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN; // tu propia cadenaconstAPP_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 cosaif (!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íncronoconst 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 defverify(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 OKraiseHTTPException(status_code=403)
# POST — eventos entrantes@app.post("/webhook")
async defreceive(request: Request):
raw_body = await request.body()
# 1. Verificar HMAC antes de cualquier otra cosaif notverify_signature(request, raw_body):
raiseHTTPException(status_code=403)
# 2. Confirmar inmediatamente
payload = json.loads(raw_body)
enqueue(payload) # enviar al worker asíncronoreturnResponse(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
functionverifySignature(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() arribaconst expectedSig = crypto
.createHmac('sha256', APP_SECRET)
.update(req.body) // Buffer raw, NO JSON analizado
.digest('hex');
// timingSafeEqual previene ataques de timingtry {
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 };
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:
Abre developers.facebook.com → tu App → WhatsApp → Configuration
En Webhooks, haz clic en Edit
Pega la URL HTTPS de tu webhook (por ejemplo https://yourdomain.com/webhook)
Introduce tu Verify Token — exactamente la misma cadena que configuraste en VERIFY_TOKEN
Haz clic en Verify and Save — Meta lanza inmediatamente el desafío GET. Tu servidor debe responder en aproximadamente 5 segundos.
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
functionparseWebhookEvent(body) {
const value = body?.entry?.[0]?.changes?.[0]?.value;
if (!value) return null;
const phoneNumberId = value.metadata?.phone_number_id;
// Mensajes entrantesif (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/lecturaif (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.
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
constGRAPH_URL = 'https://graph.facebook.com/v21.0';
constACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN;
// Paso 1: resolver ID de medio → URL temporal de descargaasync functiongetMediaUrl(mediaId) {
const res = awaitfetch(
`${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 archivoasync functiondownloadMedia(mediaUrl) {
const res = awaitfetch(mediaUrl, {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }
});
return Buffer.from(await res.arrayBuffer());
}
// Composición: resolver ID → descargar → almacenar en S3/GCS/tu servidorasync functiondownloadAndStore(mediaId, filename) {
const mediaUrl = awaitgetMediaUrl(mediaId);
const buffer = awaitdownloadMedia(mediaUrl);
awaituploadToStorage(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 = newQueue('whatsapp', { connection: redis });
// llamado dentro de tu manejador POST — inmediato y ligeroasync functionenqueue(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 separadoconst worker = newWorker('whatsapp', async (job) => {
const event = parseWebhookEvent(job.data);
if (!event) return;
if (event.kind === 'message') {
awaithandleMessage(event.raw); // tu lógica de IA / CRM aquí
}
if (event.kind === 'status') {
awaitupdateMessageStatus(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
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 frecuentes
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.
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.