← Blog

·

Bot de WhatsApp con Node.js en la API oficial (Express, desde cero)

Actualizado el 28/10/2026

Para crear un bot de WhatsApp con Node.js en la API oficial necesitas un servidor Express con HTTPS y dos rutas: un GET que devuelve el desafío de verificación de Meta y un POST que recibe los mensajes, valida la firma X-Hub-Signature-256 sobre el cuerpo crudo (con express.raw) y responde a través del endpoint /messages de la Cloud API usando el fetch nativo. Sin SDK y sin más dependencias que Express, todo cabe en un archivo de unas 150 líneas.

El bot final responde a «hola» o «menu» con tres botones, procesa el toque en cada botón, marca el mensaje como leído, ignora eventos repetidos y corre en producción con Docker y HTTPS automático. El código se probó con Express 5.3 y Node.js 26, y funciona directo con Meta o a través de Vectalk, cambiando solo dos variables de entorno. Si prefieres Python, el mismo bot existe en versión FastAPI.

Cómo funciona el bot

  1. El cliente envía un mensaje a tu número.
  2. Meta envía un POST con el evento a la URL de tu webhook.
  3. El servidor valida la firma, responde 200 de inmediato y procesa el evento después.
  4. El bot llama a POST /<PHONE_NUMBER_ID>/messages para responder.

Como la respuesta sale justo después del mensaje del cliente, está dentro de la ventana de atención de 24 horas y puede ser texto libre o un mensaje interactivo, sin plantilla. Para una visión general de cómo acceder a la API, mira la guía de WhatsApp Cloud API.

Qué necesitas antes

VariableQué esDónde encontrarla
PHONE_NUMBER_IDID del número en la Cloud APIPanel de apps, sección de WhatsApp
ACCESS_TOKENToken con el permiso whatsapp_business_messagingToken de usuario del sistema
APP_SECRETClave secreta de la app, usada en la firmaConfiguración de la app > Básica
VERIFY_TOKENCualquier texto que elijasLo defines tú y lo repites en el panel

Usa un token permanente de usuario del sistema, porque el token temporal del panel vence. El número también debe estar registrado en la Cloud API, con el PIN de verificación en dos pasos definido.

Necesitas Node.js 18 o superior, que es la versión mínima que exige Express 5 y ya incluye fetch global, sin necesidad de axios.

¿Quieres probar mientras lees? Crea tu cuenta en Vectalk y envía el primer mensaje por la API oficial. Crear cuenta gratis →

1. Prepara el proyecto

mkdir bot-whatsapp && cd bot-whatsapp
npm init -y
npm pkg set type=module
npm install express@5

type=module permite usar import. Crea el archivo server.js. Los bloques de código de las secciones 1 a 5 forman el archivo completo: pégalos en orden.

import crypto from 'node:crypto';
import express from 'express';

const {
  VERIFY_TOKEN,
  ACCESS_TOKEN,
  APP_SECRET = '',
  WEBHOOK_KEY = '',
  PHONE_NUMBER_ID = '',
  PORT = 3000,
} = process.env;
const API_URL =
  process.env.API_URL ?? `https://graph.facebook.com/v26.0/${PHONE_NUMBER_ID}/messages`;

const app = express();
const processed = new Set(); // en producción, usa Redis o tu base de datos

API_URL usa por defecto la Graph API de Meta. Para usar Vectalk, cambias esta variable y el token, como se muestra en la sección sobre Vectalk.

2. Verificación del webhook (GET)

Cuando guardas la URL de callback en el panel, Meta envía un GET con hub.mode=subscribe, hub.verify_token y hub.challenge. Si el token coincide, responde 200 con el valor de hub.challenge. Cualquier otra respuesta deja el endpoint sin verificar y Meta no envía eventos.

app.get('/webhook', (req, res) => {
  const mode = req.query['hub.mode'];
  const token = req.query['hub.verify_token'];
  if (mode === 'subscribe' && token === VERIFY_TOKEN) {
    return res.status(200).send(req.query['hub.challenge']);
  }
  res.sendStatus(403);
});

3. Validar la firma con express.raw

Cada POST de Meta trae X-Hub-Signature-256: sha256=<hash>, un HMAC-SHA256 del cuerpo calculado con la clave secreta de la app. El hash debe calcularse sobre los bytes exactos que llegaron. Si express.json() ya convirtió el cuerpo en objeto, JSON.stringify no devuelve los mismos bytes y la firma nunca coincide.

