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

EventoCuándo se dispara
message.newEntra 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.statusUn 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

EventoCuándo se dispara
conversation.handoff_requestedLa 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.unansweredUn 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.closedLa 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_changedCambia 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.

EventoCuándo se dispara
agent.status_changedUn 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

EventoCuándo se dispara
channel.disconnectedUn 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.reconnectedEl 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.statusMeta 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.changedCambia 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).

EventoCuándo se dispara
appointment.createdSe 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.rescheduledLa cita se mueve a un horario nuevo.data: igual que appointment.created
appointment.cancelledSe cancela la cita.data: igual que appointment.created
appointment.remindedSe 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.

EventoCuándo se dispara
sale.completedSe 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_replacedSe 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`.

EventoCuándo se dispara
trial.expiringFaltan pocos días para que termine el periodo de prueba.data: organizationId, trialEndsAt, daysRemaining
trial.expiredTerminó el periodo de prueba.data: organizationId, trialEndsAt
invoice.issuedSe 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.paidSe registró el pago de una factura.data: invoiceId, number, total, currency, method
invoice.overdueUna factura pasó su vencimiento sin pago.data: invoiceId, number, total, currency, dueAt, daysOverdue
invoice.payment_rejectedUn intento de cobro fue rechazado.data: invoiceId, number, amount (lo que se intentó cobrar), currency, method
invoice.refundedSe devolvió el dinero de una factura.data: invoiceId, number, amount, currency
organization.suspendedLa organización quedó suspendida por facturas vencidas.data: reason, daysThreshold
organization.reactivatedLa organización volvió a estar activa tras ponerse al día.data: reason
usage.threshold_reachedEl 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_changed
  • phone.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 sin phoneNumberIds.

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.