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.
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).
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.
{
"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
}
{
"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"
}
{
"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"
}
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";
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
}
Cada envío incluye estas cabeceras HTTP, tanto en formulario como en JSON:
| Cabecera | Descripción |
|---|---|
X-WaMundo-Signature | Firma del envío: sha256= seguido del HMAC-SHA256 en hexadecimal. |
X-WaMundo-Timestamp | Momento del envío en segundos Unix. Va incluido en la firma. |
X-WaMundo-Event | Evento enviado (sms, whatsapp, ussd, message.status o webhook.test). |
X-WaMundo-Delivery | Identificador único del envío (UUID). Úsalo para evitar procesar dos veces lo mismo. |
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
$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";
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");
});
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
Al crear el webhook marcas los eventos que quieres recibir. El campo type del envío indica cuál ocurrió.
| Evento | Descripción |
|---|---|
sms | SMS recibido en tu dispositivo. |
whatsapp | Mensaje de WhatsApp recibido. |
ussd | Respuesta a un código USSD. |
message.status | Cambio de estado de un mensaje de WhatsApp enviado: sent (enviado), failed (fallido), delivered (entregado), read (leído) o played (reproducido). |
conversation.created | Se abre una conversación nueva con un contacto (chat individual; no incluye grupos). |
conversation.closed | Una conversación se cierra (desde el chat o con «cerrar todas»). |
deal.created | Se 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.moved | Un 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.won | Un trato pasa a una etapa de Ganado. |
deal.lost | Un trato pasa a una etapa de Perdido. |
deal.reopened | Un trato ganado o perdido vuelve a una etapa abierta. |
deal.deleted | Se elimina un trato del Embudo (el chat y el contacto no se borran). |
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
}
}
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)
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"
}