Webhooks salientes
Mosend dispara webhooks HTTP POST hacia tu URL cada vez que ocurre un evento relevante. Los gestionas desde el dashboard (Configuración → Integraciones → Webhooks) o vía API.
Firma HMAC
Cada request lleva el header X-Mosend-Signature con HMAC SHA-256 del body crudo, usando el secreto que recibiste al crear el webhook. Tu endpoint debe validarlo antes de procesar el evento.
# Node.js — validación del HMAC
import crypto from 'node:crypto';
function verify(body, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body) // body CRUDO: el mismo byte a byte que llegó
.digest('hex');
const a = Buffer.from(signature ?? '');
const b = Buffer.from(expected);
// timingSafeEqual lanza si los largos difieren: compáralos antes.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Con el SDK de TypeScript no escribes el HMAC a mano: parseWebhookEvent valida la firma (timing-safe) y te devuelve el evento tipado.
import { parseWebhookEvent, MosendWebhookSignatureError } from '@moshipp/mosend-sdk';
// body = Buffer/string CRUDO del request (sin parsear)
try {
const event = parseWebhookEvent(
body,
req.header('X-Mosend-Signature'),
process.env.MOSEND_WEBHOOK_SECRET,
);
// event.type → 'message.new' | 'message.status' | ...
} catch (err) {
if (err instanceof MosendWebhookSignatureError) return res.sendStatus(401);
throw err;
}Estructura del payload
Todos los eventos llegan con el mismo sobre: type (el evento), organizationId, data (el contenido) y timestamp. El tipo de evento también viaja en el header X-Mosend-Event, y la firma en X-Mosend-Signature.
POST tu-url
X-Mosend-Event: message.new
X-Mosend-Signature: sha256=<hmac-del-body-crudo>
Content-Type: application/json
{
"type": "message.new",
"organizationId": "a1b2c3d4-...",
"timestamp": "2026-09-17T03:42:18.123Z",
"data": {
"conversationId": "...",
"phoneNumberId": "...",
"messageId": "...",
"direction": "IN",
"messageType": "text",
"text": "Hola, ¿tienen stock?",
"contactWaId": "573001234567",
"contactName": "Ana",
"receivedAt": "2026-09-17T03:42:17.900Z"
}
}Compatibilidad. Varios eventos históricos mandaban sus campos sueltos en la raíz del body, sin type ni data. Esos campos siguen estando en la raíz además de dentro de data, así que las integraciones que ya los leen no se rompen. Para código nuevo, usa siempre data.
Tu endpoint debe responder 2xx en menos de 10 s. Si falla, Mosend reintenta con backoff exponencial hasta 8 veces (≈30 minutos). Después marca el delivery como FAILED y queda en el log.
Eventos disponibles
27 eventos, agrupados por familia. La columna Campos de data lista lo que trae data; el sobre (type, organizationId, timestamp) es igual en todos.
Mensajes
| Evento | Cuándo se dispara |
|---|---|
| message.new | Entra un mensaje del cliente o sale uno confirmado por Meta. Cubre WhatsApp y Web Chat.data: conversationId, phoneNumberId, messageId, direction, messageType, text, contactId, contactWaId, contactName, receivedAt |
| message.status | Un mensaje que enviaste cambia de estado. Solo cuando el estado avanza: un delivered que llega tarde, después del read, no vuelve a disparar.data: messageId, conversationId, phoneNumberId, status, metaMessageId |
Conversaciones
| Evento | Cuándo se dispara |
|---|---|
| conversation.handoff_requested | La conversación escala a una persona (palabra clave del cliente, decisión del bot, regla o flujo). Solo el primer disparo: los recordatorios internos al asesor no lo repiten.data: conversationId, phoneNumberId, contact {id, waId, name}, reason, requestedAt |
| conversation.unanswered | Un mensaje del cliente lleva N minutos sin respuesta humana (umbral configurable, 2 min por defecto). El bot, las auto-respuestas y las plantillas no cuentan como respuesta. Una sola vez por mensaje y solo en conversaciones abiertas.data: conversationId, phoneNumberId, contactId, contactWaId, contactName, pendingCount, text, firstMessageReceivedAt, minutesSinceFirst, thresholdMinutes, messages[] {id, type, text, receivedAt, minutesElapsed} |
| conversation.closed | La conversación pasa a cerrada, a mano o por el cron de inactividad. Es la señal de «el humano terminó»: útil si un bot externo debe retomar.data: conversationId, phoneNumberId, contactId, contactWaId, contactName, channel, category, categorySource, resolutionOutcome, resolutionSource, closeReason, closedByUserId, closedAt, createdAt, firstAssignedAt, firstResponseAt, firstResponseBusinessMin, resolutionDurationMin |
| conversation.assignment_changed | Cambia quién tiene la conversación: se asigna, se toma, se libera o se devuelve al bot. Un bot externo la usa para saber que un humano la soltó sin esperar al cierre.data: conversationId, phoneNumberId, assigneeUserId (null = liberada o devuelta al bot), previousAssigneeUserId, mode (assign | claim | release | return_to_bot), by, at |
Equipo
Evento de la organización: no lleva número, así que un webhook restringido por número no lo recibe.
| Evento | Cuándo se dispara |
|---|---|
| agent.status_changed | Un asesor cambia de estado de jornada. Con `agentStatuses` eliges qué estados avisan; vacío = todos.data: agentUserId, agentName, agentEmail, fromStatus, toStatus, sessionId, changedAt, changedAtLocal, timezone, closeReason, workedMs, lunchMs, breakMs, note |
Canales y plantillas
| Evento | Cuándo se dispara |
|---|---|
| channel.disconnected | Un número en coexistencia se desconecta: se desvinculó desde la app, cambió de número, se re-registró en otro equipo, o Meta lo dio de baja.data: phoneNumberId, wabaId, displayPhoneNumber, verifiedName, reason, initiatedBy, metaEvent, at |
| channel.reconnected | El número vuelve a estar operativo. Contraparte del anterior (sin `reason`: una reconexión no tiene motivo que reportar).data: phoneNumberId, wabaId, displayPhoneNumber, verifiedName, metaEvent, at |
| template.status | Meta aprueba, rechaza o pausa una plantilla. Sale tanto del webhook de Meta como de la sincronización periódica.data: templateId, wabaId, name, language, status, previousStatus, metaStatus, reason (el motivo del rechazo solo viene por el webhook de Meta; por sincronización llega null) |
| quality.changed | Cambia la calificación de calidad que Meta le da al número.data: phoneNumberId, rating (GREEN | YELLOW | RED), reason |
Agenda de citas
Los cuatro comparten forma exacta; el evento se distingue por `type` (o por la cabecera X-Mosend-Event).
| Evento | Cuándo se dispara |
|---|---|
| appointment.created | Se agenda una cita, sea por el bot, por un agente o por la API.data: appointmentId, status, appointmentTypeId, appointmentTypeName, startAt, endAt, contact {id, waId, name}, phoneNumberId, conversationId, at |
| appointment.rescheduled | La cita se mueve a un horario nuevo.data: igual que appointment.created |
| appointment.cancelled | Se cancela la cita.data: igual que appointment.created |
| appointment.reminded | Se envió al cliente el recordatorio de su cita.data: igual que appointment.created |
Ventas de licencias
Traen llaves de licencia en claro: exige HTTPS en tu endpoint y trátalo como dato sensible.
| Evento | Cuándo se dispara |
|---|---|
| sale.completed | Se completó una venta de licencia digital, con entrega automática o por asesor.data: orderId, reference, status, provider, paymentMethod, product, variant, quantity, amountCents, currency, licenseKeys[], items[] {product, variant, quantity, unitPriceCents, pending, licenseKeys[]} (null si la orden fue de un solo producto), phoneNumberId, contact, conversationId, createdAt, paidAt, deliveredAt, paid, paymentPending |
| license.key_replaced | Se reemplazó una llave que no le funcionó al cliente: la vieja queda revocada y la nueva es la vigente.data: orderId, reference, product, variant, reason, oldKey, newKey, replacedAt, phoneNumberId, contact, conversationId |
Cuenta y facturación
Eventos de la organización: no llevan número ni cuenta de WhatsApp, así que un webhook restringido por número NO los recibe. Para estos, deja un webhook sin `phoneNumberIds`.
| Evento | Cuándo se dispara |
|---|---|
| trial.expiring | Faltan pocos días para que termine el periodo de prueba.data: organizationId, trialEndsAt, daysRemaining |
| trial.expired | Terminó el periodo de prueba.data: organizationId, trialEndsAt |
| invoice.issued | Se emitió una factura. `kind` dice cuál: `plan` (cobro del plan) o `period` (cierre de ciclo de consumo).data: invoiceId, number, total, currency, status, dueAt, kind; con kind=period además debitedFromWallet y remaining |
| invoice.paid | Se registró el pago de una factura.data: invoiceId, number, total, currency, method |
| invoice.overdue | Una factura pasó su vencimiento sin pago.data: invoiceId, number, total, currency, dueAt, daysOverdue |
| invoice.payment_rejected | Un intento de cobro fue rechazado.data: invoiceId, number, amount (lo que se intentó cobrar), currency, method |
| invoice.refunded | Se devolvió el dinero de una factura.data: invoiceId, number, amount, currency |
| organization.suspended | La organización quedó suspendida por facturas vencidas.data: reason, daysThreshold |
| organization.reactivated | La organización volvió a estar activa tras ponerse al día.data: reason |
| usage.threshold_reached | El consumo del periodo alcanzó uno de los umbrales configurados.data: metric, threshold, used, limit, percent |
Nombres antiguos que siguen funcionando
Versiones anteriores de esta página y del SDK listaban eventos que el backend nunca emitió. Si tu webhook está suscrito con uno de esos nombres, lo aceptamos como alias del evento real — no hace falta que cambies nada, aunque el body llega con el nombre real en type:
conversation.assigned→conversation.assignment_changedphone.quality→quality.changed
conversation.opened y conversation.updated no tienen equivalente: no existe un webhook de apertura ni uno genérico de cambio. Para saber que una conversación empezó, usa message.new con direction: "IN"; para los cambios de estado, conversation.closed, conversation.assignment_changed y conversation.handoff_requested.
Restringir por número (phoneNumberIds)
Por defecto un webhook recibe eventos de todos los números de la org. Si tu integración solo debe enterarse de un número (por ejemplo, un bot externo que opera un único WhatsApp), pasa phoneNumberIds al crear o editar el webhook con los UUID de Mosend de los números permitidos. Vacío o ausente = todos.
# Crear un webhook acotado a un solo número
curl -X POST 'https://api.mosend.dev/organizations/{orgId}/webhooks-outbound' \
-H 'X-Api-Key: mk_live_<prefix>.<secret>' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://tu-app.com/webhooks/mosend",
"events": ["message.new", "message.status", "conversation.handoff_requested"],
"phoneNumberIds": ["<UUID-del-numero>"]
}'Cómo decide Mosend si entregar a un webhook con scope:
- El payload trae
phoneNumberId(mensajes, conversaciones, canales, citas, ventas) → entrega solo si está en la lista permitida. - No trae número pero sí
wabaId(template.status) → entrega si esa cuenta de WhatsApp tiene al menos un número permitido. - Eventos de la organización sin número ni cuenta (
agent.status_changed, facturación, uso, prueba) → no se entregan a webhooks con scope. Para recibirlos, deja un webhook sinphoneNumberIds.
Umbral de conversation.unanswered
Si te suscribes a conversation.unanswered, puedes ajustar cuántos minutos espera Mosend antes de avisarte con unansweredThresholdMinutes (1–1440, default 2). Solo cuenta como "respondido" un mensaje de un asesor humano — el bot IA, las auto-respuestas y las plantillas no cierran el reloj. Se emite una sola notificación por mensaje sin responder, y solo para conversaciones OPEN.
Filtrar agent.status_changed por estado
Con agentStatuses eliges qué estados de jornada disparan el evento: ONLINE, LUNCH, BREAK, MEETING, TRAINING, ENDED. Vacío o ausente = todos.
Idempotencia
Mosend reintenta cualquier entrega que no reciba un 2xx, así que tu endpoint puede recibir el mismo evento más de una vez. El payload no incluye un id de entrega: deduplica por el id del recurso dentro de data — por ejemplo data.messageId para message.new y message.status, data.conversationId para los de conversación, o data.invoiceId para los de facturación.
Formato Microsoft Teams
Si el destino es un canal de Teams, crea el webhook con format: "TEAMS" (o deja que Mosend lo detecte por la URL): el evento se convierte en una Adaptive Card que Teams sí renderiza. En ese formato no se mandan X-Mosend-Signature ni X-Mosend-Event, porque Teams ignora las cabeceras propias. Para tu propio servidor usa format: "GENERIC", que es el JSON descrito arriba.