Enviar un solo mensaje por API: WhatsApp, Telegram y bots

Enviar un mensaje por API de WhatsApp y Telegram a un solo cliente: una solicitud, texto y adjunto propios, sin armar un envío masivo. Para códigos y avisos.

Enviar un solo mensaje por API de WhatsApp y Telegram con WSENDER
Un evento en tu sistema y, unos segundos después, el mensaje le llega al cliente

Hay mensajes que no pueden esperar al envío de la tarde. El cliente pagó su pedido. Agendó una cita. Pidió un código de verificación por teléfono mientras el operador sigue en la línea.

Un mensaje así va dirigido a una sola persona, sirve durante unos minutos y no se repite nunca. Armar un envío masivo para eso no tiene sentido: una audiencia de un número, un texto para un caso puntual, y todo para un solo mensaje.

Para esto ahora hay un método aparte. Una solicitud HTTP envía un mensaje a una persona concreta, con su propio texto y su adjunto. Aquí hablamos solo del envío puntual; los envíos masivos están en el artículo sobre la API de envíos masivos.

Cómo se ve la solicitud a la API

El esquema es simple: un evento en tu sistema → una solicitud a la API → el mensaje le llega al cliente.

Todo el método es una sola solicitud, POST /v1/message, con tres datos: desde qué cuenta enviar, a quién y qué. La clave es la misma que usas para los envíos masivos, la de la sección API del panel:

bash
curl -X POST https://api.wsender.io/v1/message \
  -H "Authorization: key TU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "channelId": "ID_DE_LA_CUENTA",
    "phone": "+52 55 1234 5678",
    "message": { "kind": "text", "text": "Tu pedido 84421 ya está pagado. Te esperamos mañana a las 12:00." },
    "idempotencyKey": "order-84421-paid"
  }'

La respuesta trae el mensaje con su identificador y su estado:

json
{
  "id": "0f3c9a6e-4b12-4d8a-9f31-6c5a71e08d42",
  "mid": null,
  "status": "Queued",
  "channelId": "ID_DE_LA_CUENTA",
  "target": "Phone",
  "phone": "525512345678",
  "dialogId": null,
  "errorReason": null,
  "sendDate": null,
  "failDate": null,
  "wait": null,
  "expiresAt": "2026-09-07T21:15:04Z",
  "idempotencyKey": "order-84421-paid"
}

El identificador de la cuenta sale de la lista de cuentas conectadas, GET /v1/channel, y idempotencyKey es tu propia clave de la solicitud; más abajo hablamos de ella. El campo dialogId se llena cuando el servicio encuentra la conversación con esa persona, y con él después puedes responder en esa misma conversación. Si ya le habías escrito, viene lleno desde el principio, en esta misma respuesta. mid aparece después del envío: es el identificador del mensaje dentro de la conversación. wait explica qué retiene el mensaje en la cola, y expiresAt indica hasta cuándo espera un mensaje que todavía no salió: pasado ese momento, termina con el motivo Expired.

La respuesta llega de inmediato, pero significa «aceptado en la cola», todavía no «entregado». El mensaje lo envía tu cuenta conectada, una cuenta real que funciona desde la aplicación de mensajería. Eso toma unos segundos, y con un número desconocido hasta un par de minutos: a las plataformas no les gusta que se verifiquen números al instante. Desde un bot es más rápido, porque envía directo por la Bot API, sin una aplicación de por medio.

Para el operador que está al teléfono, esta diferencia importa. Lo correcto es decirle al cliente «le estoy enviando el mensaje», y no «ya le llegó».

A quién enviar en WhatsApp, Telegram y bots de Telegram

El destinatario se indica de una de estas formas, y eso define si hace falta el número o no:

  • Número de teléfono en cualquier formato habitual. Es el caso más común: el número ya está en la ficha del cliente.
  • Nombre de usuario en Telegram, si tu cuenta lo conoce por una conversación anterior.
  • Conversación, por su identificador en la API o por la dirección del chat en la propia plataforma. Tu cuenta ya tiene que conocer la conversación: si la dirección es desconocida, la solicitud se rechaza en lugar de intentar el envío «a ciegas».

