Ir al contenido

El primer mensaje

Mandar un mensaje es pedir un comando: el motor lo guarda, responde enseguida y lo envía después, en orden y al ritmo de la instancia. Esta página amplía el paso 4 del Inicio rápido: qué lleva el pedido, qué significa cada respuesta, cómo seguir el resultado y cómo mandar una imagen. El porqué del diseño está en Comandos asíncronos y eventos.

Necesitas una instancia en conectado y una key con messages:send (y media:write para subir archivos).

POST /v1/instances/{id}/messages, con la Idempotency-Key obligatoria en el encabezado:

Campo Qué es
tipo text por omisión. Los demás (image, document, location, poll, interactive…) están en Mensajes.
destino Exactamente uno de telefono, jid o peer_ref.
texto El mensaje: hasta 4096 caracteres. En un medio, el caption (hasta 1024).
cita Opcional: el provider_id de un mensaje de este mismo chat al que respondes.
menciones Opcional: hasta 50, cada una como un destino (+número, JID de usuario o peer_ref).
correlation_id Opcional: tu identificador (hasta 200). Vuelve en el sobre de cada evento del comando.
expires_at Opcional: si no salió antes de esa fecha, no sale (fallido con comando_vencido).
generacion_esperada Opcional: si la instancia se volvió a vincular, 409 y no sale por el número nuevo.

El cuerpo es cerrado: un campo de más, o un campo que no corresponde al tipo, es 422.

Forma Ejemplo Cuándo
telefono +NUMERO_DEL_CONTACTO Un chat 1:1 con alguien que todavía no te escribió. Número internacional; el motor arma su JID.
jid CONTACTO@s.whatsapp.net, CONTACTO@lid, GRUPO@g.us Un chat que ya conoces por su JID, incluido un grupo.
peer_ref 4b7d9e01-0000-4000-8000-0000000000b1 Para responder: el que trae el evento del mensaje recibido. Solo vale en su instancia.

Por qué conviene responder con peer_ref y no con el teléfono está en Identidades: PN, LID y peer_ref.

Ventana de terminal
curl -i -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-1042-confirmacion" \
-d '{
"destino": {"telefono": "+NUMERO_DEL_CONTACTO"},
"texto": "Hola, tu pedido 1042 está confirmado.",
"correlation_id": "pedido-1042",
"expires_at": "2026-10-24T18:00:00Z"
}'
Respuesta Qué significa Qué hacer
202 Aceptado: el comando quedó guardado (status: en_cola). Esperar el resultado.
429 con command_id También aceptado. El ritmo de la instancia no deja enviarlo ya: trae next_try y Retry-After. Nada: sale solo. No hace falta repetirlo.
429 sin command_id La cola de la instancia está llena (200 envíos de fábrica). No se guardó. Esperar a que salga lo pendiente y repetir.
409 Misma Idempotency-Key con otro mensaje; instancia sin vincular, archivada, bloqueada o reemplazada; o generacion_esperada vieja. Leer detail.
422 El cuerpo no es válido: destino, campos del tipo, cita de otro chat (cita_ajena)… Corregir; errors[].location dice qué campo.
503 El transporte de WhatsApp no está disponible. Repetir con la misma clave en unos segundos.

Si la red se corta y no sabes si el pedido llegó, repítelo con la misma clave: recibes el mismo comando (y el mismo provider_message_id), nunca un segundo mensaje. Las reglas completas están en Idempotencia.

Una instancia desconectado acepta envíos: quedan en cola y salen cuando vuelve a conectar.

El 202 no dice que el mensaje salió. El resultado llega después, de dos formas.

GET /v1/commands/{id} pide el scope del tipo del comando: el de un mensaje, messages:send.

GET /v1/commands/{id} · 200 OK
{
"command_id": "5c2e7a10-0000-4000-8000-0000000000c1",
"instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1",
"idempotency_key": "pedido-1042-confirmacion",
"type": "message.text",
"generation": 1,
"status": "enviado",
"correlation_id": "pedido-1042",
"expires_at": "2026-10-24T18:00:00Z",
"provider_message_id": "PROVIDER_MESSAGE_ID",
"sent_at": "2026-10-24T15:10:01Z",
"created_at": "2026-10-24T15:10:00Z",
"updated_at": "2026-10-24T15:10:01Z"
}
  • provider_message_id es el ID del mensaje en WhatsApp. El motor lo fija antes del primer intento y no cambia en un reintento: con él cruzas los recibos y las respuestas.
  • enviado quiere decir que el servidor de WhatsApp lo aceptó. Entregado y leído llegan después, como eventos.
  • fallido trae error_code y stage; ambiguo quiere decir que pudo salir o no: no lo reenvíes con otra clave. Todos los estados, en Los estados de un comando.
  • Mientras espera su turno, el comando trae next_try. Para cancelarlo antes de que salga: POST /v1/commands/{id}/cancel (409 si ya entró al envío).

Para no consultar en bucle, suscríbete (lo cubre El primer webhook). Estos son los eventos de un envío, todos con el command_id en el sobre:

