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;
}Formato simple
Al crear o editar el webhook puedes elegir JSON simple: el mismo evento sin repeticiones (no copia data en la raíz) y, en las ventas, cada producto con sus licencias, la llave y el dato de activación juntos. Mismas cabeceras y firma. Recomendado para integraciones nuevas; el formato completo sigue igual para quien ya lo lee.
{
"type": "sale.paid",
"eventId": "evt_b4b98fcae6be43adae38440a7219584a",
"timestamp": "2026-10-27T03:21:15.019Z",
"organizationId": "782ac3a2-...",
"data": {
"orderId": "2169d078-...",
"reference": "lic_0ddcdc9f-...",
"status": "PAID",
"trigger": "PAYMENT",
"paid": true,
"paidAt": "2026-10-27T03:21:04.408Z",
"deliveredAt": null,
"amountCents": 10000000,
"currency": "COP",
"payment": { "method": "TRANSFERENCIA", "via": "Daviplata", "bank": "Daviplata", "confirmedBy": "MANUAL", "pending": false },
"customer": { "name": "Jesus Medina", "email": null, "phone": "+573104183585" },
"items": [
{
"productId": "4e8f8663-...", "sku": "O365-P", "name": "Office 365 Personal — 1 usuario, 1 año",
"variant": null, "quantity": 2, "unitPriceCents": 5000000, "pending": true,
"dataLabel": "Correo personal",
"licenses": [
{ "key": null, "data": "cliente1@gmail.com" },
{ "key": null, "data": "cliente2@gmail.com" }
]
}
],
"dataComplete": true,
"conversationId": "54dbfffe-..."
}
}key es null mientras la licencia no se ha entregado (entrega asistida): llega en sale.completed. Sin llaves en el webhook, licenses trae solo data.
Estructura del payload
Todos los eventos llegan con el mismo sobre: type (el evento), eventId, organizationId, data (el contenido) y timestamp. El tipo de evento también viaja en el header X-Mosend-Event, el id en X-Mosend-Event-Id y la firma en X-Mosend-Signature. El eventId no cambia entre reintentos: úsalo para no procesar dos veces el mismo evento.
POST tu-url
X-Mosend-Event: message.new
X-Mosend-Event-Id: evt_3f9c2a7b1d4e4c8a9b0e6f1a2c3d4e5f
X-Mosend-Signature: sha256=<hmac-del-body-crudo>
Content-Type: application/json
{
"type": "message.new",
"eventId": "evt_3f9c2a7b1d4e4c8a9b0e6f1a2c3d4e5f",
"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
36 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 |
| messaging_limit.changed | Meta 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.alert | Meta 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).
| 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 |
| appointment.completed | El equipo marcó la cita como atendida.data: igual que appointment.created (status: COMPLETED) |
| appointment.no_show | El equipo marcó que el cliente no llegó a la cita.data: igual que appointment.created (status: NO_SHOW) |
Ventas de licencias
Traen llaves de licencia en claro: exige HTTPS en tu endpoint y trátalo como dato sensible. Si el destino no necesita las llaves (un ERP, una hoja de cálculo, una automatización), desmarca «Incluir las llaves de licencia» al crear el webhook: llega el mismo evento sin licenseKeys, oldKey ni newKey.
| 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, paymentChannel {type, id, name, bank, account, confirmedBy}, product, variant, quantity, amountCents, currency, licenseKeys[], items[] {product, variant, quantity, unitPriceCents, pending, licenseKeys[], customerData[]} (siempre una lista: con un solo producto, una entrada), phoneNumberId, contact, conversationId, createdAt, paidAt, deliveredAt, paid, paymentPending, confirmedBy, groupedCharge {number, amountCents, orders} (null si no se pagó en un cobro agrupado), customerData [{ label, value, licenseKey }] (el dato que pide el producto —el correo donde se activa…— UNO POR LICENCIA y en orden, con la llave que le tocó; value null = aún falta; en un carrito también va en cada items[].customerData), customerDataComplete (false si falta alguno)Ver el payload completo y cómo validarlo |
| sale.paid | Se pagó una venta: en cada pago, sea por pasarela (Wompi, Nequi, PayPal), por transferencia validada o confirmado a mano, y también cuando se paga un fiado ya entregado (solo, en un cobro agrupado o por el aviso del banco). Llega una sola vez por pedido. Si registras la venta con sale.completed, usa este para registrar el pago: llega con el mismo orderId.data: Lo mismo que sale.completed (product con id y sku, quantity, conversationId, customerData), más customer { name, email, phone }, paid: true, paidAt, confirmedBy (AVISO | COMPROBANTE | MANUAL | SALDO), groupedCharge cuando se pagó junto con otros pedidos y trigger: PAYMENT (el pago) o DATA_COMPLETED (sale otra vez cuando se completan o corrigen los datos de activación de una venta ya pagada). |
| sale.refunded | Se registró el reembolso de una venta pagada o entregada (botón «Reembolsar» del pedido o POST …/license-delivery/orders/:orderId/refund). Las llaves entregadas quedan revocadas en Mosend: úsalo para desactivarlas en tu sistema. Llega con el mismo orderId que sale.completed y sale.paid.data: Lo mismo que sale.completed, con status REFUNDED, refundedAt, refundReason, refundTo (DINERO: se le devolvió la plata; SALDO: quedó a su favor; null: era un fiado sin pagar y se anuló) y en licenseKeys las llaves que se revocaron. |
| customer.balance_changed | Se movió el saldo a favor de un cliente: un reembolso que quedó a su favor, un pago recibido abonado, una recarga pagada, una compra con saldo, una devolución en dinero o un ajuste. Un evento por movimiento. Es de la organización: un webhook restringido por número no lo recibe.data: movementId, type (REEMBOLSO | PAGO_ABONADO | RECARGA | AJUSTE | COMPRA | DEVOLUCION | REVERSO), amountCents (positivo entra, negativo sale), currency, pocketBalanceCents (saldo de ese bolsillo: marca y moneda), balanceCents (todo lo que tiene a favor en esa moneda), brand {id, name} (null = sin marca), reason, source (PANEL | BOT | API | SISTEMA), order {id, reference, number}, receivedPaymentId, chargeId, customer {name, email, phone}, contact, createdAt |
| sale.item_delivered | Se entregó UN producto de un pedido de varios y el resto sigue pendiente (entrega parcial: Office hoy, Adobe y Autodesk después). sale.completed llega cuando ya no falta ninguno.data: Lo mismo que sale.completed, con itemId (el producto entregado), deliveryComplete: false y cada items[] con id, deliveredAt y pending. |
| sale.item_refunded | Se reembolsó UN producto de un pedido de varios y se conservó el resto. Si era el último, llega sale.refunded del pedido.data: Lo mismo que sale.completed, con itemId, refundedCents (lo devuelto por productos sueltos) y cada items[] con refundedAt, refundCents y refundTo (DINERO | SALDO). |
| 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 |
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.
| Campo | Qué trae |
|---|---|
| type | llave · cuenta · pasarela (Wompi: Nequi, Daviplata o tarjeta) · cripto (Binance) · manual |
| id | Id 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. |
| name | El 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»). |
| bank | La entidad: Bancolombia, Nequi, Daviplata, Binance… Wompi en los pagos con tarjeta. null en ventas manuales. |
| account | Número de cuenta, la llave (@usuario) o el Pay ID de Binance. null en pasarela y manual. |
| confirmedBy | AVISO (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_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.