Webhooks
Un webhook es una URL https tuya a la que WhatsMarketing envía un POST cada vez que ocurre un evento al que te suscribiste. Se crean en Integraciones → Webhooks y API (hasta 10 por organización), con un secreto whsec_… que se muestra una sola vez.
Eventos
| Evento | Cuándo | Estado |
|---|---|---|
contact.created |
Se crea un contacto (panel, API, importación o entrada por WhatsApp) | Activo |
contact.updated |
Cambian los datos de un contacto | Activo |
contact.stage_changed |
Un contacto cambia de etapa del ciclo de vida | Activo |
campaign.finished |
Una campaña termina de enviarse | Activo |
campaign.replied |
Un destinatario responde a una campaña | Próximamente |
conversation.opened, conversation.assigned, conversation.closed |
Ciclo de una conversación | Próximamente |
message.received, message.sent |
Mensajes entrantes y salientes | Próximamente |
automation.run |
Se ejecuta una automatización | Próximamente |
bot.handoff, bot.message |
Un agente IA deriva a una persona o responde | Próximamente |
Puedes suscribirte ya a los eventos «próximamente»: empezarán a llegar cuando se activen, sin tocar nada.
Qué recibes
POST /tu-ruta HTTP/1.1
Content-Type: application/json
User-Agent: WhatsMarketing-Webhooks/1.0
X-WM-Event: contact.created
X-WM-Delivery: 3f4e7f0a-9b1c-4d2e-8f3a-4b5c6d7e8f90
X-WM-Signature: t=1760436060,v1=5d41402abc4b2a76b9719d911017c592…{
"id": "3f4e7f0a-9b1c-4d2e-8f3a-4b5c6d7e8f90",
"event": "contact.created",
"createdAt": "2026-10-14T10:21:00.000Z",
"data": {
"organizationId": "f0e1d2c3-b4a5-4968-8776-655443322110",
"contactId": "0b1c2d3e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"fullName": "Laura Pérez",
"primaryPhone": "+34612345678",
"primaryEmail": "laura@ejemplo.com",
"lifecycleStage": "Lead nuevo"
}
}id y X-WM-Delivery identifican la entrega: si recibes dos veces el mismo valor (por un reintento), procesa solo la primera. data trae lo justo para saber qué pasó; para la ficha completa pide GET /contacts/{contactId}.
| Evento | data |
|---|---|
contact.created |
organizationId, contactId, fullName, primaryPhone, primaryEmail, lifecycleStage |
contact.updated |
lo mismo más changedFields (los campos que cambiaron) |
contact.stage_changed |
lo mismo más previousStage |
campaign.finished |
organizationId, campaignId, name, status, sent, failed |
Verificar la firma
La firma protege de que alguien te mande eventos falsos. Es HMAC-SHA256 del texto "<t>.<cuerpo crudo>" con tu secreto, en hexadecimal. Calcula con el cuerpo tal como llegó (sin reformatear el JSON), compara en tiempo constante y descarta marcas de tiempo de más de 5 minutos.
import crypto from 'node:crypto';
function verificar(cuerpoCrudo, cabecera, secreto) {
const partes = Object.fromEntries((cabecera || '').split(',').map((p) => p.split('=')));
if (!partes.t || !partes.v1) return false;
const esperado = Buffer.from(crypto.createHmac('sha256', secreto).update(`${partes.t}.${cuerpoCrudo}`).digest('hex'));
const recibido = Buffer.from(partes.v1);
const fresco = Math.abs(Date.now() / 1000 - Number(partes.t)) < 300;
return fresco && esperado.length === recibido.length && crypto.timingSafeEqual(esperado, recibido);
}
// Express: usa el cuerpo crudo, no el JSON ya parseado.
app.post('/webhooks/wm', express.raw({ type: 'application/json' }), (req, res) => {
if (!verificar(req.body.toString(), req.header('X-WM-Signature'), process.env.WM_WEBHOOK_SECRET)) {
return res.status(401).end();
}
res.status(200).end(); // responde ya; procesa después
const evento = JSON.parse(req.body);
// …
});import hmac, hashlib, time
def verificar(cuerpo_crudo: bytes, cabecera: str, secreto: str) -> bool:
partes = dict(p.split("=", 1) for p in (cabecera or "").split(",") if "=" in p)
if "t" not in partes or "v1" not in partes:
return False
esperado = hmac.new(secreto.encode(), f"{partes['t']}.".encode() + cuerpo_crudo, hashlib.sha256).hexdigest()
fresco = abs(time.time() - int(partes["t"])) < 300
return fresco and hmac.compare_digest(esperado, partes["v1"])<?php
function verificar(string $cuerpoCrudo, string $cabecera, string $secreto): bool {
parse_str(str_replace(',', '&', (string) $cabecera), $partes);
if (empty($partes['t']) || empty($partes['v1'])) return false;
$esperado = hash_hmac('sha256', $partes['t'] . '.' . $cuerpoCrudo, $secreto);
$fresco = abs(time() - (int) $partes['t']) < 300;
return $fresco && hash_equals($esperado, $partes['v1']);
}Reintentos y pausa
- Contamos como recibido cualquier respuesta
2xxen menos de 10 segundos. - Si falla, reintentamos 5 veces: al minuto, a los 5 minutos, a los 30, a las 2 horas y a las 12 horas.
- Diez entregas agotadas seguidas pausan el webhook. Lo reactivas desde el panel; también puedes reintentar las fallidas desde ahí.
- Las entregas se guardan 7 días: en el panel ves cada una con su respuesta.
Probar en local
- Levanta tu servidor en local.
- Abre un túnel (
ngrok http 3000o Cloudflare Tunnel) y copia la URLhttps. - Crea el webhook con esa URL y
contact.created. - Crea un contacto desde el panel o con
POST /contacts: en segundos verás elPOSTen tu consola.