En la solicitud son cuatro campos y se llena exactamente uno; el servicio no adivina a quién querías escribirle:

text
"phone": "+52 55 1234 5678"
"username": "@mariagonzalez"
"dialogId": "ID_DE_CONVERSACION_DE_LA_API"
"dialogMid": "123456789"

La última opción es la dirección del chat en la propia plataforma, el chat_id en los bots de Telegram. Sirve cuando tu sistema ya guarda las direcciones de la plataforma y no quiere guardar además nuestros identificadores.

Con los bots solo funciona el envío a una conversación. Así lo marca la plataforma: un suscriptor del bot puede no tener número ni nombre de usuario visible, pero la conversación siempre existe. Además, el bot no puede escribir primero, y esa también es una regla de la plataforma.

Qué se puede enviar

Texto, imagen, video, audio, documento o tarjeta de contacto. El texto admite variantes de redacción entre corchetes: [Hola|Buenos días] se convierte en una de las dos, para que los mensajes no parezcan hechos en serie.

Aquí hay una trampa que conviene conocer antes de la primera solicitud: los corchetes se toman como spintax aunque no tengan variantes. El texto [Importante] tu código es 1234 le llega al cliente como Importante tu código es 1234, sin corchetes. Si tus plantillas usan corchetes como adorno, cámbialos por otro símbolo.

El archivo se sube una sola vez con una solicitud aparte y después se pasa por su identificador en todos los mensajes que quieras. Una lista de precios enviada a cien clientes ocupa el espacio de un solo archivo. Las restricciones son simples: hasta 10 MB por archivo, y un archivo que no se usa se guarda un día y se borra solo.

La subida es un formulario común con el archivo; en la respuesta, el campo que importa es id:

bash
curl -X POST https://api.wsender.io/v1/file \
  -H "Authorization: key TU_TOKEN" \
  -F "file=@lista-precios-2026.pdf"

Si el archivo ya está en un enlace público, en lugar del formulario puedes usar POST /v1/file/url con el campo url, y el servidor lo descarga por su cuenta. Luego el identificador va dentro del mensaje, y el tipo se indica con el campo kind: text, image, video, audio, file o contact. La imagen, el video y el documento llevan el pie caption, y cualquier adjunto puede llevar el nombre fileName que verá el destinatario:

json
{
  "channelId": "ID_DE_LA_CUENTA",
  "phone": "+52 55 1234 5678",
  "message": {
    "kind": "file",
    "fileId": "ID_DEL_ARCHIVO_SUBIDO",
    "fileName": "Lista de precios de temporada",
    "caption": "Hola, te comparto la lista de precios que comentamos. Los precios valen hasta fin de mes."
  },
  "idempotencyKey": "price-request-1187"
}

El nombre es opcional: si no lo pasas, se usa el del archivo subido, y la extensión se agrega siempre, no hace falta indicarla. Una imagen se envía igual, con "kind": "image", y la tarjeta de contacto no necesita archivo: { "kind": "contact", "phone": "+52 55 0000 0000" }.

La única excepción: la tarjeta de contacto no se puede enviar desde un bot, ese tipo de mensaje solo lo envían las cuentas de mensajería. La solicitud se rechaza en el momento, en vez de perderse en silencio durante el envío.

Cómo saber si el mensaje llegó

La respuesta a la solicitud dijo «aceptado». A partir de ahí, el servicio te avisa cómo terminó todo con tres eventos de webhook:

  • message.sent. El mensaje salió hacia la plataforma.
  • message.failed. No se pudo enviar, y el evento trae el motivo: el destinatario bloqueó la cuenta, no se le puede escribir primero, la cuenta que envía se desconectó. O bien el envío chocó durante varios minutos con una falla temporal de la plataforma y se acabaron los intentos: ese mensaje simplemente se puede volver a poner en la cola.
  • message.notfound. El destinatario no existe en la plataforma: no hay ese número ni ese usuario. Es un evento propio, distinto de los errores. No hay nada que corregir, pero conviene marcar el contacto en tu base.