La solución es usar express.raw solo en la ruta del webhook. Según la documentación de Express, este middleware deja el cuerpo en req.body como Buffer, pero solo cuando el Content-Type coincide con la opción type, cuyo valor por defecto es application/octet-stream. Como Meta envía application/json, hay que indicar type: 'application/json'.

function signatureOk(rawBody, header = '') {
  if (!APP_SECRET) return true;
  const expected =
    'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

crypto.timingSafeEqual compara en tiempo constante, pero lanza un error si los buffers tienen tamaños distintos. Por eso el código revisa el tamaño antes.

Tres errores comunes en este paso:

  • app.use(express.json()) global antes de la ruta. El cuerpo llega ya convertido y el Buffer desaparece. Registra express.json() después de la ruta del webhook o solo en las rutas que lo necesitan.
  • type incorrecto en express.raw. Con el valor por defecto, req.body queda undefined en solicitudes JSON.
  • Clave equivocada. La firma usa la clave secreta de la app, no el token de acceso ni el VERIFY_TOKEN.

El artículo sobre cómo validar la firma del webhook profundiza en el tema.

4. Recibir el evento y responder rápido

Meta espera un 200 rápido. Si la entrega falla, reintenta con frecuencia decreciente durante hasta 7 días, así que el mismo evento puede llegar más de una vez. El código responde antes de procesar e ignora los ID de mensaje ya vistos.

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (WEBHOOK_KEY && req.query.key !== WEBHOOK_KEY) return res.sendStatus(403);
  if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
  if (!signatureOk(req.body, req.get('x-hub-signature-256'))) return res.sendStatus(401);

  const payload = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // responde ya y procesa después
  processPayload(payload).catch((err) => console.error('error al procesar', err));
});

async function processPayload(payload) {
  for (const entry of payload.entry ?? []) {
    for (const change of entry.changes ?? []) {
      for (const msg of change.value?.messages ?? []) {
        if (processed.has(msg.id)) continue; // Meta puede reenviar el mismo evento
        processed.add(msg.id);
        try {
          await handle(msg);
        } catch (err) {
          console.error(`error al responder ${msg.id}`, err.message);
        }
      }
    }
  }
}

res.sendStatus(200) sale antes de processPayload, que corre sin bloquear la respuesta. Los eventos de estado (sent, delivered, read, failed) llegan al mismo webhook en value.statuses, y este bot los ignora. Para tratarlos, mira estado del mensaje vía webhook.

5. Responder texto y botones interactivos

La lógica está en handle. «hola» o «menu» abre el menú; el toque en un botón llega como type: "interactive" con interactive.type: "button_reply" y el id definido al enviar.

const ANSWERS = {
  horario: 'Atendemos de lunes a viernes, de 9 a 18 h.',
  precios: 'Consulta la lista completa en https://example.com/precios',
  humano: '¡Listo! Un agente seguirá la conversación por aquí.',
};

async function handle(msg) {
  const dest = recipientOf(msg);
  await markRead(msg.id);

  if (msg.type === 'text') {
    const text = msg.text.body.trim().toLowerCase();
    if (['hola', 'menu', 'menú'].includes(text)) return sendMenu(dest);
    return sendText(dest, 'No entendí. Escribe *menu* para ver las opciones.');
  }
  if (msg.type === 'interactive' && msg.interactive.type === 'button_reply') {
    const choice = msg.interactive.button_reply.id;
    return sendText(dest, ANSWERS[choice] ?? 'Opción no válida.');
  }
  return sendText(dest, 'Por ahora solo entiendo texto y botones.');
}

function recipientOf(msg) {
  // Usuarios con nombre de usuario pueden llegar sin "from", solo con el BSUID
  return msg.from ? { to: msg.from } : { recipient: msg.from_user_id };
}

La función recipientOf prepara el bot para los nombres de usuario de WhatsApp. Según la documentación de Meta, cuando el cliente adopta un nombre de usuario el campo from puede faltar en el webhook, pero from_user_id (BSUID) llega siempre, y desde julio de 2026 se pueden enviar mensajes a ese identificador en el campo recipient. Detalles en usernames y BSUID.

Y las funciones que llaman a la API con el fetch nativo:

