📩 Webhook de WhatsApp en whapi.co

Recibe eventos en tiempo real en tu servidor. Ejemplos en Node.js, Python y PHP, con validación HMAC y mejores prácticas.

¿Qué es un webhook?

Un webhook es una URL de tu servidor que whapi.co llama con POST cuando ocurre un evento en tu número de WhatsApp. Tú recibes un JSON y respondes 200 OK.

Tip: En tu panel ( Dashboard → Instancia → Webhook URL ) puedes definir la URL y probarla con el botón Test Webhook. Internamente llama a /api/test-webhook.

1) Configurar la URL de webhook

  1. Ve a Dashboard → abre tu instancia (p. ej. /manage_instance.php?id=123).
  2. En Webhook URL escribe la dirección de tu endpoint (p. ej. https://tu-dominio.com/webhook) y guarda.
  3. Usa Test Webhook para enviar un evento de prueba (test_webhook).

2) Eventos que envía whapi.co

a) message_received (entrante)

Se emite cuando recibes un mensaje. Si trae multimedia, media_url apunta al archivo temporal en /uploads/whatsapp/.

CampoTipoDescripción
event_typestringSiempre message_received
idstringID del mensaje
instance_idstring|numberInstancia
chat_idstringJID del chat (@c.us/@g.us)
fromstringRemitente real (en grupos puede cambiar)
tostringDestinatario
from_me0|1Entrantes = 0
sender_namestring|nullDisplay name
bodystringTexto (vacío en media)
typestringtext,image,video,audio,document,sticker,location,vcard
timestampnumberUnix epoch (s)
is_group0|1Es grupo
quoted_message_idstring|nullSi citó otro mensaje
reactionstring|nullEmoji si aplica
media_urlstring|nullURL pública temporal
latitude/longitudenumber|nullSi type=location
vcardstring|nullvCard bruta
statusstringSiempre received
ackstringpending al ingresar
group_senderstring|nullRemitente en grupo
{ 
  "event_type":"message_received",
  "id":"ABCD1234",
  "instance_id":"123",
  "chat_id":"573001112233@c.us",
  "from":"573001112233@c.us",
  "to":"573009998877@c.us",
  "from_me":0,
  "sender_name":"Juan Pérez",
  "body":"Hola, ¿tienen menú?",
  "type":"text",
  "timestamp":1724716800,
  "is_group":0,
  "quoted_message_id":null,
  "reaction":null,
  "media_url":null,
  "latitude":null,
  "longitude":null,
  "vcard":null,
  "status":"received",
  "ack":"pending",
  "group_sender":null
}

b) message_sent (saliente)

Se emite cuando tu instancia envía un mensaje (desde /api/send-message o manualmente).

{
  "event_type":"message_sent",
  "id":"WXYZ5678",
  "instance_id":"123",
  "chat_id":"573001112233@c.us",
  "from":"573009998877@c.us",
  "to":"573001112233@c.us",
  "body":"¡Hola! Te comparto el menú 👇",
  "type":"text",
  "timestamp":1724716890,
  "is_group":0,
  "media_url":null,
  "latitude":null,
  "longitude":null,
  "vcard":null,
  "status":"sent",
  "group_sender":null
}

c) ack_update

Actualiza el estado del mensaje: pending → sent → delivered → read.

{
  "event_type":"ack_update",
  "id":"WXYZ5678",
  "ack":"delivered"
}

d) message_reaction

{
  "event_type":"message_reaction",
  "instance_id":"123",
  "message_id":"WXYZ5678",
  "from_number":"573001112233@c.us",
  "reaction":"👍",
  "timestamp":1724717000
}

e) test_webhook

{
  "event_type":"test_webhook",
  "instance_id":"123",
  "timestamp":1724717100,
  "message":"This is a test webhook message from WhApi.co"
}

3) Endpoint de configuración (API whapi.co)

Además del panel, puedes actualizar el webhook por API:

POST /api/instance/:id/webhook
Content-Type: application/json
{
  "token": "TOKEN_DE_LA_INSTANCIA",
  "url": "https://tu-dominio.com/webhook",
  "secret": "opcional_para_hmac"
}

Si defines secret, tu servidor recibirá la cabecera X-Whapi-Signature (HMAC-SHA256 sobre el cuerpo) para validar integridad/autenticidad.

4) Tu endpoint receptor

Ejemplos listos para pegar. Incluyen idempotencia y validación HMAC (X-Whapi-Signature).

const express = require("express");
const crypto = require("crypto");
const app = express();

// IMPORTANTE: conservar el raw body para HMAC
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

const SEEN = new Set();            // usa Redis en producción
const WEBHOOK_SECRET = process.env.WHAPI_WEBHOOK_SECRET || "";

function verifyHmac(req) {
  if (!WEBHOOK_SECRET) return true; // si no configuraste secret, no hay firma
  const sig = req.get("X-Whapi-Signature") || "";
  const h = crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody || "").digest("hex");
  return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(sig));
}

app.post("/webhook", async (req, res) => {
  try {
    if (!verifyHmac(req)) return res.sendStatus(401);

    const ev = req.body || {};
    const key = ev.id || ev.message_id || (ev.event_type + ":" + ev.timestamp);
    if (key) {
      if (SEEN.has(key)) return res.sendStatus(200);
      SEEN.add(key);
    }

    switch (ev.event_type) {
      case "message_received":
        console.log("📥 In:", ev.from, "->", ev.to, "|", ev.type, "|", ev.body);
        // TODO: guardar en BD, invocar bot, etc.
        break;
      case "message_sent":
        console.log("📤 Out:", ev.to, "|", ev.type, "|", ev.status);
        break;
      case "ack_update":
        console.log("✅ ACK:", ev.id, "->", ev.ack);
        break;
      case "message_reaction":
        console.log("😍 Reaction:", ev.message_id, ev.reaction, "by", ev.from_number);
        break;
      case "test_webhook":
        console.log("🧪 Test:", ev.message);
        break;
      default:
        console.log("ℹ️ Evento desconocido:", ev.event_type);
    }

    res.sendStatus(200);
  } catch (e) {
    console.error("Webhook error:", e);
    res.sendStatus(200); // ya recibimos el evento; procesa asíncrono si es largo
  }
});

