Tutorial: webhook de WhatsApp en Node.js (código completo)
Te muestro desde cero cómo recibir mensajes de WhatsApp vía Cloud API de Meta usando Node.js + Express, validar la firma HMAC para seguridad y procesar diferentes tipos de mensaje (texto, audio, imagen, estado). Código probado en producción, listo para copiar.
Requisitos previos
- Node.js 18+ instalado.
- Cuenta Meta Business con WABA configurada.
- App creada en
developers.facebook.comcon el producto WhatsApp agregado. - Tienes a mano:
WHATSAPP_TOKEN(System User),APP_SECRET,PHONE_NUMBER_ID,VERIFY_TOKEN(lo creas tú). - Servidor con HTTPS público (usaremos Render en el ejemplo — funciona en Railway, Fly.io, AWS, etc.).
Paso 1 — Setup del proyecto
mkdir wa-webhook && cd wa-webhook npm init -y npm install express # crypto viene con Node core, no hay que instalar touch server.js .env
Archivo .env:
WHATSAPP_TOKEN=EAAxxxx... APP_SECRET=abc123... PHONE_NUMBER_ID=109876543210987 VERIFY_TOKEN=mi_token_secreto_aleatorio_xyz PORT=3000
Paso 2 — Endpoint de verificación (GET)
Cuando registras el webhook en Business Manager, Meta envía un GET con hub.challenge. Tu endpoint necesita devolver exactamente ese valor, probando que eres "dueño" del endpoint.
// server.js
import express from 'express';
import crypto from 'crypto';
const app = express();
// IMPORTANTE: necesitamos el raw body para validar la firma después
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
const { WHATSAPP_TOKEN, APP_SECRET, PHONE_NUMBER_ID, VERIFY_TOKEN, PORT = 3000 } = process.env;
// Verificación inicial del webhook
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');
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
Paso 3 — Endpoint de recepción (POST)
Aquí llegan los eventos: mensaje nuevo, estado de envío, cambio de perfil, etc. Responde siempre 200 rápido (en <5 segundos), si no Meta reintenta y puede banear tu webhook.
// Recepción de eventos
app.post('/webhook', (req, res) => {
// 1) Validar firma antes de cualquier cosa
if (!verificarFirma(req)) {
console.warn('Firma inválida — rechazando');
return res.sendStatus(401);
}
// 2) Responder 200 ya (procesar async)
res.sendStatus(200);
// 3) Procesar evento
procesarEvento(req.body).catch(err => console.error('Error procesando:', err));
});
function verificarFirma(req) {
const sig = req.get('x-hub-signature-256');
if (!sig) return false;
const esperado = 'sha256=' + crypto
.createHmac('sha256', APP_SECRET)
.update(req.rawBody)
.digest('hex');
// crypto.timingSafeEqual evita timing attack
try {
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(esperado));
} catch { return false; }
}
Paso 4 — Procesar el mensaje
El payload de Meta tiene estructura anidada. Aquí está el parser para los 4 tipos más comunes:
async function procesarEvento(body) {
// Payload: { object, entry: [{ changes: [{ value: { ... } }] }] }
const entry = body.entry?.[0];
const change = entry?.changes?.[0]?.value;
if (!change) return;
// a) Mensaje recibido
const msgs = change.messages || [];
for (const msg of msgs) {
const from = msg.from; // número del remitente
const id = msg.id;
if (msg.type === 'text') {
const texto = msg.text.body;
console.log(`📩 ${from}: ${texto}`);
await responder(from, `Recibí: "${texto}"`);
}
else if (msg.type === 'image' || msg.type === 'audio' || msg.type === 'video' || msg.type === 'document') {
const mediaId = msg[msg.type].id;
console.log(`📷 ${from} envió ${msg.type} id=${mediaId}`);
// Para descargar el archivo: GET https://graph.facebook.com/v21.0/{mediaId}
// (con Bearer token) → devuelve URL con TTL corto para descarga
}
else if (msg.type === 'button' || msg.type === 'interactive') {
// Botón de plantilla o lista interactiva clickeado
console.log(`🔘 ${from} clickeó un botón`);
}
}
// b) Estado de envío (delivered, read, failed)
const statuses = change.statuses || [];
for (const st of statuses) {
console.log(`✓ status msg=${st.id} status=${st.status}`);
}
}
Paso 5 — Enviar mensaje de vuelta
async function responder(to, texto) {
const url = `https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`;
const body = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: { body: texto, preview_url: false }
};
const r = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${WHATSAPP_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!r.ok) {
console.error('Error enviando:', r.status, await r.text());
}
}
app.listen(PORT, () => console.log(`🟢 Webhook escuchando en el puerto ${PORT}`));
Paso 6 — Deploy
Render es el más simple para probar (free tier alcanza). Alternativas: Railway, Fly.io, AWS Lambda.
- Crea un repo en GitHub con el código.
- En
render.com: New → Web Service → conecta al repo. - Build command:
npm install. Start command:node server.js. - Agrega las variables de entorno (WHATSAPP_TOKEN, APP_SECRET, etc.).
- Deploy. Toma la URL pública (ej.:
https://wa-webhook.onrender.com).
Paso 7 — Registrar el webhook en Business Manager
developers.facebook.com→ tu app → WhatsApp → Configuration.- En "Webhook", haz clic en Configure.
- Callback URL:
https://wa-webhook.onrender.com/webhook. - Verify Token: el mismo valor de
VERIFY_TOKENde tu .env. - Haz clic en Verify and Save. Meta hace GET → tu endpoint responde el challenge → registrado.
- En "Webhook fields", haz clic en Manage y activa al menos
messagesymessage_template_status_update.
Probando
- Envía un mensaje desde tu celular personal al número WhatsApp de la empresa.
- Los logs del servidor deben mostrar:
📩 5491...: hola test. - Deberías recibir de vuelta:
Recibí: "hola test".
Errores comunes
- 401 en webhook validation: VERIFY_TOKEN en .env distinto al registrado en BM.
- Firma inválida: olvidaste configurar
verify: (req, _res, buf) => { req.rawBody = buf }en express.json. - Timeout 5s: procesaste sincrónico. Responde siempre
res.sendStatus(200)inmediatamente y procesa async. - El mensaje llega pero no se envía: token expirado (el token System User por defecto expira en 60 días si no es permanente). Genera un token permanente en Business Settings → Users → System Users.
- Error 470 "outside 24h window": intentaste enviar texto libre a un contacto que no respondió en las últimas 24h. Usa una plantilla HSM.
Cuándo vale la pena hacerlo desde cero (y cuándo no)
Construir desde cero tiene sentido si eres dev construyendo un SaaS con WhatsApp embebido en el producto (ej.: app de delivery, fintech enviando notificaciones). Necesitas control total del código, integración con microservicios internos, deploy propio.
No tiene sentido si solo quieres atender clientes en el WhatsApp de la empresa — gastar 2-4 semanas implementando panel multi-agente, bot IA, broadcast HSM y plantillas desde cero es trabajo perdido. Una plataforma como MercaBot lo resuelve en 3 minutos por $39/mes.
Atención WhatsApp sin código
Si el objetivo es atender al cliente (no construir producto), saltea la parte del webhook. MercaBot conecta en 3 minutos con panel listo + bot IA + broadcast.
Probar gratis →