La suscripción a estos eventos es explícita. Indica la dirección del webhook y agrega estos eventos a su filtro: si no lo haces, no llegan, aunque el webhook ya esté configurado para los eventos de los envíos masivos. Es a propósito. A quien manda cien mensajes al día le sirve un aviso por cada uno, y quien hace envíos a decenas de miles de destinatarios no debería recibir ese flujo por accidente.

Qué trae el evento: el identificador del mensaje, la cuenta que envió, el destinatario tal como lo indicaste, el estado, el motivo de la falla y las fechas. Y lo más importante: tu propia clave, la misma que mandaste en la solicitud. Con ella encuentras el pedido en tu sistema sin guardar nuestros identificadores.

Así se ve el evento de una falla, cuando el destinatario bloqueó tu cuenta:

json
{
  "id": "7d1e0b58-3a44-4f2c-9c81-15b0f7a9d6e3",
  "type": "message.failed",
  "date": "2026-09-07T09:15:42Z",
  "payload": {
    "message": {
      "id": "0f3c9a6e-4b12-4d8a-9f31-6c5a71e08d42",
      "channelId": "ID_DE_LA_CUENTA",
      "phone": "525512345678",
      "status": "Failed",
      "errorReason": "Blocked",
      "failDate": "2026-09-07T09:15:41Z",
      "idempotencyKey": "order-84421-paid"
    }
  }
}

El motivo indica qué hacer. Blocked y RestrictedSend: a esa persona no se le puede escribir desde esa cuenta. ChannelDisconnected: la cuenta se desconectó, vuelve a conectarla y envía de nuevo. Expired y Unavailable: no se logró enviar dentro del plazo, y el mensaje se puede poner otra vez en la cola con una clave nueva.

Al armar el receptor, ten en cuenta tres cosas:

  1. Un mismo evento puede llegar dos veces. Por ejemplo, tu servidor ya lo recibió, pero la respuesta se perdió en el camino. Revisa el id del evento y no repitas la acción en tu sistema.
  2. Cuando cancelas un mensaje tú mismo, no llega ningún evento. Ya recibiste la respuesta a esa solicitud, y no tiene sentido avisarte dos veces de algo que hiciste tú.
  3. El resultado se puede consultar por API. Las fallas temporales de envío las maneja el servicio. Si el mensaje no se pudo enviar dentro del plazo, pasa al estado Failed, y si tienes un webhook activo configurado, se crea el evento message.failed con el motivo. Si tu receptor no está disponible, puede que el evento no llegue; el resultado actual sigue disponible en GET /v1/message/{id}.

La entrega del webhook se reintenta con pausas cada vez más largas, pero cada evento se guarda como máximo un día. Tras un día de errores sin ninguna entrega exitosa, el webhook se pausa y los eventos nuevos dejan de acumularse.

Revisa health en la respuesta de GET /v1/webhook, corrige el receptor y llama a POST /v1/webhook/resume. Si la verificación sale bien, se reanuda la entrega de los eventos que se conservaron; los que se perdieron durante la pausa y los que vencieron no se recuperan.

Si no quieres trabajar con webhooks, puedes consultar el estado del mensaje por su identificador: GET /v1/message/{id} devuelve la misma estructura que la respuesta al envío, con el status actual: Queued, Sending, Sent, Failed o NotFound. Eso sí, justo después de la solicitud el estado será «en cola», y con una sola consulta no vas a saber el resultado: tendrás que volver a preguntar. El método no tiene un modo de «esperar hasta que se envíe», y es a propósito: mantener tu solicitud HTTP abierta un minuto es peor que mandarte un evento. Por eso los webhooks son el camino recomendado aquí.

Responder a un mensaje entrante con el mismo método

El método no sirve solo para mensajes salientes. Cuando un cliente escribe, el servicio avisa con el evento message.received, que trae el texto, el remitente y la conversación. Después respondes con la misma solicitud de envío, indicando como destinatario la conversación del evento. Así, con un solo método armas un intercambio en ambos sentidos: tu propia bandeja de entrada, un chatbot o la integración con tu CRM. Si no necesitas una bandeja propia, a los operadores les alcanza con Dialogs, que se abre desde el panel: ahí está la misma conversación, y las respuestas enviadas por API aparecen firmadas como «API».

