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)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  );
}

Con el SDK de TypeScript no escribís el HMAC a mano: parseWebhookEvent valida la firma (timing-safe) y te devuelve el evento tipado como unión discriminada.

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.event → 'message.new' | 'message.status' | ...
  // deduplicá por event.deliveryId (puede reintentarse)
} catch (err) {
  if (err instanceof MosendWebhookSignatureError) return res.sendStatus(401);
  throw err;
}

Estructura del payload

El cuerpo es JSON con type (el evento), organizationId, data (el recurso) 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-...",
  "data": {
    "conversationId": "...",
    "message": { "id": "...", "direction": "IN", "type": "text", "payload": {...}, ... }
  },
  "timestamp": "2026-05-01T03:42:18.123Z"
}

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

EventoCuándo se dispara
message.newLlega un mensaje entrante o se envía uno saliente confirmado por Meta. Trae el mensaje completo.
message.statusCambio de estado de un mensaje saliente (sent → delivered → read, o failed). Trae messageId y phoneNumberId.
conversation.handoff_requestedUna conversación pide pasar a asesor humano (keyword, decisión del bot IA, regla o flujo). Solo el primer trigger — los recordatorios internos no re-pingean.
conversation.unansweredUn mensaje del cliente lleva N minutos sin respuesta humana (umbral configurable, default 2 min). Bot IA / auto-respuestas / templates NO cuentan como respondido. Una sola notificación por mensaje, solo en conversaciones OPEN.
conversation.closedLa conversación pasa a CLOSED, sea manual (el agente cerró) o automática (cron de inactividad). Trae category, resolutionOutcome, closeReason y métricas de duración. Es la señal de "el humano terminó" — útil si un bot externo debe retomar tras el cierre.
conversation.assignment_changedCambia la asignación de una conversación: se asigna a un agente, se toma, se libera o se devuelve al bot. Trae assigneeUserId (el nuevo dueño; null = liberada o devuelta al bot), previousAssigneeUserId y mode (assign / claim / release / return_to_bot). Es la señal de "quién tiene la conv ahora" — un bot externo la usa para saber cuándo un humano la soltó sin esperar a que se cierre.
agent.status_changedUn asesor cambia de estado de jornada (activo, almuerzo, pausa, reunión, capacitación, fin). Evento a nivel ORG (no se filtra por número); qué estados notifican se controla con agentStatuses.
channel.disconnectedUn número (coexistencia) se desconecta de Cloud API: el cliente desvinculó el partner, cambió/re-registró el número, o Meta lo offboardeó. Trae reason e initiatedBy.
channel.reconnectedEl número previamente desconectado vuelve a estar operativo. Contraparte de channel.disconnected.
template.statusCambio de estado de plantilla (PENDING → APPROVED/REJECTED) por webhook de Meta. Trae wabaId.
quality.changedCambio del quality rating del número (GREEN / YELLOW / RED).
appointment.createdSe agenda una cita (por el bot, un agente o la API). Trae appointmentId, servicio, start/end, contacto y phoneNumberId.
appointment.rescheduledSe reprograma una cita a un nuevo horario.
appointment.cancelledSe cancela una cita.
appointment.remindedSe envió el recordatorio de una cita al cliente (según la configuración de la agenda).
sale.completedSe completó una venta de licencia digital (entrega automática o por asesor). Trae orderId, reference, product, variant, quantity, amountCents, currency, paymentMethod, provider, status, contact, licenseKeys (las llaves entregadas, descifradas), items (por línea si fue carrito) y phoneNumberId (top-level; un hook acotado a números entrega si el número de la venta está en su lista).
license.key_replacedSe reemplazó una llave entregada que no le funcionó al cliente. Trae orderId, reference, product, variant, reason, oldKey y newKey (ambas descifradas — la vieja queda revocada, la nueva es la vigente), replacedAt, contact y phoneNumberId (top-level, se filtra por número). Úsalo para sincronizar la licencia del cliente en tu sistema.

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 (ej. un bot externo que opera un único WhatsApp), pasá 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 (la mayoría: message.*, conversation.*) → entrega solo si está en la lista permitida.
  • No trae número pero sí wabaId (template.status) → entrega si la WABA tiene al menos un número permitido.
  • Eventos globales de la org sin número ni WABA (facturación, uso, etc.) → 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.

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.message.id para message.new / message.status, o data.conversationId para los eventos de conversación.