app.listen(3000, () => console.log("Webhook listo en http://localhost:3000/webhook"));
from flask import Flask, request
import hashlib, hmac

app = Flask(__name__)
SEEN = set()
WEBHOOK_SECRET = ""  # pon tu secreto si lo configuraste en whapi.co

def verify_hmac(raw_body: bytes, signature: str) -> bool:
    if not WEBHOOK_SECRET:
        return True
    comp = hmac.new(WEBHOOK_SECRET.encode(), raw_body or b"", hashlib.sha256).hexdigest()
    return hmac.compare_digest(comp, signature or "")

@app.post("/webhook")
def webhook():
    raw = request.get_data(cache=False, as_text=False)
    if not verify_hmac(raw, request.headers.get("X-Whapi-Signature", "")):
        return ("", 401)

    ev = request.get_json(force=True, silent=True) or {}
    key = ev.get("id") or ev.get("message_id") or f'{ev.get("event_type")}:{ev.get("timestamp")}'
    if key in SEEN:
        return ("", 200)
    SEEN.add(key)

    et = ev.get("event_type")
    if et == "message_received":
        print("📥 In:", ev.get("from"), "->", ev.get("to"), "|", ev.get("type"), "|", ev.get("body"))
    elif et == "message_sent":
        print("📤 Out:", ev.get("to"), "|", ev.get("type"), "|", ev.get("status"))
    elif et == "ack_update":
        print("✅ ACK:", ev.get("id"), "->", ev.get("ack"))
    elif et == "message_reaction":
        print("😍 Reaction:", ev.get("message_id"), ev.get("reaction"), "by", ev.get("from_number"))
    elif et == "test_webhook":
        print("🧪 Test:", ev.get("message"))
    else:
        print("ℹ️ Evento desconocido:", et)

    return ("", 200)

if __name__ == "__main__":
    app.run(port=3000)
<?php
// webhook.php
// Si usas secret en whapi.co, valida HMAC:
$WEBHOOK_SECRET = ""; // pon aquí el mismo secreto (opcional)

$raw = file_get_contents("php://input");
$ev  = json_decode($raw, true) ?: [];

function verify_hmac($raw, $secret, $headerSig) {
  if (!$secret) return true;
  $calc = hash_hmac("sha256", $raw ?: "", $secret);
  return hash_equals($calc, $headerSig ?? "");
}
if (!verify_hmac($raw, $WEBHOOK_SECRET, $_SERVER["HTTP_X_WHAPI_SIGNATURE"] ?? "")) {
  http_response_code(401); exit;
}

// Idempotencia básica (usa Redis/DB en producción)
$key = $ev["id"] ?? ($ev["message_id"] ?? (($ev["event_type"] ?? "evt").":".($ev["timestamp"] ?? time())));
$flag = sys_get_temp_dir()."/whapi_".md5($key);
if (file_exists($flag)) { http_response_code(200); exit; }
touch($flag);

// Ruteo por evento
$et = $ev["event_type"] ?? "";
switch ($et) {
  case "message_received":
    error_log("📥 In: ".$ev["from"]." -> ".$ev["to"]." | ".$ev["type"]." | ".$ev["body"]);
    break;
  case "message_sent":
    error_log("📤 Out: ".$ev["to"]." | ".$ev["type"]." | ".$ev["status"]);
    break;
  case "ack_update":
    error_log("✅ ACK: ".$ev["id"]." -> ".$ev["ack"]);
    break;
  case "message_reaction":
    error_log("😍 Reaction: ".$ev["message_id"]." ".$ev["reaction"]." by ".$ev["from_number"]);
    break;
  case "test_webhook":
    error_log("🧪 Test: ".$ev["message"]);
    break;
  default:
    error_log("ℹ️ Evento desconocido: ".$et);
}

http_response_code(200);
echo json_encode(["ok" => true]);

5) Probar con curl

message_received

curl -X POST https://tu-dominio.com/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "event_type":"message_received",
    "id":"SIM-1",
    "instance_id":"123",
    "chat_id":"573001112233@c.us",
    "from":"573001112233@c.us",
    "to":"573009998877@c.us",
    "from_me":0,
    "sender_name":"Cliente",
    "body":"Hola, ¿abren hoy?",
    "type":"text",
    "timestamp":1724716800,
    "is_group":0,
    "status":"received",
    "ack":"pending"
  }'

ack_update

curl -X POST https://tu-dominio.com/webhook \
  -H "Content-Type: application/json" \
  -d '{"event_type":"ack_update","id":"SIM-OUT-1","ack":"read"}'

6) Buenas prácticas

  • Responde 200 OK rápido; procesa en background si tu lógica tarda.
  • Usa idempotencia (clave por id/message_id) para no duplicar.
  • Valida HMAC si configuras secret (cabecera X-Whapi-Signature).
  • Persistencia: guarda cada evento (tu API ya inserta en messages y message_reactions).
  • Multimedia: descarga media_url a tu storage; en tu servidor se limpia periódicamente.
  • Seguridad: limita por IP, rate limit y WAF en tu reverse proxy.