async function callApi(payload) {
  const r = await fetch(API_URL, {
    method: 'POST',
    headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
  });
  const body = await r.json();
  if (!r.ok) throw new Error(`API ${r.status}: ${JSON.stringify(body)}`);
  return body;
}

async function markRead(messageId) {
  try {
    await callApi({ status: 'read', message_id: messageId, typing_indicator: { type: 'text' } });
  } catch {
    // no bloquea la respuesta
  }
}

function sendText(dest, text) {
  return callApi({ recipient_type: 'individual', ...dest, type: 'text', text: { body: text } });
}

function sendMenu(dest) {
  const buttons = [
    ['horario', 'Horario'],
    ['precios', 'Precios'],
    ['humano', 'Hablar con agente'],
  ];
  return callApi({
    recipient_type: 'individual',
    ...dest,
    type: 'interactive',
    interactive: {
      type: 'button',
      body: { text: '¡Hola! ¿Cómo te puedo ayudar?' },
      action: {
        buttons: buttons.map(([id, title]) => ({ type: 'reply', reply: { id, title } })),
      },
    },
  });
}

app.listen(PORT, () => console.log(`Bot escuchando en el puerto ${PORT}`));

markRead marca el mensaje como leído y muestra «escribiendo…»; Meta quita el indicador cuando respondes o después de 25 segundos. Un detalle de fetch: no lanza error en respuestas 4xx y 5xx, por eso el código revisa r.ok y lanza el error con el cuerpo de Meta, que incluye el código para el diagnóstico.

Límites de los botones de respuesta según la documentación de Meta:

CampoLímite
Botones por mensajehasta 3
title del botón20 caracteres
id del botón256 caracteres
body.text1.024 caracteres
footer.text60 caracteres

El toque en el botón llega así al webhook (fragmento de messages[0]):

{
  "from": "5215512345678",
  "id": "wamid.HBgL...",
  "type": "interactive",
  "interactive": {
    "type": "button_reply",
    "button_reply": { "id": "precios", "title": "Precios" }
  }
}

6. Probar en local

export VERIFY_TOKEN="un-texto-cualquiera"
export ACCESS_TOKEN="EAAG..."
export APP_SECRET="tu-clave-secreta"
export PHONE_NUMBER_ID="123456789012345"
node server.js

En otra terminal, prueba la verificación y un evento firmado sin depender de Meta:

curl "http://127.0.0.1:3000/webhook?hub.mode=subscribe&hub.verify_token=un-texto-cualquiera&hub.challenge=42"

BODY='{"entry":[{"changes":[{"value":{"messages":[{"from":"5215512345678","id":"wamid.prueba1","type":"text","text":{"body":"menu"}}]}}]}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $NF}')
curl -i -X POST http://127.0.0.1:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  --data-binary "$BODY"

El primer comando devuelve 42. El segundo devuelve 200 OK y hace que el bot envíe el menú al número de from (usa tu número de prueba). Cambia un carácter de BODY sin recalcular la firma y la respuesta pasa a ser 401.

