Volver al blog
Desarrolladores

Catálogo de eventos webhook de Waspy: mensajes, estados y transcripción de audio

Waspy entrega cuatro eventos: message.received, message.status_changed, conversation.created y contact.created. El audio llega sin texto; se lee después con GET /messages/:id.

· 12 min
Catálogo de eventos webhook de Waspy: mensajes, estados y transcripción de audio

Respuesta corta: cada POST que Waspy manda a tu URL trae el mismo sobre: event, webhookId, deliveredAt, attempt y data. Al 8 de noviembre de 2026 los eventos implementados son cuatro: message.received, message.status_changed, conversation.created y contact.created. No existe template.approved. Un audio entra con la transcripción vacía: el texto se lee después con GET /api/v2/messages/:id.

El alta de la suscripción, el secreto y el replay están en gestionar webhooks. La firma, en verificar la firma. Los estados de un mensaje saliente, en estados. Esta nota es el catálogo del JSON.

La referencia pública está en eventos, introducción y seguridad. La base es https://api.waspytech.com/api/v2. La clave entra en Scale (US$150 por mes) y Enterprise: Planes y la API.

El sobre y los headers

{
  "event": "message.received",
  "webhookId": "uuid-de-la-suscripcion",
  "deliveredAt": "2026-11-08T18:04:00.000Z",
  "attempt": 1,
  "data": {}
}

El worker arma ese objeto en cada intento. webhookId es el id de tu suscripción, no el del mensaje. deliveredAt es la hora de este intento. attempt arranca en 1. data es el payload del evento.

HeaderValor
Content-Typeapplication/json. No se puede pisar
User-AgentWaspy-Webhooks/1.0, salvo que un header propio de la suscripción lo reemplace
X-Waspy-Signaturet=,v1= de .
X-Waspy-Webhook-IdEl UUID de la suscripción
X-Waspy-Webhook-EventEl nombre del evento
X-Waspy-Webhook-Versionv1
X-Waspy-Webhook-AttemptEl número de intento
X-Waspy-First-Attempt-AtEl unix del primer intento. No cambia en los reintentos
El t= de la firma es nuevo en cada intento. X-Waspy-First-Attempt-At queda quieto para que midas la edad del payload. Los headers propios de la suscripción viajan también en el reintento y en el POST /webhooks/:id/test. No pueden reemplazar Content-Type ni los X-Waspy-*. Cómo calcular el HMAC está en la nota de la firma.

Un 2xx o cualquier 3xx cuenta como entregado. El POST no sigue el redirect. Si tu URL falla, el worker reintenta (hasta 5 intentos, con espera de 1 minuto, 5 minutos, 30 minutos, 2 horas y 12 horas) y attempt sube. Diez fallos seguidos apagan la suscripción. El reenvío manual está en replay.

Los cuatro eventos

eventCuándo lo encola el código
message.receivedEntró un mensaje de un contacto
message.status_changedUn saliente pasó a un estado que el handler acepta
conversation.createdEl mensaje entrante abrió un hilo nuevo
contact.createdPOST /api/v2/contacts, o un entrante que acaba de crear el contacto
No hay un quinto nombre en el enum de schemas.ts. Un cambio de plantilla en Meta se guarda en la fila local y no sale por este webhook.

El orden de encolado, cuando un entrante crea todo junto, es contact.created, después conversation.created, después message.received. Cada uno es un POST distinto. No es una transacción: tu URL puede ver el mensaje antes que el contacto si un intento se retrasa. Entre dos suscripciones no hay un orden común. La doc lo dice igual.

message.received

data es el mensaje público más contact (id, name, phoneNumber). Campos que importan para enganchar tu sistema:

CampoQué es
idUUID de Waspy. Es la clave para deduplicar y para el GET de después
conversationId, contactId, phoneNumberIdHilo, contacto y canal
directioninbound
typetext, image, audio, video, document, …
contentEn un texto, { "body": "..." }. En media, el id de Meta puede estar acá antes de que exista mediaId
waMessageIdEl wamid de Meta
contact.phoneNumberE.164, por ejemplo +5491126032641
{
  "event": "message.received",
  "webhookId": "sub-uuid",
  "deliveredAt": "2026-11-08T18:04:01.000Z",
  "attempt": 1,
  "data": {
    "id": "msg-uuid",
    "conversationId": "conv-uuid",
    "contactId": "contact-uuid",
    "phoneNumberId": "channel-uuid",
    "direction": "inbound",
    "type": "text",
    "content": { "body": "Hola, tienen el talle M?" },
    "status": "delivered",
    "waMessageId": "wamid.HBgL...",
    "transcription": null,
    "transcriptionStatus": null,
    "contact": {
      "id": "contact-uuid",
      "name": "María Pérez",
      "phoneNumber": "+5491126032641"
    }
  }
}

Audio: el texto no viene en este POST

