Webhooks de WhatsApp API: recibir mensajes y estados con Node y Python
Waspy le avisa a tu servidor cuando entra un mensaje o cambia un estado, con una firma HMAC que podés verificar en Node o Python. Alta del webhook, reintentos y el payload real.
Respuesta corta: un webhook de Waspy es un POST que tu servidor recibe cuando pasa algo en WhatsApp: entra un mensaje, cambia el estado de uno que mandaste, se crea un chat o un contacto. Lo das de alta en POST https://api.waspytech.com/api/v2/webhooks, guardás el secreto que vuelve una sola vez, y verificás el header X-Waspy-Signature (HMAC-SHA256 de timestamp.cuerpo) antes de procesar el JSON. Si respondés 2xx o 3xx, Waspy lo da por entregado.
Sirve para el caso típico de una tienda que no quiere andar preguntando "¿llegó algo?" cada 30 segundos: el aviso de "ya pagué" o el "¿dónde está mi pedido?" entra a tu sistema en el momento, y el estado delivered o read actualiza la fila del pedido.
Qué eventos existen
La API acepta exactamente estos cuatro nombres, en minúscula y con punto:
| Evento | Cuándo llega |
|---|---|
message.received | Un cliente te escribió |
message.status_changed | Un mensaje que mandaste pasó de estado (sent, delivered, read, failed, …) |
conversation.created | Se abrió una conversación nueva |
contact.created | Entró un contacto nuevo |
La referencia pública está en docs.waspytech.com. La API REST (y por lo tanto los webhooks) está en los planes Scale y Enterprise: el detalle está en Planes y en la página de la API.
Dar de alta la suscripción
La clave va como Authorization: Bearer wspy_... y necesita el scope webhooks:write. Una clave sin permisos marcados tiene acceso completo (las claves viejas se crearon así). Si la clave tiene una lista de scopes y no incluye webhooks:write, la respuesta es 403 con código FORBIDDEN.
curl -sS https://api.waspytech.com/api/v2/webhooks \
-H "Authorization: Bearer $WASPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://envios.mitienda.com.ar/waspy",
"events": ["message.received", "message.status_changed"],
"description": "Pedidos de la tienda"
}'
La respuesta 201 incluye data.secret. Guardalo en el entorno del servidor. No se puede volver a leer: si lo perdés, POST /api/v2/webhooks/:id/rotate-secret genera otro e invalida el anterior al instante.
Otros endpoints reales, todos bajo /api/v2/webhooks:
| Método | Ruta | Scope | Para qué |
|---|---|---|---|
| GET | /webhooks | webhooks:read | Listar suscripciones |
| GET | /:id | webhooks:read | Ver una |
| PATCH | /:id | webhooks:write | Cambiar URL, eventos o enabled |
| DELETE | /:id | webhooks:write | Borrarla (204) |
| POST | /:id/test | webhooks:write | Encola un message.received de prueba, con datos ficticios |
| GET | /:id/deliveries | webhooks:read | Historial de intentos |
| POST | /:id/replay/:deliveryId | webhooks:write | Reenviar un intento ya registrado |
| POST | /:id/rotate-secret | webhooks:write | Rotar el secreto |
Cómo llega el pedido
Cada entrega es un POST con Content-Type: application/json y User-Agent: Waspy-Webhooks/1.0. El cuerpo es un sobre. Un texto que entra se guarda con status: "delivered": no hay un estado received. El ejemplo de abajo muestra los campos del contrato; los UUID son ilustrativos.
{
"event": "message.received",
"webhookId": "uuid-de-tu-suscripcion",
"deliveredAt": "2026-10-16T14:05:00.000Z",
"attempt": 1,
"data": {
"id": "uuid-del-mensaje",
"conversationId": "uuid",
"direction": "inbound",
"type": "text",
"content": { "body": "Hola, pagué con Mercado Pago" },
"status": "delivered",
"origin": "inbound",
"waMessageId": "wamid.HBgN...",
"clientRef": null,
"contact": {
"id": "uuid",
"name": "Laura",
"phoneNumber": "+5491155554444"
}
}
}
En message.status_changed, data suma previousStatus y newStatus. Si al enviar pusiste clientRef (por ejemplo pedido-1847), vuelve igual en cada cambio de estado: así atás el evento al pedido de Tienda Nube sin una tabla intermedia. El envío desde una planilla está en enviar WhatsApp desde Google Sheets.
Headers que importan:
| Header | Valor |
|---|---|
X-Waspy-Signature | t= |
X-Waspy-Webhook-Id | Id de la suscripción |
X-Waspy-Webhook-Event | Nombre del evento |
X-Waspy-Webhook-Version | v1 |
X-Waspy-Webhook-Attempt | Número de intento, desde 1 |
X-Waspy-First-Attempt-At | Unix del primer intento (no cambia en los reintentos) |
t es la hora de ese intento y cambia en cada reintento. X-Waspy-First-Attempt-At es la que te dice cuándo se originó el evento.
Verificar la firma en Node
Waspy firma HMAC-SHA256 de la cadena timestamp + "." + cuerpo, en hexadecimal. La ventana de 5 minutos del ejemplo es una defensa tuya contra reenvíos: Waspy no te impone ese número.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.WASPY_WEBHOOK_SECRET;
function firmaOk(rawBody, header) {
const partes = Object.fromEntries(String(header || '').split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i), p.slice(i + 1)];
}));
if (!partes.t || !partes.v1) return false;
const edad = Math.abs(Math.floor(Date.now() / 1000) - Number(partes.t));
if (!Number.isFinite(edad) || edad > 300) return false;
const esperado = createHmac('sha256', SECRET).update(`${partes.t}.${rawBody}`).digest('hex');
const a = Buffer.from(esperado);
const b = Buffer.from(partes.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!firmaOk(raw, req.headers['x-waspy-signature'])) {
res.writeHead(401);
res.end('firma invalida');
return;
}
const evento = JSON.parse(raw);
if (evento.event === 'message.received' && evento.data?.type === 'text') {
console.log(evento.data.contact?.phoneNumber, evento.data.content?.body);
}
res.writeHead(200);
res.end('ok');
});
}).listen(8080);
Respondé rápido. El timeout de entrega es de 10 segundos. Si tu handler tarda más (consultar Tienda Nube, emitir una factura), guardá el evento y respondé 200 antes.
Verificar la firma en Python
Misma regla: leé los bytes antes de parsear.
import hashlib
import hmac
import json
import os
import time
SECRET = os.environ["WASPY_WEBHOOK_SECRET"].encode()
def firma_ok(raw: bytes, header: str) -> bool:
partes = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = partes.get("t"), partes.get("v1")
if not t or not v1:
return False
try:
edad = abs(int(time.time()) - int(t))
except ValueError:
return False
if edad > 300:
return False
esperado = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, v1)
# Flask: cuerpo = request.get_data() (no request.json)
# if not firma_ok(cuerpo, request.headers.get("X-Waspy-Signature", "")):
# return ("firma invalida", 401)
# evento = json.loads(cuerpo)
Reintentos y qué cuenta como entregado
Un 2xx o cualquier 3xx es entrega exitosa. Waspy no sigue el redirect con otro POST: un Google Apps Script que responde 302 ya procesó el cuerpo, y volver a postear lo ejecutaría dos veces.
4xx, 5xx y timeouts se reintentan. Hay 5 intentos en total. Entre uno y el siguiente Waspy espera 1 minuto, 5 minutos, 30 minutos y 2 horas. Después del quinto, ese evento no se reintenta solo: lo podés reenviar con POST /webhooks/:id/replay/:deliveryId (el deliveryId sale de GET /webhooks/:id/deliveries).
Si fallan 10 entregas seguidas (sin contar las de prueba), la suscripción se desactiva sola y disabledReason lo explica. Un POST /:id/test no resetea ese contador.
El tope de la API es 120 pedidos por minuto por cuenta. Si te pasás, la respuesta es 429 con código RATE_LIMITED.
n8n, Make y Zapier
No hace falta un nodo propio de Waspy. En n8n o Make el disparador es un webhook HTTP, y en Zapier un "Catch Hook". La URL que te dan (por ejemplo https://hooks.zapier.com/...) es la que cargás en POST /api/v2/webhooks.
Esas herramientas suelen parsear el JSON antes de que puedas ver el cuerpo crudo, así que no pueden verificar la firma. Para un flujo interno de bajo riesgo puede alcanzar. Para algo que crea pedidos o marca pagos, poné un servidor chico adelante (el de Node de arriba) que verifique la firma y recién ahí llame a n8n.
Si en vez de empujar eventos querés que un asistente lea la bandeja cuando se lo pedís, eso es el conector MCP, no el webhook. El MCP no avisa solo: cada consulta lee la cuenta en ese momento.
Qué no hacer
- No proceses el JSON si la firma no cierra. Cualquiera que adivine la URL podría inventar un "ya pagué".
- No uses el webhook de Meta (el de la Cloud API directa) y el de Waspy a la vez para la misma lógica sin deduplicar: son dos contratos distintos. Si Waspy ya recibe los eventos de Meta, suscribite al de Waspy.
- No confirmes un pago solo porque el texto dice "pagué". El mensaje es un aviso; la acreditación la ves en Mercado Pago. Cómo mandar el link está en cobrar por WhatsApp.
Si estás armando la integración y querés el inbox del equipo en el mismo número, creá la cuenta: 10 días gratis, sin tarjeta, y la API se habilita en Scale.
