Actualizado el 29/10/2026
Un webhook de la API de WhatsApp es una URL HTTPS de tu sistema que recibe, por POST, cada evento de la cuenta: mensajes recibidos, cambios de estado (sent, delivered, read, failed) y alertas de la cuenta. Un endpoint bien hecho valida el origen de la solicitud, responde 200 en pocos segundos, pone el evento en una cola, lo procesa en segundo plano y descarta los duplicados por ID. En la integración directa con Meta también hay un paso de verificación por GET; con Vectalk, la URL se registra por API y recibe el mismo payload que envía Meta.
Esta guía muestra el formato de los eventos, las diferencias entre integrar directo y a través de Vectalk, y un servidor completo en Node.js y en Python.
Cómo funciona el flujo de eventos
En la integración directa, Meta llama al endpoint configurado en tu app. Con Vectalk, Meta llama a Vectalk, que reenvía el evento a todas las URLs activas registradas para esa instancia:
Directo: Meta ──POST──▶ tu endpoint
Vectalk: Meta ──POST──▶ Vectalk ──POST──▶ tus URLs (una o varias)
El cuerpo es el mismo en los dos casos. Lo que cambia es cómo se registra la URL y cómo se autentica la solicitud.
| Aspecto | Cloud API directa | Vectalk |
|---|---|---|
| Registro de la URL | Panel de apps > WhatsApp > Configuración | POST /api/webhooks con instance_id, url y name |
| Verificación del endpoint | GET con hub.mode, hub.verify_token y hub.challenge | No descrita en la documentación de Vectalk |
| Firma | Header X-Hub-Signature-256 (HMAC-SHA256 con el secreto de la app) | No descrita en la documentación de Vectalk |
| URLs por cuenta | Un endpoint por app (con reemplazos por cuenta de mensajes o por número) | Varias URLs por instancia, todas reciben el mismo evento |
| Tiempo de respuesta | Meta reintenta si no recibe 200 | Responder 200 en menos de 20 segundos; si no, Vectalk reenvía |
| Reintentos | Frecuencia decreciente durante hasta 7 días | Reenvío documentado, sin plazo informado |
Fuentes: descripción general de webhooks de WhatsApp, crear un endpoint de webhook y webhooks en la documentación de Vectalk.
Cómo registrar el webhook en Vectalk
curl -X POST https://apis.vectalk.com.br/api/webhooks \
-H "Authorization: Bearer TU_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"instance_id": "TU_INSTANCE_ID",
"url": "https://miapp.com/whatsapp/eventos",
"name": "Producción"
}'
La respuesta trae el id del webhook y active: true. El endpoint de actualización recibe ese id y te permite cambiar la URL, el nombre o desactivar el webhook (active); consulta Registrar webhook y Actualizar webhook. La URL tiene que ser HTTPS.
Tener varias URLs por instancia sirve en dos casos: separar producción y pruebas, y enviar el mismo evento a dos sistemas (el CRM y el data warehouse, por ejemplo) sin armar un reenvío propio.
¿Quieres probar mientras lees? Crea tu cuenta en Vectalk y envía el primer mensaje por la API oficial. Crear cuenta gratis →
Verificación del endpoint (integración directa)
Cuando configuras el webhook en el panel de apps de Meta (en WhatsApp > Configuración, o en Casos de uso > Personalizar > Configuración si la app se creó con el caso de uso de WhatsApp), Meta hace un GET a tu URL con tres parámetros:
GET https://miapp.com/whatsapp/eventos?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=mi-token-secreto
El endpoint debe comprobar que hub.verify_token sea igual al valor que definiste en el panel y devolver hub.challenge en el cuerpo, con estado 200. Cualquier otra respuesta hace fallar la verificación. Meta también exige un certificado TLS válido en el endpoint; no acepta certificados autofirmados.
Los payloads que vas a recibir
Todo evento llega con object: "whatsapp_business_account" y una lista entry. Cada entry tiene una lista changes, y cada change tiene field y value. Para mensajes y estados, field es messages. Otros campos suscribibles son, por ejemplo, account_alerts, account_update, message_template_status_update, phone_number_quality_update y smb_message_echoes.
Mensaje recibido
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "558430000000",
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [{
"profile": { "name": "João Silva" },
"wa_id": "5584988888888",
"user_id": "BR.13491208655302741918"
}],
"messages": [{
"from": "5584988888888",
"from_user_id": "BR.13491208655302741918",
"id": "wamid.HBgLNTUx...",
"timestamp": "1760000000",
"type": "text",
"text": { "body": "Hola, quiero más información" }
}]
}
}]
}]
}
Desde abril de 2026, Meta también envía el BSUID (ID de usuario con alcance de empresa) en contacts[].user_id y messages[].from_user_id. El número de teléfono ya no está garantizado: si el cliente activó un nombre de usuario de WhatsApp y no se cumple ninguna de las condiciones de Meta (un mensaje o llamada intercambiados con ese número de la empresa en los últimos 30 días, o que el cliente esté en tu libreta de contactos), from y wa_id se omiten. No uses el teléfono como única clave del contacto; los detalles están en nombres de usuario de WhatsApp y BSUID.
El campo type indica dónde está el contenido. Los más comunes:
type | Dónde leer |
|---|---|
text | text.body |
image, video, audio, document, sticker | <tipo>.id (descarga por el endpoint de multimedia), caption, filename |
interactive | interactive.button_reply o interactive.list_reply |
location | location.latitude, location.longitude |
reaction | reaction.message_id, reaction.emoji |
order | order.catalog_id, order.product_items[] |
Estado de un mensaje enviado
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "558430000000", "phone_number_id": "PHONE_NUMBER_ID" },
"statuses": [{
"id": "wamid.HBgLNTUx...",
"status": "delivered",
"timestamp": "1760000010",
"recipient_id": "5584988888888",
"recipient_user_id": "BR.13491208655302741918",
"pricing": { "billable": true, "pricing_model": "PMP", "type": "regular", "category": "utility" }
}]
}
}]
}]
}
El id del estado es el wamid que recibiste al enviar el mensaje. El campo recipient_user_id trae el BSUID del cliente, tanto si enviaste el mensaje al número como al BSUID; recipient_id (el teléfono) se omite cuando enviaste al BSUID y Meta no puede incluir el número. Además de sent, delivered, read y failed, existe played para mensajes de voz. Cuando status es failed, el objeto trae errors[] con code, title, message y error_data.details.
El objeto pricing trae category (marketing, utility, authentication, service, entre otras) y type: regular para mensajes cobrables, o un tipo gratuito como free_customer_service o free_entry_point. Desde el 1 de octubre de 2026, las plantillas de utilidad enviadas dentro de la ventana de atención se cobran, así que llegan como regular en lugar de free_customer_service. Meta anunció que el campo billable quedará obsoleto en favor de type y category. pricing llega con el estado sent y con uno de delivered o read, nunca con failed. Referencia completa: documentación del webhook de estado. Para saber cuánto cuesta cada categoría, consulta el precio de la API de WhatsApp en Brasil y, para otros mercados, el precio de la API de WhatsApp por país. El manejo de estados está explicado a fondo en estados de mensaje por webhook en WhatsApp.
Un mismo POST puede traer varios mensajes o varios estados: según la documentación de webhooks de Meta, las notificaciones se agrupan con hasta 1.000 actualizaciones por solicitud, aunque el agrupamiento no está garantizado. Recorre las listas en lugar de leer solo el primer elemento.
Buenas prácticas
Responde 200 rápido y procesa después
El endpoint no debe llamar a tu base de datos, a la IA ni a otra API antes de responder. Guarda el cuerpo crudo en una cola (Redis, SQS, RabbitMQ, una tabla de eventos) y devuelve 200. Un worker aparte hace el trabajo pesado. Vectalk documenta un límite de 20 segundos para la respuesta; mantente muy por debajo.
Sé idempotente
Meta advierte que los reintentos pueden duplicar notificaciones, y un proxy también puede reenviar. Usa el id del mensaje (o la combinación id + status, en el caso de los estados) como clave única. Si la clave ya se procesó, descarta el evento.
Valida la firma
En la integración directa, Meta firma cada POST con un HMAC-SHA256 del cuerpo, usando el secreto de tu app, y envía el resultado en el header X-Hub-Signature-256 con el formato sha256=<hash>. Calcula el hash sobre el cuerpo crudo, antes de cualquier parseo de JSON, y compara en tiempo constante; el paso a paso está en cómo validar la firma del webhook de WhatsApp. Los webhooks también admiten TLS mutuo (mTLS). No dependas solo de una lista de IPs permitidas: Meta cambia sus direcciones IP periódicamente y recomienda mTLS en su lugar.
Con Vectalk, usa el mecanismo de autenticación que Vectalk indique para las solicitudes reenviadas (consulta la documentación o la página de contacto). Mientras eso no esté configurado en tu entorno, usa una ruta de URL difícil de adivinar, limita el endpoint a HTTPS y no confíes en el contenido para acciones sensibles sin verificar el phone_number_id esperado.
No dependas del orden
Por los reintentos, un estado antiguo puede llegar después de uno más nuevo, y cuando un mensaje se lee enseguida Meta envía read sin webhook de delivered. Guarda el estado más avanzado y el timestamp de cada etapa, sin retroceder nunca.
Prepárate para payloads grandes
Meta informa que los payloads de webhook pueden tener hasta 3 MB. Ajusta el límite de cuerpo de tu servidor (el predeterminado de Express es 100 kb).
Monitorea
Registra cuántos eventos llegan por minuto y cuántos fallan en el worker. Una caída brusca de eventos de mensajes suele ser la primera señal de un webhook desactivado, un certificado vencido o un número desconectado.
Ejemplo completo en Node.js (Express)
import express from 'express'
import crypto from 'node:crypto'
import { createClient } from 'redis'
const app = express()
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN // integración directa
const APP_SECRET = process.env.META_APP_SECRET // integración directa
// Guarda el cuerpo crudo para validar la firma
app.use('/whatsapp/eventos', express.raw({ type: 'application/json', limit: '3mb' }))
// Verificación del endpoint (solo en la integración directa con Meta)
app.get('/whatsapp/eventos', (req, res) => {
const { 'hub.mode': mode, 'hub.verify_token': token, 'hub.challenge': challenge } = req.query
if (mode === 'subscribe' && token === VERIFY_TOKEN) return res.status(200).send(challenge)
return res.sendStatus(403)
})
function firmaValida(req) {
const header = req.get('X-Hub-Signature-256') || ''
const esperado = 'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(req.body).digest('hex')
const a = Buffer.from(header)
const b = Buffer.from(esperado)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post('/whatsapp/eventos', async (req, res) => {
if (APP_SECRET && !firmaValida(req)) return res.sendStatus(401)
await redis.lPush('whatsapp:eventos', req.body.toString('utf8'))
res.sendStatus(200)
})
app.listen(3000)
Y el worker, que consume la cola y descarta duplicados:
// usa un cliente Redis conectado igual que arriba
async function procesar(evento) {
for (const entry of evento.entry ?? []) {
for (const change of entry.changes ?? []) {
const v = change.value ?? {}
for (const msg of v.messages ?? []) {
const nuevo = await redis.set(`wa:msg:${msg.id}`, '1', { NX: true, EX: 60 * 60 * 24 * 8 })
if (!nuevo) continue // duplicado
await tratarMensaje(v.metadata.phone_number_id, msg, v.contacts) // tu lógica de negocio
}
for (const st of v.statuses ?? []) {
const nuevo = await redis.set(`wa:st:${st.id}:${st.status}`, '1', { NX: true, EX: 60 * 60 * 24 * 8 })
if (!nuevo) continue
await actualizarEstado(st) // guarda estado, errores y pricing
}
}
}
}
while (true) {
const item = await redis.brPop('whatsapp:eventos', 0)
try {
await procesar(JSON.parse(item.element))
} catch (err) {
console.error('error al procesar evento', err)
await redis.lPush('whatsapp:eventos:error', item.element)
}
}
La expiración de ocho días en las claves cubre la ventana de reintentos de Meta, que llega a siete días.
Ejemplo completo en Python (FastAPI)
import hashlib, hmac, json, os
import redis.asyncio as redis
from fastapi import FastAPI, Request, Response
app = FastAPI()
r = redis.from_url(os.environ["REDIS_URL"])
VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN") # integración directa
APP_SECRET = os.environ.get("META_APP_SECRET", "") # integración directa
@app.get("/whatsapp/eventos")
async def verificar(request: Request):
q = request.query_params
if q.get("hub.mode") == "subscribe" and q.get("hub.verify_token") == VERIFY_TOKEN:
return Response(content=q.get("hub.challenge", ""), media_type="text/plain")
return Response(status_code=403)
def firma_valida(cuerpo: bytes, header: str) -> bool:
esperado = "sha256=" + hmac.new(APP_SECRET.encode(), cuerpo, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, header or "")
@app.post("/whatsapp/eventos")
async def recibir(request: Request):
cuerpo = await request.body()
if APP_SECRET and not firma_valida(cuerpo, request.headers.get("X-Hub-Signature-256")):
return Response(status_code=401)
await r.lpush("whatsapp:eventos", cuerpo)
return Response(status_code=200)
Worker en Python:
import asyncio, json, os
import redis.asyncio as redis
r = redis.from_url(os.environ["REDIS_URL"])
OCHO_DIAS = 60 * 60 * 24 * 8
async def procesar(evento: dict):
for entry in evento.get("entry", []):
for change in entry.get("changes", []):
v = change.get("value", {})
for msg in v.get("messages", []):
if not await r.set(f"wa:msg:{msg['id']}", 1, nx=True, ex=OCHO_DIAS):
continue
await tratar_mensaje(v["metadata"]["phone_number_id"], msg, v.get("contacts", []))
for st in v.get("statuses", []):
if not await r.set(f"wa:st:{st['id']}:{st['status']}", 1, nx=True, ex=OCHO_DIAS):
continue
await actualizar_estado(st)
async def main():
while True:
_, item = await r.brpop("whatsapp:eventos")
try:
await procesar(json.loads(item))
except Exception as exc:
print("error al procesar evento", exc)
await r.lpush("whatsapp:eventos:error", item)
asyncio.run(main())
Las funciones tratar_mensaje y actualizar_estado (y sus equivalentes en Node) son tu lógica de negocio: responder, guardar en el CRM, activar un agente. Dentro de ellas, identifica al contacto por msg.from_user_id (o contacts[].user_id) y usa msg.from solo cuando esté presente.
Qué hacer con cada tipo de evento en el worker
Separar el tratamiento por tipo de evento hace que el worker sea más fácil de mantener. Una división que funciona bien:
- Mensajes recibidos: guarda el mensaje, actualiza la hora del último mensaje del contacto (es lo que indica si la ventana de 24 horas está abierta) y recién entonces activa el chatbot, el agente o la cola de atención humana.
- Estados
sent,deliveredyread: actualiza el registro del mensaje enviado por elwamid, guardando la hora de cada etapa. Estos datos alimentan los informes de entrega y lectura. - Estado
failed: guarda el código de error y decide la acción. Algunos errores piden un nuevo intento más tarde; otros indican que el envío no va a funcionar sin un cambio, como una plantilla fuera de la ventana o un número inválido. No reenvíes en bucle. - Otros campos (
account_update,account_alerts, cambios de estado de plantillas): envíalos a un canal de alertas del equipo técnico, porque suelen requerir acción humana.
Mantén también una cola de errores, como en los ejemplos. Un evento que falló al procesarse no debe desaparecer: tiene que quedar disponible para reprocesarlo cuando se corrija el bug.
Pruebas en local
Para recibir eventos reales en tu computadora, expón el puerto local con un túnel HTTPS (ngrok, Cloudflare Tunnel o similar) y registra la URL del túnel como un webhook adicional de la instancia, con un nombre como «dev». Como Vectalk entrega cada evento a todas las URLs activas, el entorno de desarrollo recibe el mismo tráfico que producción sin interferir con ella. Desactiva el webhook de dev cuando termines. El paso a paso completo está en cómo probar el webhook de WhatsApp en local con ngrok.
También puedes probar el parser sin tráfico real, guardando payloads de ejemplo y enviándolos con curl (deja META_APP_SECRET sin definir en esta prueba, o firma el cuerpo como se muestra en la guía de firma; si no, el servidor responderá 401):
curl -X POST http://localhost:3000/whatsapp/eventos \
-H "Content-Type: application/json" \
--data @ejemplos/mensaje-texto.json
Errores comunes
| Síntoma | Causa probable |
|---|---|
| Mensajes procesados dos veces | Falta deduplicar por id |
| Eventos que llegan atrasados en ráfaga | Endpoint lento; el origen acumuló reintentos |
| La firma nunca coincide | Hash calculado sobre el JSON ya parseado, no sobre el cuerpo crudo |
413 Payload Too Large | Límite de cuerpo del servidor por debajo de 3 MB |
| Solo se procesa el primer mensaje del lote | Código que lee messages[0] en lugar de recorrer la lista |
Estado que retrocede de read a delivered | Eventos fuera de orden que sobrescriben el estado |
| Contactos sin teléfono en el CRM | Código que usa solo from/wa_id e ignora el BSUID |
Preguntas frecuentes
¿Tengo que devolver algo en el cuerpo de la respuesta del webhook?
No. Basta con el estado HTTP 200. El contenido de la respuesta se ignora, excepto en la verificación por GET de la integración directa, donde el cuerpo debe ser el hub.challenge.
¿Puedo usar la misma URL para varios números?
Sí. Usa metadata.phone_number_id para saber a qué número pertenece el evento. En Vectalk, cada webhook se registra por instancia, y puedes apuntar varias instancias a la misma URL.
¿Durante cuánto tiempo Meta intenta reenviar un evento?
Hasta siete días, con frecuencia decreciente, según la documentación de webhooks de Meta. En Vectalk, la documentación indica que el evento se reenvía cuando el endpoint no responde 200 en menos de 20 segundos.
¿Cómo recibo los mensajes que el dueño responde desde la app, con coexistencia?
Mediante el campo de webhook smb_message_echoes. La guía de coexistencia en la API de WhatsApp explica los campos adicionales.
Estoy migrando desde Baileys. ¿El formato es muy diferente?
Sí. Los eventos siguen la estructura entry[].changes[].value de Meta, los contactos se identifican por wa_id y, desde abril de 2026, también por el BSUID (user_id), y la multimedia llega por id. La guía cómo migrar a la API oficial de WhatsApp cubre esa transición. Para empezar con Vectalk, crea tu cuenta en apps.vectalk.com.br/register; las dudas de cuenta van al Centro de Ayuda (en portugués).
Fuentes oficiales consultadas el 29/10/2026: Descripción general de webhooks de WhatsApp, Crear un endpoint de webhook, Webhooks de la Graph API: primeros pasos, Referencia del webhook de estado, Referencia del webhook de mensaje de texto, ID de usuario con alcance de empresa (BSUID), Precios, Webhooks en la documentación de Vectalk, Registrar webhook (Vectalk), Actualizar webhook (Vectalk).
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.