La página de eventos dice que un audio llega con transcription: null y transcriptionStatus: "pending". El worker de media pone pending después de bajar el archivo y encolar la transcripción. El message.received se encola en el post-commit del mensaje, antes de esa descarga. En el primer POST, los dos campos suelen seguir en null.

No hay un segundo evento cuando el texto queda listo. Lo leés vos:

curl -sS "https://api.waspytech.com/api/v2/messages/MSG_UUID" \
  -H "Authorization: Bearer $WASPY_API_KEY"

Scope messages:read. El :id es el UUID de data.id. Si no es UUID, 400 VALIDATION_ERROR. Si es de otra cuenta, 404 MESSAGE_NOT_FOUND.

transcriptionStatusQué hacer
null o pendingTodavía no está. Volvé a leer en unos segundos
completedtranscription tiene el texto
emptyEl audio no tenía habla reconocible
failedNo se pudo transcribir. El archivo sigue en media

Media

GET /api/v2/media/:id con media:read devuelve url, expiresIn, mimeType y fileName. El TTL por default en la config del API es 900 segundos. La URL firmada se pide de nuevo cuando vence. El primer webhook puede traer mediaId: null y mediaKey: null: mediaKey se escribe en el mensaje cuando termina la descarga, y el fan-out ya salió. Releé el GET del mensaje y, con mediaId, pedí la URL. Un id ajeno es 404 MEDIA_NOT_FOUND, un código que errors no lista. El camino largo está en bajar media entrante.

message.status_changed

Llega cuando un saliente cambia de estado y el handler acepta la transición. data repite el mensaje y suma previousStatus y newStatus. clientRef vuelve tal cual lo mandaste en POST /messages o en el batch. Si no lo mandaste, es null.

{
  "event": "message.status_changed",
  "webhookId": "sub-uuid",
  "deliveredAt": "2026-11-08T18:06:00.000Z",
  "attempt": 1,
  "data": {
    "id": "msg-uuid",
    "direction": "outbound",
    "status": "delivered",
    "previousStatus": "sent",
    "newStatus": "delivered",
    "clientRef": "pedido-1847",
    "errorCode": null,
    "errorMessage": null
  }
}

El ciclo que documenta la doc es queued → sent → delivered → read, o failed. Dos entregas HTTP pueden cruzarse: tu endpoint vio read y el delivered llega un minuto después porque el primer intento falló. Por cada data.id (o por clientRef, si es el id de tu pedido) guardá el rango más alto: read > delivered > sent. No pises un read con un delivered tardío.

failed es terminal para el integrador. Si newStatus es failed, el motivo está en errorCode (un código de Meta, por ejemplo 131026 o 131049) y en errorMessage. El handler, además, acepta un delivered o un read que llegan después de un failed (un dispositivo falló y otro recibió) y no emite un sent que llegue después de un failed: esa transición se descarta antes del fan-out. Cómo cruzar clientRef con tu fila está en clientRef.

conversation.created y contact.created

conversation.created trae el hilo (status, assignedTo, lastMessagePreview, serviceWindowExpiresAt) y el contact resumido. Sale cuando el entrante abre el hilo, no en cada mensaje siguiente.

contact.created trae id, phoneNumber, name, email, tags, customFields, optedIn, lastMessageAt y createdAt. Lo dispara POST /api/v2/contacts y también el entrante que acaba de crear la ficha. Un contacto que ya existía no vuelve a emitir este evento en cada mensaje.

Idempotencia

Deduplicá por data.id + event. El mismo mensaje puede entrar dos veces si respondiste 200 y la conexión se cortó antes de que Waspy lo anotara: el reintento trae el mismo data.id y un attempt mayor. Tratalo como la misma fila.

El emisor, además, se saltea un payload idéntico de la misma cuenta y el mismo evento si ya lo encoló en los últimos 60 segundos. Eso no reemplaza tu deduplicación: un reintento HTTP no es un emit nuevo.

function alreadySeen(event, data, seen) {
  const key = event + ':' + data.id;
  if (seen.has(key)) return true;
  seen.add(key);
  return false;
}

function rank(status) {
  return { queued: 1, sent: 2, delivered: 3, read: 4, failed: 9 }[status] || 0;
}

Para el estado, si newStatus es failed, cerrá. Si no, actualizá solo cuando rank(newStatus) sea mayor que el que ya tenés. El scope para leer el mensaje después es messages:read. Los de la suscripción están en scopes.

Waspy no le escribe solo al cliente porque llegó un webhook. Si conectás un modelo de afuera, el mensaje al cliente lo confirma una persona, o lo manda tu sistema con una plantilla que ya tenías prevista. El conector está en MCP.

Diez días de prueba, sin tarjeta, en el registro.

Tu equipo atendiendo por WhatsApp en minutos

CRM facil pero potente. En 10 minutos tenes a tu equipo trabajando — no necesitas hablar con nadie.