{"id":542,"date":"2026-10-28T16:07:10","date_gmt":"2026-10-28T16:07:10","guid":{"rendered":"https:\/\/vectalk.com.br\/blog\/bot-whatsapp-nodejs-express\/"},"modified":"2026-10-28T16:07:20","modified_gmt":"2026-10-28T16:07:20","slug":"bot-whatsapp-nodejs-express","status":"publish","type":"post","link":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/","title":{"rendered":"Bot de WhatsApp con Node.js en la API oficial (Express, desde cero)"},"content":{"rendered":"\n<p><em>Actualizado el 28\/10\/2026<\/em><\/p>\n\n\n\n<p>Para crear un bot de WhatsApp con Node.js en la API oficial necesitas un servidor Express con HTTPS y dos rutas: un <code>GET<\/code> que devuelve el desaf\u00edo de verificaci\u00f3n de Meta y un <code>POST<\/code> que recibe los mensajes, valida la firma <code>X-Hub-Signature-256<\/code> sobre el cuerpo crudo (con <code>express.raw<\/code>) y responde a trav\u00e9s del endpoint <code>\/messages<\/code> de la Cloud API usando el <code>fetch<\/code> nativo. Sin SDK y sin m\u00e1s dependencias que Express, todo cabe en un archivo de unas 150 l\u00edneas.<\/p>\n\n\n\n<p>El bot final responde a &#8220;hola&#8221; o &#8220;menu&#8221; con tres botones, procesa el toque en cada bot\u00f3n, marca el mensaje como le\u00eddo, ignora eventos repetidos y corre en producci\u00f3n con Docker y HTTPS autom\u00e1tico. El c\u00f3digo se prob\u00f3 con Express 5.3 y Node.js 26, y funciona directo con Meta o a trav\u00e9s de Vectalk, cambiando solo dos variables de entorno. Si prefieres Python, el mismo bot existe en <a href=\"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-python-fastapi\/\">versi\u00f3n FastAPI<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">C\u00f3mo funciona el bot<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>El cliente env\u00eda un mensaje a tu n\u00famero.<\/li>\n\n\n<li>Meta env\u00eda un <code>POST<\/code> con el evento a la URL de tu webhook.<\/li>\n\n\n<li>El servidor valida la firma, responde <code>200<\/code> de inmediato y procesa el evento despu\u00e9s.<\/li>\n\n\n<li>El bot llama a <code>POST \/&lt;PHONE_NUMBER_ID&gt;\/messages<\/code> para responder.<\/li>\n\n<\/ol>\n\n\n\n<p>Como la respuesta sale justo despu\u00e9s del mensaje del cliente, est\u00e1 dentro de la ventana de atenci\u00f3n de 24 horas y puede ser texto libre o un mensaje interactivo, sin plantilla. Para una visi\u00f3n general de c\u00f3mo acceder a la API, mira la gu\u00eda de <a href=\"https:\/\/vectalk.com.br\/blog\/es\/whatsapp-cloud-api-es\/\">WhatsApp Cloud API<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Qu\u00e9 necesitas antes<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Variable<\/th><th>Qu\u00e9 es<\/th><th>D\u00f3nde encontrarla<\/th><\/tr><\/thead><tbody><tr><td><code>PHONE_NUMBER_ID<\/code><\/td><td>ID del n\u00famero en la Cloud API<\/td><td>Panel de apps, secci\u00f3n de WhatsApp<\/td><\/tr><tr><td><code>ACCESS_TOKEN<\/code><\/td><td>Token con el permiso <code>whatsapp_business_messaging<\/code><\/td><td>Token de usuario del sistema<\/td><\/tr><tr><td><code>APP_SECRET<\/code><\/td><td>Clave secreta de la app, usada en la firma<\/td><td>Configuraci\u00f3n de la app &gt; B\u00e1sica<\/td><\/tr><tr><td><code>VERIFY_TOKEN<\/code><\/td><td>Cualquier texto que elijas<\/td><td>Lo defines t\u00fa y lo repites en el panel<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>Usa un <a href=\"https:\/\/vectalk.com.br\/blog\/es\/token-permanente-api-whatsapp-es\/\">token permanente de usuario del sistema<\/a>, porque el token temporal del panel vence. El n\u00famero tambi\u00e9n debe estar registrado en la Cloud API, con el PIN de verificaci\u00f3n en dos pasos definido.<\/p>\n\n\n\n<p>Necesitas Node.js 18 o superior, que es la versi\u00f3n m\u00ednima que exige Express 5 y ya incluye <code>fetch<\/code> global, sin necesidad de axios.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. Prepara el proyecto<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">mkdir bot-whatsapp &amp;&amp; cd bot-whatsapp\nnpm init -y\nnpm pkg set type=module\nnpm install express@5<\/code><\/pre>\n\n\n\n<p><code>type=module<\/code> permite usar <code>import<\/code>. Crea el archivo <code>server.js<\/code>. Los bloques de c\u00f3digo de las secciones 1 a 5 forman el archivo completo: p\u00e9galos en orden.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">import crypto from 'node:crypto';\nimport express from 'express';\n\nconst {\n  VERIFY_TOKEN,\n  ACCESS_TOKEN,\n  APP_SECRET = '',\n  WEBHOOK_KEY = '',\n  PHONE_NUMBER_ID = '',\n  PORT = 3000,\n} = process.env;\nconst API_URL =\n  process.env.API_URL ?? `https:\/\/graph.facebook.com\/v26.0\/${PHONE_NUMBER_ID}\/messages`;\n\nconst app = express();\nconst processed = new Set(); \/\/ en producci\u00f3n, usa Redis o tu base de datos<\/code><\/pre>\n\n\n\n<p><code>API_URL<\/code> usa por defecto la Graph API de Meta. Para usar Vectalk, cambias esta variable y el token, como se muestra en la secci\u00f3n sobre Vectalk.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. Verificaci\u00f3n del webhook (GET)<\/h2>\n\n\n\n<p>Cuando guardas la URL de callback en el panel, Meta env\u00eda un <code>GET<\/code> con <code>hub.mode=subscribe<\/code>, <code>hub.verify_token<\/code> y <code>hub.challenge<\/code>. Si el token coincide, responde <code>200<\/code> con el valor de <code>hub.challenge<\/code>. Cualquier otra respuesta deja el endpoint sin verificar y Meta no env\u00eda eventos.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">app.get('\/webhook', (req, res) =&gt; {\n  const mode = req.query['hub.mode'];\n  const token = req.query['hub.verify_token'];\n  if (mode === 'subscribe' &amp;&amp; token === VERIFY_TOKEN) {\n    return res.status(200).send(req.query['hub.challenge']);\n  }\n  res.sendStatus(403);\n});<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">3. Validar la firma con express.raw<\/h2>\n\n\n\n<p>Cada <code>POST<\/code> de Meta trae <code>X-Hub-Signature-256: sha256=&lt;hash&gt;<\/code>, un HMAC-SHA256 del cuerpo calculado con la clave secreta de la app. El hash debe calcularse sobre los bytes exactos que llegaron. Si <code>express.json()<\/code> ya convirti\u00f3 el cuerpo en objeto, <code>JSON.stringify<\/code> no devuelve los mismos bytes y la firma nunca coincide.<\/p>\n\n\n\n<p>La soluci\u00f3n es usar <code>express.raw<\/code> solo en la ruta del webhook. Seg\u00fan la documentaci\u00f3n de Express, este middleware deja el cuerpo en <code>req.body<\/code> como <code>Buffer<\/code>, pero solo cuando el <code>Content-Type<\/code> coincide con la opci\u00f3n <code>type<\/code>, cuyo valor por defecto es <code>application\/octet-stream<\/code>. Como Meta env\u00eda <code>application\/json<\/code>, hay que indicar <code>type: 'application\/json'<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">function signatureOk(rawBody, header = '') {\n  if (!APP_SECRET) return true;\n  const expected =\n    'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');\n  const a = Buffer.from(expected);\n  const b = Buffer.from(header);\n  return a.length === b.length &amp;&amp; crypto.timingSafeEqual(a, b);\n}<\/code><\/pre>\n\n\n\n<p><code>crypto.timingSafeEqual<\/code> compara en tiempo constante, pero lanza un error si los buffers tienen tama\u00f1os distintos. Por eso el c\u00f3digo revisa el tama\u00f1o antes.<\/p>\n\n\n\n<p>Tres errores comunes en este paso:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>app.use(express.json())<\/code> global antes de la ruta.<\/strong> El cuerpo llega ya convertido y el <code>Buffer<\/code> desaparece. Registra <code>express.json()<\/code> despu\u00e9s de la ruta del webhook o solo en las rutas que lo necesitan.<\/li>\n\n\n<li><strong><code>type<\/code> incorrecto en <code>express.raw<\/code>.<\/strong> Con el valor por defecto, <code>req.body<\/code> queda <code>undefined<\/code> en solicitudes JSON.<\/li>\n\n\n<li><strong>Clave equivocada.<\/strong> La firma usa la clave secreta de la app, no el token de acceso ni el <code>VERIFY_TOKEN<\/code>.<\/li>\n\n<\/ul>\n\n\n\n<p>El art\u00edculo sobre <a href=\"https:\/\/vectalk.com.br\/blog\/es\/validar-firma-webhook-whatsapp\/\">c\u00f3mo validar la firma del webhook<\/a> profundiza en el tema.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Recibir el evento y responder r\u00e1pido<\/h2>\n\n\n\n<p>Meta espera un <code>200<\/code> r\u00e1pido. Si la entrega falla, reintenta con frecuencia decreciente durante hasta 7 d\u00edas, as\u00ed que el mismo evento puede llegar m\u00e1s de una vez. El c\u00f3digo responde antes de procesar e ignora los ID de mensaje ya vistos.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">app.post('\/webhook', express.raw({ type: 'application\/json' }), (req, res) =&gt; {\n  if (WEBHOOK_KEY &amp;&amp; req.query.key !== WEBHOOK_KEY) return res.sendStatus(403);\n  if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);\n  if (!signatureOk(req.body, req.get('x-hub-signature-256'))) return res.sendStatus(401);\n\n  const payload = JSON.parse(req.body.toString('utf8'));\n  res.sendStatus(200); \/\/ responde ya y procesa despu\u00e9s\n  processPayload(payload).catch((err) =&gt; console.error('error al procesar', err));\n});\n\nasync function processPayload(payload) {\n  for (const entry of payload.entry ?? []) {\n    for (const change of entry.changes ?? []) {\n      for (const msg of change.value?.messages ?? []) {\n        if (processed.has(msg.id)) continue; \/\/ Meta puede reenviar el mismo evento\n        processed.add(msg.id);\n        try {\n          await handle(msg);\n        } catch (err) {\n          console.error(`error al responder ${msg.id}`, err.message);\n        }\n      }\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p><code>res.sendStatus(200)<\/code> sale antes de <code>processPayload<\/code>, que corre sin bloquear la respuesta. Los eventos de estado (<code>sent<\/code>, <code>delivered<\/code>, <code>read<\/code>, <code>failed<\/code>) llegan al mismo webhook en <code>value.statuses<\/code>, y este bot los ignora. Para tratarlos, mira <a href=\"https:\/\/vectalk.com.br\/blog\/es\/estado-mensaje-webhook-whatsapp\/\">estado del mensaje v\u00eda webhook<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. Responder texto y botones interactivos<\/h2>\n\n\n\n<p>La l\u00f3gica est\u00e1 en <code>handle<\/code>. &#8220;hola&#8221; o &#8220;menu&#8221; abre el men\u00fa; el toque en un bot\u00f3n llega como <code>type: \"interactive\"<\/code> con <code>interactive.type: \"button_reply\"<\/code> y el <code>id<\/code> definido al enviar.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">const ANSWERS = {\n  horario: 'Atendemos de lunes a viernes, de 9 a 18 h.',\n  precios: 'Consulta la lista completa en https:\/\/example.com\/precios',\n  humano: '\u00a1Listo! Un agente seguir\u00e1 la conversaci\u00f3n por aqu\u00ed.',\n};\n\nasync function handle(msg) {\n  const dest = recipientOf(msg);\n  await markRead(msg.id);\n\n  if (msg.type === 'text') {\n    const text = msg.text.body.trim().toLowerCase();\n    if (['hola', 'menu', 'men\u00fa'].includes(text)) return sendMenu(dest);\n    return sendText(dest, 'No entend\u00ed. Escribe *menu* para ver las opciones.');\n  }\n  if (msg.type === 'interactive' &amp;&amp; msg.interactive.type === 'button_reply') {\n    const choice = msg.interactive.button_reply.id;\n    return sendText(dest, ANSWERS[choice] ?? 'Opci\u00f3n no v\u00e1lida.');\n  }\n  return sendText(dest, 'Por ahora solo entiendo texto y botones.');\n}\n\nfunction recipientOf(msg) {\n  \/\/ Usuarios con nombre de usuario pueden llegar sin \"from\", solo con el BSUID\n  return msg.from ? { to: msg.from } : { recipient: msg.from_user_id };\n}<\/code><\/pre>\n\n\n\n<p>La funci\u00f3n <code>recipientOf<\/code> prepara el bot para los nombres de usuario de WhatsApp. Seg\u00fan la documentaci\u00f3n de Meta, cuando el cliente adopta un nombre de usuario el campo <code>from<\/code> puede faltar en el webhook, pero <code>from_user_id<\/code> (BSUID) llega siempre, y desde julio de 2026 se pueden enviar mensajes a ese identificador en el campo <code>recipient<\/code>. Detalles en <a href=\"https:\/\/vectalk.com.br\/blog\/es\/nombres-de-usuario-whatsapp-bsuid\/\">usernames y BSUID<\/a>.<\/p>\n\n\n\n<p>Y las funciones que llaman a la API con el <code>fetch<\/code> nativo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">async function callApi(payload) {\n  const r = await fetch(API_URL, {\n    method: 'POST',\n    headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, 'Content-Type': 'application\/json' },\n    body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),\n  });\n  const body = await r.json();\n  if (!r.ok) throw new Error(`API ${r.status}: ${JSON.stringify(body)}`);\n  return body;\n}\n\nasync function markRead(messageId) {\n  try {\n    await callApi({ status: 'read', message_id: messageId, typing_indicator: { type: 'text' } });\n  } catch {\n    \/\/ no bloquea la respuesta\n  }\n}\n\nfunction sendText(dest, text) {\n  return callApi({ recipient_type: 'individual', ...dest, type: 'text', text: { body: text } });\n}\n\nfunction sendMenu(dest) {\n  const buttons = [\n    ['horario', 'Horario'],\n    ['precios', 'Precios'],\n    ['humano', 'Hablar con agente'],\n  ];\n  return callApi({\n    recipient_type: 'individual',\n    ...dest,\n    type: 'interactive',\n    interactive: {\n      type: 'button',\n      body: { text: '\u00a1Hola! \u00bfC\u00f3mo te puedo ayudar?' },\n      action: {\n        buttons: buttons.map(([id, title]) =&gt; ({ type: 'reply', reply: { id, title } })),\n      },\n    },\n  });\n}\n\napp.listen(PORT, () =&gt; console.log(`Bot escuchando en el puerto ${PORT}`));<\/code><\/pre>\n\n\n\n<p><code>markRead<\/code> marca el mensaje como le\u00eddo y muestra &#8220;escribiendo&#8230;&#8221;; Meta quita el indicador cuando respondes o despu\u00e9s de 25 segundos. Un detalle de <code>fetch<\/code>: no lanza error en respuestas 4xx y 5xx, por eso el c\u00f3digo revisa <code>r.ok<\/code> y lanza el error con el cuerpo de Meta, que incluye el c\u00f3digo para el diagn\u00f3stico.<\/p>\n\n\n\n<p>L\u00edmites de los botones de respuesta seg\u00fan la documentaci\u00f3n de Meta:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Campo<\/th><th>L\u00edmite<\/th><\/tr><\/thead><tbody><tr><td>Botones por mensaje<\/td><td>hasta 3<\/td><\/tr><tr><td><code>title<\/code> del bot\u00f3n<\/td><td>20 caracteres<\/td><\/tr><tr><td><code>id<\/code> del bot\u00f3n<\/td><td>256 caracteres<\/td><\/tr><tr><td><code>body.text<\/code><\/td><td>1.024 caracteres<\/td><\/tr><tr><td><code>footer.text<\/code><\/td><td>60 caracteres<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>El toque en el bot\u00f3n llega as\u00ed al webhook (fragmento de <code>messages[0]<\/code>):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-json\">{\n  \"from\": \"5215512345678\",\n  \"id\": \"wamid.HBgL...\",\n  \"type\": \"interactive\",\n  \"interactive\": {\n    \"type\": \"button_reply\",\n    \"button_reply\": { \"id\": \"precios\", \"title\": \"Precios\" }\n  }\n}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">6. Probar en local<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">export VERIFY_TOKEN=\"un-texto-cualquiera\"\nexport ACCESS_TOKEN=\"EAAG...\"\nexport APP_SECRET=\"tu-clave-secreta\"\nexport PHONE_NUMBER_ID=\"123456789012345\"\nnode server.js<\/code><\/pre>\n\n\n\n<p>En otra terminal, prueba la verificaci\u00f3n y un evento firmado sin depender de Meta:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">curl \"http:\/\/127.0.0.1:3000\/webhook?hub.mode=subscribe&amp;hub.verify_token=un-texto-cualquiera&amp;hub.challenge=42\"\n\nBODY='{\"entry\":[{\"changes\":[{\"value\":{\"messages\":[{\"from\":\"5215512345678\",\"id\":\"wamid.prueba1\",\"type\":\"text\",\"text\":{\"body\":\"menu\"}}]}}]}]}'\nSIG=$(printf '%s' \"$BODY\" | openssl dgst -sha256 -hmac \"$APP_SECRET\" | awk '{print $NF}')\ncurl -i -X POST http:\/\/127.0.0.1:3000\/webhook \\\n  -H \"Content-Type: application\/json\" \\\n  -H \"X-Hub-Signature-256: sha256=$SIG\" \\\n  --data-binary \"$BODY\"<\/code><\/pre>\n\n\n\n<p>El primer comando devuelve <code>42<\/code>. El segundo devuelve <code>200 OK<\/code> y hace que el bot env\u00ede el men\u00fa al n\u00famero de <code>from<\/code> (usa tu n\u00famero de prueba). Cambia un car\u00e1cter de <code>BODY<\/code> sin recalcular la firma y la respuesta pasa a ser <code>401<\/code>.<\/p>\n\n\n\n<p>Para eventos reales, Meta exige una URL p\u00fablica con certificado TLS v\u00e1lido. En desarrollo, usa un t\u00fanel, como muestra la gu\u00eda para <a href=\"https:\/\/vectalk.com.br\/blog\/es\/probar-webhook-whatsapp-localmente\/\">probar el webhook en local con ngrok<\/a>. Luego ve a Panel de apps &gt; WhatsApp &gt; Configuraci\u00f3n, completa la URL de callback (<code>https:\/\/...\/webhook<\/code>) y el token de verificaci\u00f3n, guarda y suscr\u00edbete al campo <code>messages<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">7. Despliegue simple con Docker y HTTPS<\/h2>\n\n\n\n<p><code>npm install<\/code> ya cre\u00f3 el <code>package-lock.json<\/code>. Crea el <code>Dockerfile<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">FROM node:22-slim\nWORKDIR \/app\nCOPY package.json package-lock.json .\/\nRUN npm ci --omit=dev\nCOPY server.js .\nENV PORT=3000\nCMD [\"node\", \"server.js\"]<\/code><\/pre>\n\n\n\n<p>Levanta el contenedor en el servidor:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">docker build -t bot-whatsapp .\ndocker run -d --restart unless-stopped -p 127.0.0.1:3000:3000 \\\n  -e VERIFY_TOKEN -e ACCESS_TOKEN -e APP_SECRET -e PHONE_NUMBER_ID \\\n  bot-whatsapp<\/code><\/pre>\n\n\n\n<p>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\u00e1n abiertos. El <code>Caddyfile<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-bash\">bot.tuempresa.com\nreverse_proxy 127.0.0.1:3000<\/code><\/pre>\n\n\n\n<p>Actualiza la URL de callback en el panel a <code>https:\/\/bot.tuempresa.com\/webhook<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Checklist antes de pasar a producci\u00f3n<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Deduplicaci\u00f3n persistente.<\/strong> El <code>Set<\/code> 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\u00f3n.<\/li>\n\n\n<li><strong>Cola.<\/strong> 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 <code>processPayload<\/code> pierde el evento, porque el <code>200<\/code> ya se envi\u00f3.<\/li>\n\n\n<li><strong>Una instancia por ahora.<\/strong> Con estado en memoria, no corras en modo cl\u00faster. Antes de escalar, saca el estado del proceso.<\/li>\n\n\n<li><strong>Errores de la API.<\/strong> El c\u00f3digo registra el cuerpo de error de Meta; la tabla de <a href=\"https:\/\/vectalk.com.br\/blog\/es\/errores-api-whatsapp\/\">errores de la API de WhatsApp<\/a> ayuda a interpretarlo.<\/li>\n\n\n<li><strong>Secretos fuera del c\u00f3digo.<\/strong> Token y clave secreta solo en variables de entorno o en un gestor de secretos.<\/li>\n\n\n<li><strong>Multimedia.<\/strong> Im\u00e1genes, audios y documentos llegan al webhook solo con un ID; la descarga es una llamada aparte a la API de medios.<\/li>\n\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">C\u00f3mo hacerlo con Vectalk<\/h2>\n\n\n\n<p>Vectalk es un proxy de la WhatsApp Cloud API oficial de Meta: los payloads de env\u00edo y los eventos del webhook son los mismos de Meta, as\u00ed que <code>server.js<\/code> funciona sin cambiar la l\u00f3gica. Cambian tres cosas:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>URL y token.<\/strong> Define <code>API_URL=https:\/\/apis.vectalk.com.br\/api\/messages<\/code> y usa en <code>ACCESS_TOKEN<\/code> un token de instancia de Vectalk. Con \u00e9l, el <code>phone_number_id<\/code> se resuelve autom\u00e1ticamente.<\/li>\n\n\n<li><strong>Registro del webhook por API.<\/strong> Registra la URL con <code>POST https:\/\/apis.vectalk.com.br\/api\/webhooks<\/code>, con <code>instance_id<\/code>, <code>url<\/code> y <code>name<\/code>. Puedes tener varias URL por instancia.<\/li>\n\n\n<li><strong>Protecci\u00f3n del endpoint.<\/strong> La <a href=\"https:\/\/docs.vectalk.com.br\/concepts\/webhooks\">documentaci\u00f3n de webhooks de Vectalk<\/a> no describe un encabezado de firma. Deja <code>APP_SECRET<\/code> vac\u00edo, define <code>WEBHOOK_KEY<\/code> con un valor aleatorio largo y registra la URL como <code>https:\/\/bot.tuempresa.com\/webhook?key=TU_VALOR<\/code>.<\/li>\n\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"language-js\">const r = await fetch('https:\/\/apis.vectalk.com.br\/api\/webhooks', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.VECTALK_TOKEN}`,\n    'Content-Type': 'application\/json',\n  },\n  body: JSON.stringify({\n    instance_id: 'TU_INSTANCIA',\n    url: 'https:\/\/bot.tuempresa.com\/webhook?key=TU_VALOR',\n    name: 'Bot Node.js',\n  }),\n});\nconsole.log(await r.json());<\/code><\/pre>\n\n\n\n<p>Vectalk espera un <code>200<\/code> en menos de 20 segundos y reenv\u00eda el evento si no lo recibe, algo que la deduplicaci\u00f3n ya cubre. Crea tu cuenta en <a href=\"https:\/\/apps.vectalk.com.br\/register\">apps.vectalk.com.br\/register<\/a> y consulta los dem\u00e1s endpoints en la <a href=\"https:\/\/docs.vectalk.com.br\">documentaci\u00f3n<\/a>. Para sumar IA a las respuestas, el tutorial para <a href=\"https:\/\/vectalk.com.br\/blog\/es\/chatgpt-api-oficial-whatsapp-es\/\">conectar ChatGPT a la API oficial de WhatsApp<\/a> parte de una base parecida.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Preguntas frecuentes<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfNecesito una librer\u00eda de WhatsApp para Node.js?<\/h3>\n\n\n\n<p>No. La Cloud API es HTTP con JSON: Express para recibir y <code>fetch<\/code> para enviar son suficientes. Las librer\u00edas que automatizan WhatsApp Web, como Baileys, son otra cosa: no usan la API oficial de Meta.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfPor qu\u00e9 la firma nunca coincide?<\/h3>\n\n\n\n<p>Casi siempre es el cuerpo: alg\u00fan middleware convirti\u00f3 el JSON antes de <code>express.raw<\/code>, o el <code>type<\/code> no es <code>application\/json<\/code>. Revisa tambi\u00e9n que la clave sea la clave secreta de la app.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfEl bot puede iniciar la conversaci\u00f3n?<\/h3>\n\n\n\n<p>El texto libre y los botones solo funcionan dentro de la ventana de 24 horas abierta por el \u00faltimo mensaje del cliente. Fuera de ella, hay que enviar una plantilla aprobada.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfCu\u00e1nto cuesta mantener un bot?<\/h3>\n\n\n\n<p>Las respuestas dentro de la ventana de atenci\u00f3n son mensajes de servicio. Desde el 1 de octubre de 2026, cada n\u00famero 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\u00f1os) por mensaje. Las tarifas de otros pa\u00edses est\u00e1n en la p\u00e1gina de precios de Meta.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfPuedo usar TypeScript o Fastify?<\/h3>\n\n\n\n<p>S\u00ed. La secuencia no cambia: responder al <code>GET<\/code> de verificaci\u00f3n, validar el HMAC sobre el cuerpo crudo, devolver <code>200<\/code> r\u00e1pido y llamar al endpoint de mensajes. Con Fastify, aseg\u00farate de tener acceso al cuerpo crudo antes de que se convierta el JSON.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><strong>Fuentes oficiales consultadas el 28\/10\/2026:<\/strong> <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/webhooks\/create-webhook-endpoint\" target=\"_blank\" rel=\"noreferrer noopener\">Create a webhook endpoint (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/webhooks\/overview\" target=\"_blank\" rel=\"noreferrer noopener\">Webhooks overview (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/messages\/interactive-reply-buttons-messages\" target=\"_blank\" rel=\"noreferrer noopener\">Interactive reply buttons messages (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/webhooks\/reference\/messages\/interactive\" target=\"_blank\" rel=\"noreferrer noopener\">Interactive messages webhook (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/typing-indicators\" target=\"_blank\" rel=\"noreferrer noopener\">Typing indicators (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/business-scoped-user-ids\" target=\"_blank\" rel=\"noreferrer noopener\">Business-scoped user IDs (Meta)<\/a>, <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/pricing\" target=\"_blank\" rel=\"noreferrer noopener\">Pricing (Meta)<\/a>, <a href=\"https:\/\/expressjs.com\/en\/5x\/api\/express\/\" target=\"_blank\" rel=\"noreferrer noopener\">Express 5: express.raw()<\/a>, <a href=\"https:\/\/expressjs.com\/en\/5x\/api.html\" target=\"_blank\" rel=\"noreferrer noopener\">Express 5.x API<\/a>, <a href=\"https:\/\/caddyserver.com\/docs\/quick-starts\/reverse-proxy\" target=\"_blank\" rel=\"noreferrer noopener\">Caddy: reverse proxy quick-start<\/a>, <a href=\"https:\/\/docs.vectalk.com.br\/concepts\/webhooks\">Vectalk: webhooks<\/a>, <a href=\"https:\/\/docs.vectalk.com.br\/authentication\">Vectalk: autenticaci\u00f3n<\/a>, <a href=\"https:\/\/docs.vectalk.com.br\/api-reference\/messages\/send-buttons\">Vectalk: botones de respuesta<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Tutorial completo para crear un bot de WhatsApp en Node.js con Express sobre la API oficial: webhook, firma con express.raw, botones y despliegue con HTTPS.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[12],"tags":[],"class_list":["post-542","post","type-post","status-publish","format-standard","hentry","category-uncategorized-es"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.7 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Bot de WhatsApp con Node.js y Express en la API | Vectalk<\/title>\n<meta name=\"description\" content=\"Tutorial de API de WhatsApp Node JS con Express: verificaci\u00f3n del webhook, firma con express.raw, fetch nativo, botones interactivos y despliegue.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Bot de WhatsApp con Node.js y Express en la API | Vectalk\" \/>\n<meta property=\"og:description\" content=\"Tutorial de API de WhatsApp Node JS con Express: verificaci\u00f3n del webhook, firma con express.raw, fetch nativo, botones interactivos y despliegue.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/\" \/>\n<meta property=\"og:site_name\" content=\"Blog Vectalk\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-28T16:07:10+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-10-28T16:07:20+00:00\" \/>\n<meta name=\"author\" content=\"wanderleyfcunha\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"wanderleyfcunha\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"13 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/\"},\"author\":{\"name\":\"wanderleyfcunha\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#\\\/schema\\\/person\\\/ff36ecd9d8257856f227f8e5d639db2b\"},\"headline\":\"Bot de WhatsApp con Node.js en la API oficial (Express, desde cero)\",\"datePublished\":\"2026-10-28T16:07:10+00:00\",\"dateModified\":\"2026-10-28T16:07:20+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/\"},\"wordCount\":1766,\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/\",\"url\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/\",\"name\":\"Bot de WhatsApp con Node.js y Express en la API | Vectalk\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#website\"},\"datePublished\":\"2026-10-28T16:07:10+00:00\",\"dateModified\":\"2026-10-28T16:07:20+00:00\",\"author\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#\\\/schema\\\/person\\\/ff36ecd9d8257856f227f8e5d639db2b\"},\"description\":\"Tutorial de API de WhatsApp Node JS con Express: verificaci\u00f3n del webhook, firma con express.raw, fetch nativo, botones interactivos y despliegue.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/es\\\/bot-whatsapp-nodejs-express\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Bot de WhatsApp con Node.js en la API oficial (Express, desde cero)\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/\",\"name\":\"Blog Vectalk\",\"description\":\"\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/vectalk.com.br\\\/blog\\\/#\\\/schema\\\/person\\\/ff36ecd9d8257856f227f8e5d639db2b\",\"name\":\"wanderleyfcunha\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g\",\"caption\":\"wanderleyfcunha\"},\"sameAs\":[\"https:\\\/\\\/vectalk.com.br\\\/blog\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Bot de WhatsApp con Node.js y Express en la API | Vectalk","description":"Tutorial de API de WhatsApp Node JS con Express: verificaci\u00f3n del webhook, firma con express.raw, fetch nativo, botones interactivos y despliegue.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/","og_locale":"en_US","og_type":"article","og_title":"Bot de WhatsApp con Node.js y Express en la API | Vectalk","og_description":"Tutorial de API de WhatsApp Node JS con Express: verificaci\u00f3n del webhook, firma con express.raw, fetch nativo, botones interactivos y despliegue.","og_url":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/","og_site_name":"Blog Vectalk","article_published_time":"2026-10-28T16:07:10+00:00","article_modified_time":"2026-10-28T16:07:20+00:00","author":"wanderleyfcunha","twitter_card":"summary_large_image","twitter_misc":{"Written by":"wanderleyfcunha","Est. reading time":"13 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/#article","isPartOf":{"@id":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/"},"author":{"name":"wanderleyfcunha","@id":"https:\/\/vectalk.com.br\/blog\/#\/schema\/person\/ff36ecd9d8257856f227f8e5d639db2b"},"headline":"Bot de WhatsApp con Node.js en la API oficial (Express, desde cero)","datePublished":"2026-10-28T16:07:10+00:00","dateModified":"2026-10-28T16:07:20+00:00","mainEntityOfPage":{"@id":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/"},"wordCount":1766,"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/","url":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/","name":"Bot de WhatsApp con Node.js y Express en la API | Vectalk","isPartOf":{"@id":"https:\/\/vectalk.com.br\/blog\/#website"},"datePublished":"2026-10-28T16:07:10+00:00","dateModified":"2026-10-28T16:07:20+00:00","author":{"@id":"https:\/\/vectalk.com.br\/blog\/#\/schema\/person\/ff36ecd9d8257856f227f8e5d639db2b"},"description":"Tutorial de API de WhatsApp Node JS con Express: verificaci\u00f3n del webhook, firma con express.raw, fetch nativo, botones interactivos y despliegue.","breadcrumb":{"@id":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/vectalk.com.br\/blog\/es\/bot-whatsapp-nodejs-express\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/vectalk.com.br\/blog\/"},{"@type":"ListItem","position":2,"name":"Bot de WhatsApp con Node.js en la API oficial (Express, desde cero)"}]},{"@type":"WebSite","@id":"https:\/\/vectalk.com.br\/blog\/#website","url":"https:\/\/vectalk.com.br\/blog\/","name":"Blog Vectalk","description":"","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/vectalk.com.br\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Person","@id":"https:\/\/vectalk.com.br\/blog\/#\/schema\/person\/ff36ecd9d8257856f227f8e5d639db2b","name":"wanderleyfcunha","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/21c37077f1731eb558973f27a7b647d55f72fde5200a843ac5f5345b247ea725?s=96&d=mm&r=g","caption":"wanderleyfcunha"},"sameAs":["https:\/\/vectalk.com.br\/blog"]}]}},"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"","_links":{"self":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts\/542","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/comments?post=542"}],"version-history":[{"count":1,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts\/542\/revisions"}],"predecessor-version":[{"id":545,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/posts\/542\/revisions\/545"}],"wp:attachment":[{"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/media?parent=542"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/categories?post=542"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vectalk.com.br\/blog\/wp-json\/wp\/v2\/tags?post=542"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}