¿Qué son los Webhooks?

Los webhooks son notificaciones HTTP automáticas que el sistema envía a tu servidor cada vez que ocurre un evento importante, como recibir un mensaje SMS o de WhatsApp.

Esta función se activa con eventos entrantes: SMS recibidos, mensajes de WhatsApp y respuestas USSD.


Casos de Uso

  • Guardar mensajes recibidos en tu propia base de datos
  • Crear tickets de soporte automáticos al recibir un mensaje
  • Integrar con tu CRM (registrar contactos, actualizar estados)
  • Enviar alertas a Telegram, Slack o correo cuando llega un mensaje
  • Generar reportes automáticos de actividad
  • Sincronizar conversaciones con sistemas externos
  • Activar flujos de automatización en herramientas como Zapier o n8n

¿Cómo Funciona?

Cuando se recibe un evento (ej: un SMS entrante), el sistema hace un POST automático a la URL que configuraste, enviando toda la información del mensaje como formulario (application/x-www-form-urlencoded).

Evento Recibido
(SMS / WhatsApp / USSD)
→
Sistema procesa el evento
→
Construye el payload
→
POST a tu URL webhook
→
Tu servidor recibe y procesa
→
Responde HTTP 200

Estructura de los Datos (Payload)

Cada payload incluye un tipo (type) y los datos del evento. A continuación puedes ver la estructura según el tipo de mensaje:

Por defecto el envío es un formulario (application/x-www-form-urlencoded) con los mismos campos que los ejemplos, por ejemplo data[phone] y data[message]. Los ejemplos se muestran en JSON solo por claridad.

SMS Recibido
{
    "type": "sms",               // Tipo de evento
    "data": {
        "id": "123",             // ID único del mensaje en el sistema
        "rid": "456",            // ID de referencia
        "sim": 1,                // Ranura SIM que recibió el mensaje (1 o 2)
        "phone": "+58412...",    // Número del remitente
        "message": "Hola...",    // Contenido del mensaje
        "timestamp": 1679900000  // Fecha/hora en formato Unix
    },
    "secret": "tu_clave_secreta" // Clave para verificar autenticidad
}
WhatsApp Recibido
{
    "type": "whatsapp",          // Tipo de evento
    "data": {
        "id": "789",             // ID único del mensaje
        "wid": "[email protected]", // ID de la cuenta WhatsApp receptora
        "phone": "+58414...",    // Número del remitente
        "message": "Hola...",    // Contenido del mensaje
        "attachment": false,     // Enlace del archivo adjunto, si lo hay
        "timestamp": 1679900000, // Fecha/hora en formato Unix
        "reply_to": "+58414...", // A dónde responder: úsalo como "recipient" en /send/whatsapp
        "sender_jid": null,      // JID @lid del remitente, si WhatsApp lo envía
        "addressing_mode": "pn"  // "pn" = número real · "lid" = escribió por @usuario, sin número:
                                 //   responde a reply_to (1234567890@lid), no a phone
    },
    "secret": "tu_clave_secreta"
}
Respuesta USSD
{
    "type": "ussd",              // Tipo de evento
    "data": {
        "id": "101",             // ID único
        "sim": 1,                // Ranura SIM
        "code": "*123#",         // Código USSD ejecutado
        "response": "Saldo: $5", // Respuesta del operador
        "timestamp": 1679900000
    },
    "secret": "tu_clave_secreta"
}

Ejemplo de Código (PHP)

Tu servidor recibe los datos via POST como formulario (application/x-www-form-urlencoded), con los mismos campos que los ejemplos de arriba. Aquí un ejemplo completo de cómo procesarlos:

<?php
// 1. Recibir los datos del webhook (formulario: llegan en $_POST)
$payload = $_POST;

// 2. Verificar la autenticidad con tu clave secreta
$miSecreto = "tu_clave_secreta_aqui";
if (!isset($payload["secret"]) || !hash_equals($miSecreto, $payload["secret"])) {
    http_response_code(403);
    die("No autorizado");
}

// 3. Procesar según el tipo de evento
$tipo = $payload["type"];
$data = $payload["data"];

switch ($tipo) {
    case "sms":
        // SMS recibido
        $telefono = $data["phone"];
        $mensaje  = $data["message"];
        // Guardar en base de datos, enviar alerta, etc.
        break;

    case "whatsapp":
        // Mensaje de WhatsApp recibido
        $telefono = $data["phone"];
        $mensaje  = $data["message"];
        break;

    case "ussd":
        // Respuesta USSD
        $codigo   = $data["code"];
        $respuesta = $data["response"];
        break;

}

