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).
El pedido
Sección titulada «El pedido»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.
El destino
Sección titulada «El destino»| 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.
Las respuestas
Sección titulada «Las respuestas»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" }'vence := time.Now().Add(3 * time.Hour)cmd, err := c.EnviarTexto(ctx, inst.ID, "pedido-1042-confirmacion", motor.Texto{ Destino: motor.Destino{Telefono: "+NUMERO_DEL_CONTACTO"}, Texto: "Hola, tu pedido 1042 está confirmado.", CorrelationID: "pedido-1042", ExpiresAt: &vence,})var e *motor.Errorswitch {case errors.As(err, &e): // 4xx o 5xx con detalle en español; e.Codigo() trae el código tipificado, si hay. return fmt.Errorf("el motor no aceptó el envío: %w", err)case err != nil: return err // red: repite con la MISMA clave}// Un 429 de ritmo no es un error: el comando quedó guardado y trae NextTry.fmt.Println(cmd.ID, cmd.Estado, cmd.NextTry)async function enviar(instanciaId, clave, cuerpo) { const res = await fetch(`https://motor.ejemplo.com/v1/instances/${instanciaId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': clave, }, body: JSON.stringify(cuerpo), }); const json = await res.json(); // 202, o 429 con el comando guardado (trae command_id y next_try): los dos son «aceptado». if (res.status === 202 || (res.status === 429 && json.command_id)) return json; throw new Error(`${res.status}: ${json.detail}`);}
const comando = await enviar(instanciaId, 'pedido-1042-confirmacion', { destino: { telefono: '+NUMERO_DEL_CONTACTO' }, texto: 'Hola, tu pedido 1042 está confirmado.', correlation_id: 'pedido-1042',});| 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.
Seguir el resultado
Sección titulada «Seguir el resultado»El 202 no dice que el mensaje salió. El resultado llega después, de dos formas.
Consultando el comando
Sección titulada «Consultando el comando»GET /v1/commands/{id} pide el scope del tipo del comando: el de un mensaje, messages:send.
{ "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_ides 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.enviadoquiere decir que el servidor de WhatsApp lo aceptó. Entregado y leído llegan después, como eventos.fallidotraeerror_codeystage;ambiguoquiere 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).
Por eventos
Sección titulada «Por eventos»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. |
{ "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.
Una imagen
Sección titulada «Una imagen»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).
POST /v1/instances/{id}/media/uploads(scopemedia:write): el cuerpo es el archivo crudo, no multipart.Content-Typees su MIME y?nombre=el nombre del archivo (opcional). El motor detecta el MIME real por el contenido y responde 201 con elmedia_id, yalisto.POST /v1/instances/{id}/messagescontipo: "image",medio.media_idy, si quieres, un caption entexto.
# 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." }'// El SDK no trae la subida ni los envíos de medios: es HTTP directo.f, err := os.Open("pedido-1042.jpg")if err != nil { return err}defer f.Close()req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/instances/"+inst.ID+"/media/uploads?nombre=pedido-1042.jpg", f)req.Header.Set("Authorization", "Bearer "+c.Token)req.Header.Set("Content-Type", "image/jpeg")res, err := c.HTTP.Do(req) // 201if err != nil { return err}defer res.Body.Close()var medio struct { MediaID string `json:"media_id"` Estado string `json:"estado"` // listo}if err := json.NewDecoder(res.Body).Decode(&medio); err != nil { return err}
cuerpo, _ := json.Marshal(map[string]any{ "destino": map[string]string{"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "image", "medio": map[string]string{"media_id": medio.MediaID}, "texto": "Así quedó tu pedido.",})req, _ = http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/instances/"+inst.ID+"/messages", bytes.NewReader(cuerpo))req.Header.Set("Authorization", "Bearer "+c.Token)req.Header.Set("Content-Type", "application/json")req.Header.Set("Idempotency-Key", "pedido-1042-foto")res, err = c.HTTP.Do(req) // 202 con el comando (type: message.image)import { readFile } from 'node:fs/promises';
const base = `https://motor.ejemplo.com/v1/instances/${instanciaId}`;const auth = { Authorization: `Bearer ${apiKey}` };
// 1. Subir el archivo crudo:const subida = await fetch(`${base}/media/uploads?nombre=pedido-1042.jpg`, { method: 'POST', headers: { ...auth, 'Content-Type': 'image/jpeg' }, body: await readFile('pedido-1042.jpg'),});const { media_id } = await subida.json(); // 201, estado: "listo"
// 2. Mandarlo:const comando = await enviar(instanciaId, 'pedido-1042-foto', { destino: { telefono: '+NUMERO_DEL_CONTACTO' }, tipo: 'image', medio: { media_id }, texto: 'Así quedó tu pedido.',});{ "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 comodocument. - El
media_ides de esa instancia: en un envío de otra instancia, la respuesta es 404medio_no_encontradoenbody.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 amedia.ready. Su host tiene que estar en los destinos permitidos (los mismos de los webhooks); si no, 422url_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.
Errores comunes
Sección titulada «Errores comunes»| 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. |
Siguiente paso
Sección titulada «Siguiente paso»Recibe el primer webhook para enterarte del resultado sin consultar.