La respuesta se puede enviar citando un mensaje concreto, como en la propia app de mensajería cuando contestas algo que se dijo a mitad de la conversación. Para eso, la solicitud lleva el identificador del mensaje citado: viene tanto en el evento del mensaje entrante como en el historial de la conversación que devuelve la API.

Es el mismo POST /v1/message, solo que el destinatario es la conversación y se agrega el campo replyToMid:

json
{
  "channelId": "ID_DE_LA_CUENTA",
  "dialogId": "ID_DE_CONVERSACION_DEL_EVENTO",
  "replyToMid": "MID_DEL_MENSAJE_DEL_CLIENTE",
  "message": { "kind": "text", "text": "Sí, lo tenemos disponible. ¿Te lo apartamos hasta la tarde?" },
  "idempotencyKey": "reply-5c2e-1"
}

Los dos identificadores vienen en el evento del mensaje entrante: la conversación en payload.dialog.id y el mensaje en payload.message.mid. Solo se puede citar un mensaje de la misma conversación. Si es de otra, el servicio rechaza la solicitud en el momento, en vez de perder la cita en silencio durante el envío. En WhatsApp, en Telegram y en los bots de Telegram se puede citar cualquier tipo de mensaje.

Puede pasar que borren el mensaje original después de tu solicitud. En ese caso, la cuenta de mensajería envía el mensaje normal, sin cita: no vale la pena perder el texto por un detalle de formato. Los bots no hacen esto: el mensaje termina con un error, y el motivo indica qué hacer: volver a enviarlo sin cita.

Repetir la solicitud no crea un segundo mensaje

Un problema típico de los mensajes transaccionales: la red se cae justo cuando llega la respuesta, tu código repite la solicitud y el cliente recibe dos mensajes iguales.

Manda tu propia clave única con la solicitud, y una repetición con esa misma clave devuelve el mismo mensaje con su estado actual, sin enviar otro. Es la mecánica estándar de idempotencia, y aquí funciona a nivel del propio mensaje.

Mientras el mensaje espera en la cola, lo puedes cancelar con otra solicitud: DELETE /v1/message/{id}. Una vez que la cuenta que envía lo tomó, ya es tarde para cancelar: llega el código 409 con el estado actual en el cuerpo, y el resultado lo decide el propio envío. Un mensaje cancelado se lee después como Failed con el motivo Canceled; no existe un estado «cancelado» aparte.

Lo que el servicio no te deja hacer

Te contamos los límites con claridad para que no diseñes de más.

  • La revisión de temas prohibidos también funciona aquí. El mensaje se revisa al momento de la solicitud; si se rechaza, no entra a la cola y la respuesta trae el motivo. El envío directo no sirve para esquivar la moderación.
  • El límite diario del plan se comparte con los envíos masivos. Los mensajes directos lo consumen igual: un mensaje enviado cuenta como un mensaje del plan, y el método no tiene un límite propio. Tampoco hay cobro extra por pasarse: con el límite agotado, la solicitud se rechaza y nada se envía con costo adicional. Además, lo que queda disponible se reduce por lo que ya está en la cola del proyecto.
  • Entre envíos desde una misma cuenta hay una pausa de unos segundos. Protege la cuenta de un flujo de mensajes «en bucle»: una conversación real con un cliente no la nota, pero un intento de mandar mil mensajes con este método sí choca con ella.
  • El servicio no da números propios. El mensaje sale de tu cuenta conectada o de tu bot: la reputación del número sigue siendo tuya, y las respuestas de los clientes te llegan a la aplicación de mensajería de siempre.

Qué sigue

El método funciona en los planes habituales y por ahora está en etapa de pruebas: todavía no tiene precio propio, y los mensajes cuentan como mensajes normales del plan. El plan gratuito incluye 15 mensajes al día, suficiente para armar la integración.

Empieza con una sola solicitud a tu propio número: conecta una cuenta, obtén la clave en la sección API del panel y mándate un mensaje de prueba. Después agrega la suscripción a los eventos y coloca la llamada en el punto de tu sistema donde nace el evento.

La descripción completa de los métodos, con la opción de ejecutar solicitudes desde el navegador, está en la documentación de la API, y la especificación para generar un cliente, en el archivo OpenAPI.