// 4. Responder con 200 OK para confirmar recepción
http_response_code(200);
echo "OK";
Importante: Tu servidor debe responder con código HTTP 200 para confirmar que recibió el webhook correctamente.

Formato del envío: formulario o JSON a elección

Al crear o editar un webhook eliges el formato: formulario clásico (application/x-www-form-urlencoded, el de siempre) o JSON (application/json). Los webhooks existentes siguen enviando como formulario hasta que los cambies.

En JSON el cuerpo mantiene los mismos campos (secret, type, data) y añade tres en la raíz: event (igual a type), delivery_id (identificador único de cada envío) y created_at (fecha UTC en ISO-8601).

{
    "secret": "tu_clave_secreta",
    "type": "whatsapp",
    "data": { "id": 789, "phone": "+58414...", "message": "Hola..." },
    "event": "whatsapp",                                  // = type
    "delivery_id": "0b6f6c0e-6d3c-4a8e-9a53-7c1f1d1e2b44", // único por envío
    "created_at": "2026-10-27T15:04:05Z"                  // UTC, ISO-8601
}

Cabeceras de cada envío

Cada envío incluye estas cabeceras HTTP, tanto en formulario como en JSON:

CabeceraDescripción
X-WaMundo-SignatureFirma del envío: sha256= seguido del HMAC-SHA256 en hexadecimal.
X-WaMundo-TimestampMomento del envío en segundos Unix. Va incluido en la firma.
X-WaMundo-EventEvento enviado (sms, whatsapp, ussd, message.status o webhook.test).
X-WaMundo-DeliveryIdentificador único del envío (UUID). Úsalo para evitar procesar dos veces lo mismo.

Verifica la firma

La firma se calcula con tu clave secreta (la que copias con el botón de «Opciones» del webhook) sobre el texto timestamp + "." + cuerpo, donde el cuerpo son los bytes exactos que recibes, sin decodificar ni reformatear.

X-WaMundo-Signature: sha256=HMAC_SHA256( clave = tu_clave_secreta, mensaje = timestamp + "." + cuerpo_exacto )

Compara siempre con una función de tiempo constante (hash_equals en PHP, timingSafeEqual en Node, compare_digest en Python) y rechaza los envíos cuyo timestamp tenga más de 5 minutos de diferencia. Además, por compatibilidad, el secret sigue viajando dentro del cuerpo.

PHP
<?php
$secreto = "tu_clave_secreta";
$raw = file_get_contents("php://input");               // bytes exactos, ANTES de decodificar
$ts  = $_SERVER["HTTP_X_WAMUNDO_TIMESTAMP"] ?? "";
$sig = $_SERVER["HTTP_X_WAMUNDO_SIGNATURE"] ?? "";

// Rechaza envíos con más de 5 minutos de diferencia (evita repeticiones)
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
    http_response_code(400);
    exit("Timestamp fuera de rango");
}

$esperada = "sha256=" . hash_hmac("sha256", $ts . "." . $raw, $secreto);
if (!hash_equals($esperada, $sig)) {
    http_response_code(403);
    exit("Firma inválida");
}

// Firma correcta: ya puedes leer los datos
$esJson  = strpos($_SERVER["CONTENT_TYPE"] ?? "", "application/json") === 0;
$payload = $esJson ? json_decode($raw, true) : $_POST;

http_response_code(200);
echo "OK";
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRETO = "tu_clave_secreta";

// Necesitas los bytes EXACTOS del cuerpo: no uses express.json() antes de verificar
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
  const ts  = req.get("X-WaMundo-Timestamp") || "";
  const sig = req.get("X-WaMundo-Signature") || "";

  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.status(400).send("Timestamp fuera de rango");
  }

  const esperada = "sha256=" + crypto.createHmac("sha256", SECRETO)
    .update(ts + ".")
    .update(req.body)            // Buffer con el cuerpo crudo
    .digest("hex");

  const a = Buffer.from(esperada);
  const b = Buffer.from(sig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(403).send("Firma inválida");
  }

  const texto = req.body.toString("utf8");
  const payload = (req.get("Content-Type") || "").startsWith("application/json")
    ? JSON.parse(texto)
    : Object.fromEntries(new URLSearchParams(texto));   // claves tipo "data[phone]"

  res.status(200).send("OK");
});
Python (Flask)
import hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRETO = b"tu_clave_secreta"

