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

29 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
messaging_limit.changedMeta sube o baja el límite diario de clientes a los que se puede escribir con plantillas (es del portafolio: lo comparten todos sus números). Llega por el aviso de Meta o por la consulta diaria.data: wabaId, previousTier, tier (TIER_250 | TIER_2K | TIER_10K | TIER_100K | TIER_UNLIMITED), dailyLimit (null = ilimitado), source (WEBHOOK | REFRESCO)
account.alertMeta niega, aplaza o pide más información para subir el límite, aprueba o rechaza la insignia verde, o borra la foto de perfil del número.data: wabaId, type (INCREASED_CAPABILITIES_ELIGIBILITY_DEFERRED | …_FAILED | …_NEED_MORE_INFO | OBA_APPROVED | OBA_REJECTED | PROFILE_PICTURE_LOST), severity, status, description, entityType, entityId

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, paymentChannel {type, id, name, bank, account, confirmedBy}, 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, paymentPendingVer el payload completo y cómo validarlo
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

Ventas: sale.completed completo

Se envía cuando se entrega una venta de licencia (automática o por un asesor). Si la venta se entregó fiada, vuelve a enviarse cuando se marca pagada, ya con paid: true y el medio por el que entró el dinero. Usa orderId para no registrarla dos veces.

POST tu-url
X-Mosend-Event: sale.completed
X-Mosend-Signature: sha256=<hmac-del-body-crudo>
Content-Type: application/json

{
  "type": "sale.completed",
  "organizationId": "782ac3a2-…",
  "timestamp": "2026-10-02T20:41:07.512Z",
  "data": {
    "orderId": "0b9c…",
    "reference": "lic_7Hq…",
    "status": "DELIVERED",
    "provider": "transferencia",
    "paymentMethod": "TRANSFERENCIA",
    "paymentChannel": {
      "type": "cuenta",
      "id": "5f1e…",
      "name": "Bancolombia ahorros",
      "bank": "Bancolombia",
      "account": "91235305327",
      "confirmedBy": "COMPROBANTE"
    },
    "product": { "id": "p1…", "name": "Office 365 Pro Plus" },
    "variant": null,
    "quantity": 1,
    "amountCents": 6500000,
    "currency": "COP",
    "licenseKeys": ["XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"],
    "items": null,
    "phoneNumberId": "d1161c60-…",
    "contact": { "id": "c1…", "waId": "573001112233", "name": "Ana" },
    "conversationId": "cv1…",
    "createdAt": "2026-10-02T20:31:55.004Z",
    "paidAt": "2026-10-02T20:40:58.317Z",
    "deliveredAt": "2026-10-02T20:41:07.400Z",
    "paid": true,
    "paymentPending": false
  }
}

Los importes van en centavos (6500000 = $65.000). Por compatibilidad, los campos de data también llegan sueltos en la raíz; lee siempre los de data.

Por dónde entró el dinero: paymentChannel

paymentMethod solo dice el tipo de cobro (TRANSFERENCIA, NEQUI…) y se mantiene igual para no romper integraciones existentes. paymentChannel dice exactamente a qué cuenta, llave o pasarela entró, con el nombre que le pusiste en Licencias → Cobros. Una cuenta o llave nueva aparece sola, con su nombre.

CampoQué trae
typellave · cuenta · pasarela (Wompi: Nequi, Daviplata o tarjeta) · cripto (Binance) · manual
idId de la cuenta o la llave en Mosend. null en pasarela, cripto y manual. Úsalo para mapear cada medio en tu sistema: no cambia aunque le cambies el nombre.
nameEl nombre que le diste al medio: «Bancolombia ahorros», «Nequi», «Llave Licencia Digital». Para Binance y la pasarela, el que definas en su panel (si no, «Binance», «Nequi», «Daviplata», «Tarjeta»).
bankLa entidad: Bancolombia, Nequi, Daviplata, Binance… Wompi en los pagos con tarjeta. null en ventas manuales.
accountNúmero de cuenta, la llave (@usuario) o el Pay ID de Binance. null en pasarela y manual.
confirmedByAVISO (llegó el aviso del banco) · COMPROBANTE (captura del cliente o del asesor) · MANUAL (lo confirmó una persona sin prueba) · null (cobrado por la pasarela, o ventas anteriores a este campo).

Validarlo y leerlo

// Express — el body debe llegar CRUDO para poder verificar la firma
import express from 'express';
import crypto from 'node:crypto';

const app = express();
app.post('/webhooks/mosend', express.raw({ type: 'application/json' }), (req, res) => {
  const firma = req.header('X-Mosend-Signature') ?? '';
  const esperada = 'sha256=' + crypto
    .createHmac('sha256', process.env.MOSEND_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const ok = firma.length === esperada.length &&
    crypto.timingSafeEqual(Buffer.from(firma), Buffer.from(esperada));
  if (!ok) return res.sendStatus(401);

  const evento = JSON.parse(req.body.toString('utf8'));
  if (evento.type === 'sale.completed') {
    const { orderId, paid, amountCents, currency, paymentChannel } = evento.data;
    // paymentChannel.id → tu cuenta contable; paymentChannel.name → lo que ves en Mosend
    registrarVenta({ orderId, paid, amountCents, currency, medio: paymentChannel.name });
  }
  res.sendStatus(200); // responde 2xx en menos de 10 s
});

Para probar sin vender, usa POST /webhooks-outbound/:id/test (envía un webhook.test firmado) o revisa los envíos y sus respuestas en Configuración → Integraciones → Webhooks.

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.