Multimedia
La multimedia del motor es privada: el archivo vive en el bucket que el operador configura en
Ajustes → Almacenamiento, y en la base solo quedan su identidad, su MIME real, su tamaño, su
SHA-256, su estado y su retención. Nunca hay un enlace público ni un base64 guardado. Para mandar un
archivo, primero lo subes y después lo citas por su media_id; un archivo que llega en un
mensaje se descarga solo y te avisa con media.ready.
Requisitos
Sección titulada «Requisitos»| Operación | Scope |
|---|---|
POST /v1/instances/{id}/media/uploads (subir) |
media:write |
DELETE /v1/media/{id} (borrar o cancelar una descarga) |
media:write |
GET /v1/media/{id} (estado y permiso de descarga) |
media:read |
GET /v1/media/{id}/content (descargar) |
media:read, o el permiso temporal ?permiso= sin API key |
POST /v1/instances/{id}/messages con un tipo de multimedia |
messages:send |
Un medio es de una instancia y de una app: otra app recibe 404, y un envío solo puede
usar un media_id de su misma instancia.
Subir un archivo
Sección titulada «Subir un archivo»POST /v1/instances/{id}/media/uploads recibe el archivo crudo en el cuerpo (no multipart ni
base64), con Content-Type igual a su MIME. Parámetros opcionales: nombre (el nombre del archivo,
para un documento; hasta 200 caracteres) y sha256 (el hash en hex: si no coincide, 422
digest).
El motor lo copia en streaming a su disco temporal, calcula su SHA-256, detecta el MIME real por
el contenido (lo declarado puede precisar un contenedor genérico, como un ZIP que es un .docx,
pero no contradecirlo: 422 mime_falso), revisa las cuotas y lo guarda en el bucket. Responde
201 con el media_id y un permiso temporal de descarga.
curl -X POST "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/media/uploads?nombre=factura-1042.pdf" \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/pdf" \ --data-binary @factura-1042.pdf// El SDK de Go no trae la subida: es HTTP directo, en streaming desde el archivo.f, err := os.Open("factura-1042.pdf")if err != nil { return err}defer f.Close()info, _ := f.Stat()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/instances/"+instanciaID+"/media/uploads?nombre=factura-1042.pdf", f)req.ContentLength = info.Size()req.Header.Set("Authorization", "Bearer "+c.Token)req.Header.Set("Content-Type", "application/pdf")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"`}err = json.NewDecoder(res.Body).Decode(&medio)import { readFile } from 'node:fs/promises';
const res = await fetch( `https://motor.ejemplo.com/v1/instances/${instanciaId}/media/uploads?nombre=factura-1042.pdf`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/pdf' }, body: await readFile('factura-1042.pdf'), },);const medio = await res.json(); // 201: { media_id, estado: "listo", … }{ "media_id": "0b9d6c7e-0000-4000-8000-0000000000f1", "instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1", "origen": "subida", "estado": "listo", "mime": "application/pdf", "mime_declarado": "application/pdf", "bytes": 182044, "sha256": "9f2c…", "nombre_archivo": "factura-1042.pdf", "metadatos": {}, "created_at": "2026-10-24T15:20:00Z", "retencion_hasta": "2026-11-23T15:20:00Z", "descarga": { "url": "/v1/media/0b9d6c7e-0000-4000-8000-0000000000f1/content?permiso=mwp_…", "expira_en": "2026-10-24T15:35:00Z" }}Tamaños y cuotas
Sección titulada «Tamaños y cuotas»Los límites son de Ajustes → Multimedia (los pone el operador):
| Ajuste | De fábrica | Qué pasa al superarlo |
|---|---|---|
multimedia.tamano_maximo_mb |
100 MB por archivo (subir, importar o descargar) | 413 demasiado_grande |
multimedia.cuota_instancia_mb |
2048 MB guardados por instancia | 429 cuota_excedida |
multimedia.cuota_app_mb |
10 240 MB por app (cada app puede tener la suya) | 429 cuota_excedida |
multimedia.espacio_libre_minimo_mb |
256 MB libres en el disco temporal | 503 presion_disco con Retry-After |
Se puede subir cualquier tipo de archivo: lo que cambia es con qué tipo se puede mandar.
Tipos de envío y formatos
Sección titulada «Tipos de envío y formatos»tipo |
Formato que sirve (MIME real) | Caption (texto) |
|---|---|---|
image |
JPEG o PNG | Sí, hasta 1024 |
video |
MP4 o 3GP | Sí, hasta 1024 |
audio |
OGG/Opus, MP3, M4A, AAC o AMR | No |
document |
Cualquiera (medio.nombre_archivo es el nombre que ve el destinatario) |
Sí, hasta 1024 |
Un archivo que no sirve para ese tipo es 422 formato_incompatible: mándalo como document
o usa un subtipo con conversión.
Conversión con ffmpeg
Sección titulada «Conversión con ffmpeg»Cuatro subtipos se convierten antes de salir, con ffmpeg, como un trabajo durable acotado en procesos, tiempo y memoria:
tipo |
Sale como | Se puede hacer de | Caption |
|---|---|---|---|
ptt |
Nota de voz: Opus mono en Ogg, con su duración y su forma de onda | Un audio o un vídeo | No |
sticker |
WebP de 512 × 512: estático hasta 100 KB, animado hasta 6 s y 500 KB | Una imagen (no HEIC, AVIF ni TIFF), un GIF o un vídeo | No |
ptv |
Vídeo circular: MP4 cuadrado de 480, hasta 60 s | Un vídeo | No |
gif |
MP4 sin audio, en bucle, hasta 720 de ancho y 30 s | Un vídeo o un GIF | Sí |
Mientras convierte, el comando sigue en_cola y su media.estado es pendiente. Un archivo que
no sirve para el subtipo es 422 formato_no_convertible al aceptar. Si la conversión falla, el
envío termina fallido con conversion_fallida, y el motivo (formato_no_convertible,
tiempo_excedido o memoria_excedida) va en media.motivo del comando y en media.failed. Si el
motor no tiene ffmpeg instalado, los subtipos responden 503 conversion_no_disponible.
Los límites de la conversión también son de Ajustes: multimedia.conversiones_simultaneas (2),
multimedia.segundos_conversion (120) y multimedia.memoria_conversion_mb (1024).
Mandar un archivo
Sección titulada «Mandar un archivo»POST /v1/instances/{id}/messages con el tipo y medio, que lleva exactamente uno de:
medio.media_id: una subida de esta instancia, en estadolisto(si no, 409no_listo).medio.url: una URLhttp(s)cuyo host está en los destinos permitidos de los webhooks (si no, 422url_no_permitida). El motor la importa como un trabajo durable y el envío espera amedia.ready.
El archivo se sube a WhatsApp dentro del mismo envío durable: si esa subida falla, no salió nada y
se reintenta. Puede llevar cita y menciones, como un texto (ver Mensajes).
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: factura-1042" \ -d '{ "destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "document", "medio": {"media_id": "0b9d6c7e-0000-4000-8000-0000000000f1", "nombre_archivo": "factura-1042.pdf"}, "texto": "Tu factura del pedido 1042" }'// El SDK de Go solo trae el texto: el envío de multimedia es HTTP directo.cuerpo, _ := json.Marshal(map[string]any{ "destino": map[string]string{"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "document", "medio": map[string]string{"media_id": medio.MediaID, "nombre_archivo": "factura-1042.pdf"}, "texto": "Tu factura del pedido 1042",})req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/instances/"+instanciaID+"/messages", bytes.NewReader(cuerpo))req.Header.Set("Authorization", "Bearer "+c.Token)req.Header.Set("Content-Type", "application/json")req.Header.Set("Idempotency-Key", "factura-1042")res, err := c.HTTP.Do(req) // 202 con el comando message.document
// Después, como cualquier envío:cmd, err := c.Comando(ctx, comandoID) // cmd.Estado == "enviado"const res = await fetch(`https://motor.ejemplo.com/v1/instances/${instanciaId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'factura-1042', }, body: JSON.stringify({ destino: { telefono: '+NUMERO_DEL_CONTACTO' }, tipo: 'document', medio: { media_id: medio.media_id, nombre_archivo: 'factura-1042.pdf' }, texto: 'Tu factura del pedido 1042', }),});const comando = await res.json(); // 202: { command_id, type: "message.document", media: {…} }{ "command_id": "5c2e7a10-0000-4000-8000-0000000000c5", "instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1", "idempotency_key": "factura-1042", "type": "message.document", "generation": 1, "status": "en_cola", "media": { "media_id": "0b9d6c7e-0000-4000-8000-0000000000f1", "estado": "listo" }, "created_at": "2026-10-24T15:21:00Z", "updated_at": "2026-10-24T15:21:00Z"}El type del comando es message.image, message.video, message.document, message.audio,
message.ptt, message.ptv, message.sticker o message.gif. El resultado llega como cualquier
envío: message.sent cuando WhatsApp lo acepta y message.updated con entregado, leido o
reproducido; o command.failed con medio_no_disponible (el medio falló o se borró antes de
salir, por ejemplo una importación de URL que no se pudo hacer), medio_incompatible (lo importado
no sirve para el tipo) o conversion_fallida.
Recibir un archivo
Sección titulada «Recibir un archivo»Un mensaje con un archivo llega como message.received con contenido.medio. El archivo todavía no
está: el motor lo descarga de WhatsApp como un trabajo durable y, mientras tanto, estado es
pendiente. El media_id ya viene en el evento.
{ "chat": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] }, "provider_id": "PROVIDER_MESSAGE_ID", "from_me": false, "es_grupo": false, "operacion": "mensaje", "contenido": { "tipo": "audio", "medio": { "media_id": "0b9d6c7e-0000-4000-8000-0000000000f2", "estado": "pendiente", "mime": "audio/ogg; codecs=opus", "bytes": 20449, "duracion_seg": 9, "ptt": true, "sha256": "4a1b…" } }, "momento": "2026-10-24T15:22:00Z"}medio puede traer además nombre (un documento), ancho y alto, y las banderas ptt (nota de
voz), ptv (vídeo circular), gif y animado (sticker animado). El caption va en
contenido.texto. Un mensaje importado del historial con la multimedia del historial apagada llega
con estado: no_descargado y sin media_id: solo sus metadatos.
Cuando la descarga termina llega uno de dos eventos, con el provider_id del mensaje:
{ "media_id": "0b9d6c7e-0000-4000-8000-0000000000f2", "origen": "entrante", "estado": "listo", "mime": "audio/ogg", "bytes": 20449, "sha256": "4a1b…", "provider_id": "PROVIDER_MESSAGE_ID"}{ "media_id": "0b9d6c7e-0000-4000-8000-0000000000f3", "origen": "entrante", "estado": "fallido", "motivo": "vencido_en_whatsapp", "provider_id": "PROVIDER_MESSAGE_ID"}origen es entrante (un archivo recibido), url (una importación de medio.url, con el
command_id del envío que la usa) o conversion. Motivos de media.failed:
vencido_en_whatsapp (WhatsApp ya no lo tiene), invalido (no coincide con su hash),
demasiado_grande, cuota_excedida, reintentos_agotados, url_no_permitida, url_rechazada,
y los de la conversión. Una descarga que falla por algo pasajero se reintenta hasta 6 veces (a los
30 s, 2 min, 10 min, 30 min y 1 h) antes de reintentos_agotados; si el almacenamiento no responde
o el disco está casi lleno, espera sin gastar intentos.
Descargar un archivo
Sección titulada «Descargar un archivo»Hay dos maneras:
- Con la API key (scope
media:read):GET /v1/media/{id}/content. - Con un permiso temporal, sin API key:
GET /v1/media/{id}devuelve, si el medio estálisto,descarga.url(una ruta relativa con?permiso=mwp_…) ydescarga.expira_en. Vale hasta que vence (15 minutos de fábrica,multimedia.minutos_permiso) y solo para ese medio. Es el esquema de seguridadpermisoMediadel OpenAPI: sirve para pasarle el archivo a un navegador o a otro servicio sin darle tu key. No es público ni permanente: no lo guardes.
El archivo sale en streaming, con su Content-Type real y Content-Disposition: attachment. Con
encoding=base64, la respuesta es un JSON con data en base64, solo para archivos de hasta
multimedia.base64_maximo_kb (5120 KB de fábrica); uno más grande es 422
demasiado_grande_para_base64.
# Con la API keycurl -o nota.ogg https://motor.ejemplo.com/v1/media/MEDIA_ID/content \ -H "Authorization: Bearer TU_API_KEY"
# El permiso temporal (descarga.url), para usarlo sin keycurl https://motor.ejemplo.com/v1/media/MEDIA_ID \ -H "Authorization: Bearer TU_API_KEY"curl -o nota.ogg "https://motor.ejemplo.com/v1/media/MEDIA_ID/content?permiso=mwp_…"// El SDK de Go no trae la descarga: es HTTP directo, en streaming al disco.req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/v1/media/"+mediaID+"/content", nil)req.Header.Set("Authorization", "Bearer "+c.Token)res, err := c.HTTP.Do(req)if err != nil { return err}defer res.Body.Close()if res.StatusCode != http.StatusOK { return fmt.Errorf("descarga: %d", res.StatusCode) // 409 no_listo, 410 no_disponible…}out, err := os.Create("nota.ogg")if err != nil { return err}defer out.Close()_, err = io.Copy(out, res.Body)// 1. El estado y el permiso temporalconst info = await ( await fetch(`https://motor.ejemplo.com/v1/media/${mediaId}`, { headers: { Authorization: `Bearer ${apiKey}` }, })).json();
// 2. Descargar con el permiso (sin la key): descarga.url es relativa al motorif (info.estado === 'listo') { const res = await fetch(`https://motor.ejemplo.com${info.descarga.url}`); const archivo = Buffer.from(await res.arrayBuffer());}Borrar, retención y expiración
Sección titulada «Borrar, retención y expiración»- Retención: un medio
listotraeretencion_hasta, a losmultimedia.dias_retenciondías (30 de fábrica). Después, el motor borra el archivo y el medio quedaborradoconmotivo: retencion, salvo que lo use un envío pendiente. Su descarga responde 410no_disponible. - Mensajes temporales: cuando vence un mensaje temporal, el motor borra también sus medios
(
borrado,motivo: mensaje_vencido). Ver Mensajes. - Borrar ya:
DELETE /v1/media/{id}(scopemedia:write) quita el archivo del bucket, o cancela su descarga o importación pendiente, y lo dejaborrado(motivo: pedido). Si un envío pendiente lo usa, 409en_uso. Repetirlo no hace nada. - En WhatsApp: un archivo viejo puede ya no estar en los servidores de WhatsApp; su descarga
falla con
vencido_en_whatsappy el motor nunca inventa el contenido.
Los estados de un medio son pendiente, subiendo, listo, fallido y borrado. trabajo
muestra el progreso de su descarga, importación o conversión (intentos, proximo_intento_en,
motivo).
Errores comunes
Sección titulada «Errores comunes»El código va al final de type (urn:motor-whatsapp:error:<código>). Ver Errores.
| HTTP | Código | Cuándo |
|---|---|---|
| 401 | — | El permiso temporal no es válido o venció: pide otro con GET /v1/media/{id}. |
| 404 | — | El medio no existe o es de otra app. |
| 409 | no_listo |
El medio todavía no está listo: espera media.ready. |
| 409 | en_uso |
Borrar un medio que usa un envío pendiente. |
| 410 | no_disponible |
Falló, se borró o venció su retención. |
| 413 | demasiado_grande |
Supera multimedia.tamano_maximo_mb. |
| 422 | mime_falso / digest / vacio |
El MIME declarado contradice el contenido, el SHA-256 no coincide o el archivo está vacío. |
| 422 | formato_incompatible |
El formato no sirve para ese tipo de envío. |
| 422 | formato_no_convertible |
El archivo no se puede convertir a ese subtipo. |
| 422 | url_no_permitida |
medio.url fuera de los destinos permitidos. |
| 404 | medio_no_encontrado |
Un media_id que no existe en esta instancia (no existe, se borró o es de otra instancia o de otra app: la respuesta es la misma). El campo va en errors[].location. |
| 422 | — | medio sin exactamente uno de media_id o url; un caption en audio, ptt, sticker o ptv. |
| 422 | demasiado_grande_para_base64 |
Pedir en base64 un archivo más grande que el máximo. |
| 429 | cuota_excedida |
Supera la cuota de la instancia o de la app. |
| 503 | multimedia_deshabilitada |
Falta configurar el almacenamiento. |
| 503 | almacen_no_disponible / presion_disco |
El bucket no responde o el disco temporal está casi lleno (con Retry-After). |
| 503 | conversion_no_disponible |
El motor no tiene ffmpeg para los subtipos. |
Límites de WhatsApp
Sección titulada «Límites de WhatsApp»Lo comprobado con números reales (octubre de 2026):
- Ida y vuelta: imagen con caption, PDF con nombre, MP3 y vídeo salen con su vista previa, su
texto y su nombre; lo que manda el teléfono llega con su
media.readyen segundos y el archivo idéntico (mismo SHA-256) por la API, en Android e iOS (caso A18, 16 al 21/10/2026). - Almacenamiento caído: con el bucket apagado, los archivos recibidos quedan
pendiente, sinmedia.failed, y al volver quedanlisto(caso A19, 16/10/2026). Un archivo vencido en WhatsApp no se pudo provocar: el teléfono no deja reenviar uno que ya no tiene. - Nota de voz y stickers: la nota de voz convertida se ve y se escucha como nota de voz en
Android e iOS (caso A20, 21/10/2026); los stickers estático y animado se ven y el animado se mueve
(caso A21, 21/10/2026). Un sticker animado de la colección del teléfono llega en el formato nuevo
de WhatsApp (
application/was) y el motor lo guarda comostickerconanimado: true. - PTV y GIF: no están garantizados en números no oficiales. Se vieron bien en Android, iOS y WhatsApp Web (caso A22, 16 y 18/10/2026), pero puede cambiar.
- Reenviar un medio de un canal: salía
enviadopero no llegaba, porque un medio de canal no va cifrado (falla F10, corregida y repetida el 20/10/2026).
Lo general de WhatsApp está en Limitaciones de WhatsApp.