Evento Cuándo
command.accepted Al guardarse el comando.
message.sent El servidor lo aceptó.
message.updated Cambió su estado: entregado, leido, reproducido.
command.failed No salió: error_code y stage.
command.ambiguous Quedó en duda. Si después llega su eco o su recibo, sale un message.sent con conciliado: true.
message.sent
{
"schema_version": 1,
"event_id": "e1f0c2d4-0000-4000-8000-000000001201",
"instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1",
"seq": 1201,
"type": "message.sent",
"occurred_at": "2026-10-24T15:10:01Z",
"received_at": "2026-10-24T15:10:01Z",
"generation": 1,
"origin": "operator",
"command_id": "5c2e7a10-0000-4000-8000-0000000000c1",
"correlation_id": "pedido-1042",
"data": {
"chat": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@s.whatsapp.net", "clase": "usuario", "aliases": [] },
"provider_id": "PROVIDER_MESSAGE_ID",
"command_id": "5c2e7a10-0000-4000-8000-0000000000c1",
"aceptado_en": "2026-10-24T15:10:01Z"
}
}

Un message.updated no siempre trae el command_id en el sobre: crúzalo por data.provider_id, que es el provider_message_id del comando. Guarda también data.chat.peer_ref: es la clave estable de ese chat para responder después.

Mandar un medio son dos pasos: subir el archivo al motor y mandar un mensaje que lo cite. El motor nunca acepta base64 en un envío. Necesitas que el operador haya configurado el almacenamiento (sin él, 503 multimedia_deshabilitada; el texto sigue funcionando).

  1. POST /v1/instances/{id}/media/uploads (scope media:write): el cuerpo es el archivo crudo, no multipart. Content-Type es su MIME y ?nombre= el nombre del archivo (opcional). El motor detecta el MIME real por el contenido y responde 201 con el media_id, ya listo.
  2. POST /v1/instances/{id}/messages con tipo: "image", medio.media_id y, si quieres, un caption en texto.
Ventana de terminal
# 1. Subir:
curl -X POST "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/media/uploads?nombre=pedido-1042.jpg" \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @pedido-1042.jpg
# 2. Mandar (con el media_id de la respuesta):
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-1042-foto" \
-d '{
"destino": {"telefono": "+NUMERO_DEL_CONTACTO"},
"tipo": "image",
"medio": {"media_id": "MEDIA_ID"},
"texto": "Así quedó tu pedido."
}'
POST …/media/uploads · 201 Created
{
"media_id": "3f9a1c2b-0000-4000-8000-0000000000e1",
"instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1",
"origen": "subida",
"estado": "listo",
"mime": "image/jpeg",
"mime_declarado": "image/jpeg",
"bytes": 184320,
"sha256": "…",
"nombre_archivo": "pedido-1042.jpg",
"metadatos": { "…": "…" },
"retencion_hasta": "2026-11-23T15:12:00Z",
"descarga": { "url": "…", "expira_en": "2026-10-24T15:27:00Z" },
"created_at": "2026-10-24T15:12:00Z"
}
  • Formatos que salen sin convertir: imagen JPEG o PNG; vídeo MP4 o 3GP; audio OGG/Opus, MP3, M4A, AAC o AMR; un documento, cualquiera. Otro formato es 422 formato_incompatible: mándalo como document.
  • El media_id es de esa instancia: en un envío de otra instancia, la respuesta es 404 medio_no_encontrado en body.medio.media_id, la misma que si no existiera. El archivo se borra al vencer su retención (30 días de fábrica), salvo que lo use un envío pendiente.
  • En vez de subir, puedes dar medio.url: el motor lo descarga como un trabajo durable y el envío espera a media.ready. Su host tiene que estar en los destinos permitidos (los mismos de los webhooks); si no, 422 url_no_permitida.

Errores propios de la subida: 413 demasiado_grande (100 MB de fábrica), 422 mime_falso (lo declarado contradice el contenido), 429 cuota_excedida (la cuota de la instancia o de la app) y 503 presion_disco con Retry-After. La nota de voz, el sticker, el GIF y los demás tipos están en Multimedia.

Síntoma Causa Qué hacer
403 «…no tiene el scope messages:send» La key no puede enviar. Emite una key con el scope.
404 «No existe esa instancia» Otra app, fuera del alcance de la key o ID mal. Revisa whoami.
409 «Esa Idempotency-Key ya se usó con otro mensaje» Reusaste una clave para otro contenido. Una clave por mensaje lógico.
409 «La instancia no está vinculada» Sin número vinculado. Vincula primero.
422 body.destino Ninguno o más de un destino, o un teléfono inválido. Exactamente uno, en formato internacional.
422 body.destino.peer_ref El peer_ref es de otra instancia. Usa el del evento de esta instancia.
422 cita_ajena La cita es de otro chat. Cita un mensaje del mismo chat.
fallido con generacion_cambiada La instancia se volvió a vincular antes de que saliera. Decide si mandarlo de nuevo, con otra clave.
fallido con comando_vencido Pasó expires_at antes de salir. —
fallido con credencial_revocada La key que lo pidió se revocó o se rotó antes de que saliera. Ver Rotar una key.
ambiguo Se cortó justo al enviarlo. No lo reenvíes con otra clave: espera su conciliación.

Recibe el primer webhook para enterarte del resultado sin consultar.