Volver al blog
Desarrolladores

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.

· 12 min
Webhooks de WhatsApp API: recibir mensajes y estados con Node y Python

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:

EventoCuándo llega
message.receivedUn cliente te escribió
message.status_changedUn mensaje que mandaste pasó de estado (sent, delivered, read, failed, …)
conversation.createdSe abrió una conversación nueva
contact.createdEntró un contacto nuevo
No hay un evento genérico ni nombres en mayúscula. Si mandás otro string, la API responde 400.

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étodoRutaScopePara qué
GET/webhookswebhooks:readListar suscripciones
GET/:idwebhooks:readVer una
PATCH/:idwebhooks:writeCambiar URL, eventos o enabled
DELETE/:idwebhooks:writeBorrarla (204)
POST/:id/testwebhooks:writeEncola un message.received de prueba, con datos ficticios
GET/:id/deliverieswebhooks:readHistorial de intentos
POST/:id/replay/:deliveryIdwebhooks:writeReenviar un intento ya registrado
POST/:id/rotate-secretwebhooks:writeRotar el secreto
Si esa URL ya está cargada, la API responde 409 con código WEBHOOK_DUPLICATE_URL. El destino tiene que ser HTTPS público: Waspy rechaza direcciones internas.

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:

HeaderValor
X-Waspy-Signaturet=,v1=
X-Waspy-Webhook-IdId de la suscripción
X-Waspy-Webhook-EventNombre del evento
X-Waspy-Webhook-Versionv1
X-Waspy-Webhook-AttemptNúmero de intento, desde 1
X-Waspy-First-Attempt-AtUnix del primer intento (no cambia en los reintentos)
La firma se calcula sobre el cuerpo crudo, no sobre un JSON que rearmaste. 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.

Tu equipo atendiendo por WhatsApp en minutos

CRM facil pero potente. En 10 minutos tenes a tu equipo trabajando — no necesitas hablar con nadie.