Para eventos reales, Meta exige una URL pública con certificado TLS válido. En desarrollo, usa un túnel, como muestra la guía para probar el webhook en local con ngrok. Luego ve a Panel de apps > WhatsApp > Configuración, completa la URL de callback (https://.../webhook) y el token de verificación, guarda y suscríbete al campo messages.

7. Despliegue simple con Docker y HTTPS

npm install ya creó el package-lock.json. Crea el Dockerfile:

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js .
ENV PORT=3000
CMD ["node", "server.js"]

Levanta el contenedor en el servidor:

docker build -t bot-whatsapp .
docker run -d --restart unless-stopped -p 127.0.0.1:3000:3000 \
  -e VERIFY_TOKEN -e ACCESS_TOKEN -e APP_SECRET -e PHONE_NUMBER_ID \
  bot-whatsapp

Para el HTTPS, Caddy obtiene y renueva el certificado solo cuando el DNS del dominio apunta al servidor y los puertos 80 y 443 están abiertos. El Caddyfile:

bot.tuempresa.com
reverse_proxy 127.0.0.1:3000

Actualiza la URL de callback en el panel a https://bot.tuempresa.com/webhook.

Checklist antes de pasar a producción

  • Deduplicación persistente. El Set en memoria se pierde en cada reinicio y no se comparte entre instancias. Guarda los ID en Redis o en tu base de datos, con expiración.
  • Cola. Si la respuesta depende de IA o de sistemas lentos, mueve el procesamiento a una cola (BullMQ, RabbitMQ, SQS) con un worker aparte. Un proceso que se cae en medio de processPayload pierde el evento, porque el 200 ya se envió.
  • Una instancia por ahora. Con estado en memoria, no corras en modo clúster. Antes de escalar, saca el estado del proceso.
  • Errores de la API. El código registra el cuerpo de error de Meta; la tabla de errores de la API de WhatsApp ayuda a interpretarlo.
  • Secretos fuera del código. Token y clave secreta solo en variables de entorno o en un gestor de secretos.
  • Multimedia. Imágenes, audios y documentos llegan al webhook solo con un ID; la descarga es una llamada aparte a la API de medios.

Cómo hacerlo con Vectalk

Vectalk es un proxy de la WhatsApp Cloud API oficial de Meta: los payloads de envío y los eventos del webhook son los mismos de Meta, así que server.js funciona sin cambiar la lógica. Cambian tres cosas:

  1. URL y token. Define API_URL=https://apis.vectalk.com.br/api/messages y usa en ACCESS_TOKEN un token de instancia de Vectalk. Con él, el phone_number_id se resuelve automáticamente.
  2. Registro del webhook por API. Registra la URL con POST https://apis.vectalk.com.br/api/webhooks, con instance_id, url y name. Puedes tener varias URL por instancia.
  3. Protección del endpoint. La documentación de webhooks de Vectalk no describe un encabezado de firma. Deja APP_SECRET vacío, define WEBHOOK_KEY con un valor aleatorio largo y registra la URL como https://bot.tuempresa.com/webhook?key=TU_VALOR.
const r = await fetch('https://apis.vectalk.com.br/api/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.VECTALK_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    instance_id: 'TU_INSTANCIA',
    url: 'https://bot.tuempresa.com/webhook?key=TU_VALOR',
    name: 'Bot Node.js',
  }),
});
console.log(await r.json());

Vectalk espera un 200 en menos de 20 segundos y reenvía el evento si no lo recibe, algo que la deduplicación ya cubre. Crea tu cuenta en apps.vectalk.com.br/register y consulta los demás endpoints en la documentación. Para sumar IA a las respuestas, el tutorial para conectar ChatGPT a la API oficial de WhatsApp parte de una base parecida.

Preguntas frecuentes

¿Necesito una librería de WhatsApp para Node.js?

No. La Cloud API es HTTP con JSON: Express para recibir y fetch para enviar son suficientes. Las librerías que automatizan WhatsApp Web, como Baileys, son otra cosa: no usan la API oficial de Meta.

¿Por qué la firma nunca coincide?

Casi siempre es el cuerpo: algún middleware convirtió el JSON antes de express.raw, o el type no es application/json. Revisa también que la clave sea la clave secreta de la app.

¿El bot puede iniciar la conversación?

El texto libre y los botones solo funcionan dentro de la ventana de 24 horas abierta por el último mensaje del cliente. Fuera de ella, hay que enviar una plantilla aprobada.

¿Cuánto cuesta mantener un bot?

Las respuestas dentro de la ventana de atención son mensajes de servicio. Desde el 1 de octubre de 2026, cada número tiene 1.000 mensajes de servicio gratis al mes; a partir del 1.001, la tarifa para destinatarios en Brasil es de R$ 0,0350 (reales brasileños) por mensaje. Las tarifas de otros países están en la página de precios de Meta.

¿Puedo usar TypeScript o Fastify?

Sí. La secuencia no cambia: responder al GET de verificación, validar el HMAC sobre el cuerpo crudo, devolver 200 rápido y llamar al endpoint de mensajes. Con Fastify, asegúrate de tener acceso al cuerpo crudo antes de que se convierta el JSON.


Fuentes oficiales consultadas el 28/10/2026: Create a webhook endpoint (Meta), Webhooks overview (Meta), Interactive reply buttons messages (Meta), Interactive messages webhook (Meta), Typing indicators (Meta), Business-scoped user IDs (Meta), Pricing (Meta), Express 5: express.raw(), Express 5.x API, Caddy: reverse proxy quick-start, Vectalk: webhooks, Vectalk: autenticación, Vectalk: botones de respuesta.

Prueba hoy la API oficial de WhatsApp

Crea tu cuenta, genera el token y envía el primer mensaje con la guía de la documentación. Los mismos payloads y webhooks de la Cloud API de Meta.