@app.post("/webhook")
def webhook():
    raw = request.get_data()                      # bytes exactos, antes de leer request.form
    ts  = request.headers.get("X-WaMundo-Timestamp", "")
    sig = request.headers.get("X-WaMundo-Signature", "")

    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(400)

    esperada = "sha256=" + hmac.new(SECRETO, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(esperada, sig):
        abort(403)

    payload = request.get_json(force=True) if request.is_json else request.form
    return "OK", 200

Eventos disponibles

Al crear el webhook marcas los eventos que quieres recibir. El campo type del envío indica cuál ocurrió.

EventoDescripción
smsSMS recibido en tu dispositivo.
whatsappMensaje de WhatsApp recibido.
ussdRespuesta a un código USSD.
message.statusCambio de estado de un mensaje de WhatsApp enviado: sent (enviado), failed (fallido), delivered (entregado), read (leído) o played (reproducido).
conversation.createdSe abre una conversación nueva con un contacto (chat individual; no incluye grupos).
conversation.closedUna conversación se cierra (desde el chat o con «cerrar todas»).
deal.createdSe crea un trato en el Embudo. Si se crea directamente en una etapa de Ganado o Perdido se envían además deal.won o deal.lost (dos eventos seguidos).
deal.movedUn trato cambia de etapa sin cambiar de estado: de una etapa abierta a otra abierta (o entre dos etapas de Ganado o de Perdido).
deal.wonUn trato pasa a una etapa de Ganado.
deal.lostUn trato pasa a una etapa de Perdido.
deal.reopenedUn trato ganado o perdido vuelve a una etapa abierta.
deal.deletedSe elimina un trato del Embudo (el chat y el contacto no se borran).
message.status

message.status te avisa cuando un mensaje que enviaste cambia de estado. Hay dos formas de dato, según el estado:

// Enviado (o fallido): status = "sent" | "failed"
{
    "secret": "tu_clave_secreta",
    "type": "message.status",
    "data": {
        "id": 12345,                 // ID del mensaje enviado en WaMundo
        "status": "sent",            // "sent" | "failed"
        "phone": "+58414...",        // Destinatario (o grupo)
        "wa_msg_id": "3EB0A1B2C3D4", // ID del mensaje en WhatsApp (puede venir vacío si falló)
        "timestamp": 1679900000
    }
}

// Recibo de WhatsApp: status = "delivered" | "read" | "played"
{
    "secret": "tu_clave_secreta",
    "type": "message.status",
    "data": {
        "wa_msg_id": "3EB0A1B2C3D4", // el mismo ID de WhatsApp
        "status": "delivered",       // "delivered" | "read" | "played"
        "from": "[email protected]", // quien generó el recibo, tal como lo entrega WhatsApp
        "timestamp": 1679900005
    }
}
Los envíos de message.status esperan como máximo 2 segundos tu respuesta y no se reintentan; responde rápido con un código 2xx y procesa después. El orden de llegada no está garantizado: usa timestamp o wa_msg_id para ordenar. Si tu servidor falla de forma continua (200 fallos seguidos o 72 horas) pausamos ese webhook y lo volvemos a probar una vez por hora.
Garantías y límites
  • Entrega «best effort»: cada evento se envía una sola vez, con un tiempo máximo de 2 segundos y sin reintentos.
  • Los eventos pueden descartarse cuando hay mucha carga.
  • El orden entre eventos no está garantizado: usa timestamp para ordenarlos.
  • read y played se notifican una sola vez por mensaje: si ya se notificó read, no se envía played.
  • delivered se notifica una sola vez por mensaje.
  • Solo para mensajes enviados desde WaMundo (no para los escritos desde el teléfono).
  • En grupos se notifica el primer participante que cumple el estado.
conversation.created / conversation.closed

Estos eventos avisan cuando entra un contacto nuevo al chat y cuando se cierra una conversación. Nunca incluyen el texto de los mensajes. Se comprueban cada minuto, así que llegan con hasta un minuto de retraso.

// conversation.created
{
    "type": "conversation.created",
    "data": {
        "conversation_id": 123456,
        "account_id": 159,                 // número de WhatsApp de tu cuenta
        "phone": "58414...",               // vacío si WhatsApp solo entrega un identificador interno (LID)
        "addressing_mode": "pn",           // "pn" (número) | "lid"
        "reply_to": "58414...",            // a quién responder por la API
        "contact_name": "Ana",
        "status": "open",
        "assigned_to": null,               // ID del agente o null
        "created_at": 1679900000,
        "timestamp": 1679900000
    }
}

// conversation.closed: mismos campos, más:
//   "closed_at": 1679900600, "closed_by_uid": 12 | null  (en lugar de created_at)
  • Las marcas de tiempo van en segundos Unix (UTC). «closed_at» es el momento en que WaMundo detectó el cierre (±1 minuto).
  • Una conversación que se reabre no emite «conversation.created»; si se vuelve a cerrar, emite «conversation.closed» otra vez. Si se reabre y se cierra de nuevo dentro del mismo minuto, el cierre no se avisa.
  • Cada evento se entrega una sola vez. Si tu servidor no responde en ese momento, no se reintenta: guarda lo que recibes y, si necesitas más seguridad, consulta la API.
  • Con el formato «form», los valores vacíos (assigned_to, closed_by_uid y el teléfono vacío) no se envían. Con «json» llegan como null o cadena vacía.
deal.created / deal.moved / deal.won / deal.lost / deal.reopened / deal.deleted

Estos eventos avisan de lo que ocurre en tu Embudo de ventas. Se envían en el momento del cambio y nunca incluyen la nota del trato ni el texto de los mensajes.

// deal.moved (los demás eventos deal.* tienen la misma forma)
{
    "secret": "tu_clave_secreta",
    "type": "deal.moved",
    "data": {
        "deal": {
            "id": 57,
            "title": "Plan Profesional - Ana",
            "amount": "1234.50",              // texto decimal, con punto
            "currency": "USD",
            "status": "open",                 // "open" | "won" | "lost"
            "stage": { "id": 12, "name": "Negociación", "kind": "open" },
            "previous_stage": { "id": 11, "name": "Propuesta enviada" },   // solo en moved, won, lost y reopened
            "contact": { "phone": "58414...", "name": "Ana" },
            "conversation_id": 123456,        // null si el trato no está ligado a un chat
            "assigned_to": 12,                // ID del agente o null
            "created_at": "2026-10-28T14:00:00Z",
            "updated_at": "2026-10-28T15:30:00Z",
            "closed_at": null                 // fecha ISO-8601 UTC si está ganado o perdido
        },
        "by": 12,                             // ID del usuario que hizo el cambio
        "timestamp": 1793200200
    },
    "event": "deal.moved",
    "delivery_id": "0b6f6c0e-6d3c-4a8e-9a53-7c1f1d1e2b44",
    "created_at": "2026-10-28T15:30:00Z"
}
  • Cuando el estado de un trato cambia al moverlo se envía un solo evento: deal.won, deal.lost o deal.reopened (no se envía además deal.moved). deal.moved solo se envía si cambia de etapa sin cambiar de estado. Reordenar tarjetas dentro de la misma etapa no envía nada. Al eliminar una etapa con tratos, cada trato reasignado envía su evento (deal.moved, o deal.won, deal.lost o deal.reopened si cambia de estado), con un máximo de 50 eventos por eliminación.
  • Una persona que mueve un trato de Ganado a Perdido (o al revés) genera deal.lost (o deal.won); al volver a una etapa abierta genera deal.reopened.
  • «previous_stage» solo viaja en deal.moved, deal.won, deal.lost y deal.reopened. Las fechas van en ISO-8601 UTC y «timestamp» en segundos Unix. «closed_at» es null mientras el trato está abierto.
  • Cada evento se entrega una sola vez, con un máximo de 2 segundos de espera, y no se reintenta. Responde rápido con un código 2xx y procesa después. El orden de llegada no está garantizado y «timestamp» va en segundos enteros: dos eventos del mismo segundo pueden llegar en cualquier orden. Para ordenarlos compara con el estado que ya guardas del trato (etapa y estado actuales) y usa la cabecera X-WaMundo-Delivery (identificador único de cada envío) para descartar duplicados.
  • Con el formato «form», los valores vacíos (null) no se envían. Con «json» llegan como null.

Qué debe cumplir tu servidor

  • Responde con un código 2xx lo antes posible: máximo 5 segundos (2 segundos en message.status). Si tardas más, WaMundo corta la conexión y cuenta el intento como fallido. Guarda el aviso y procésalo después.
  • Los avisos de estado (message.status) se envían de uno en uno por cuenta; si tu servidor es lento, algunos avisos pueden descartarse. No se reintentan.
  • La URL debe ser pública (https recomendado). No se aceptan direcciones privadas, locales ni las del propio servidor de WaMundo, y no se siguen redirecciones.
  • WaMundo no verifica el certificado TLS de tu servidor. Por eso la seguridad real es la firma X-WaMundo-Signature: valídala siempre antes de confiar en el aviso.
  • Por compatibilidad, el campo secret también viaja dentro del cuerpo. Si usas la firma, puedes ignorarlo.
  • En grupos, el campo from de un recibo es el participante que lo generó, no el grupo.
  • Tras muchos fallos seguidos (200 o 3 días sin éxito) el webhook se pausa y se vuelve a probar cada hora. Revisa el registro y usa «Enviar prueba» para comprobarlo.