4dim / Notas
Adjuntos: WhatsApp canjea, Messenger trae la URL
La misma foto llega por dos protocolos distintos. La URL firmada que vive minutos, el 403 que no era de permisos sino de una cabecera, el pie de foto que tumba un audio, el MIME que miente en las notas de voz, y dónde se guardan los archivos de los clientes.
Dos protocolos para la misma foto
Si tus clientes te mandan fotos, audios o facturas en PDF, esta nota te cuenta qué hace el sistema con ese archivo y por dónde se pierde. Un cliente manda la foto del producto que quiere. Si viene por WhatsApp o por Instagram la foto es la misma, pero lo que hay que hacer para tenerla no.
| WhatsApp Cloud API | Messenger e Instagram | |
|---|---|---|
| Adjunto entrante | Llega un media_id. Paso 1: canjearlo por una URL firmada. Paso 2: bajar los bytes de esa URL | La URL ya viene resuelta en el webhook |
| Adjunto saliente | Se sube primero, se obtiene un id, y se manda el mensaje con ese id | Se sube y se manda en la misma petición |
| Cómo se llama un PDF | document | file |
| Tope | 16 MB para documentos | El mismo tope lo aplicamos nosotros |
Mantenemos dos tablas de tipos separadas, una por familia, en vez de una común con excepciones. La excepción de una tabla de nombres es por donde se cuela el que no era.
La URL que vive minutos
Al canjear un media_id, WhatsApp devuelve una URL firmada: un enlace temporal que solo sirve un rato. Caduca en minutos, y eso decide la arquitectura. El archivo se descarga en el momento de recibir el webhook, el aviso con el que Meta entrega cada mensaje que llega.
Nunca después, cuando alguien abra el hilo. Un diseño «perezoso», que baje el archivo al abrirlo, funciona en la prueba y falla con todo cliente al que no se atienda en la siguiente media hora.
Pero el webhook tiene su propio reloj: lo que tarde en responder decide si Meta reintenta. Por eso la descarga dentro del webhook lleva un plazo de 8 segundos. Sobra para una foto y no alcanza para un video largo. Si no alcanza, el mensaje entra igual, con un texto que avisa de que el archivo no se pudo traer.
Fuera del webhook, subir o bajar un archivo tiene 60 segundos de plazo, contra los 10 de una petición normal. Los 10 no alcanzan para 16 MB con la conexión de una tienda de barrio. Y cortar a mitad de subida deja el archivo «a medio viajar en Meta», que es el peor de los dos mundos.
El 403 que no era de permisos
La URL firmada no apunta a graph.facebook.com sino a la CDN de Meta, la red de servidores desde la que sirve los archivos. Es otro host. Y esa CDN responde 403 si la petición no lleva User-Agent, la cabecera con la que un programa dice quién es.
Un 403 se lee como «sin permiso». Y uno se pasa horas revisando tokens y alcances cuando lo que falta es una cabecera que cualquier navegador manda sin que nadie se la pida. El comentario del código dice, literal, que «costó descubrirlo una vez».
Un 403 de la CDN de Meta sin User-Agent no habla de permisos. Habla de una cabecera.
El tamaño se mira antes de leer, y el tipo real manda
Antes de descargar el cuerpo se comprueba contra el tope la cabecera content-length, que es la que anuncia cuánto pesa el archivo. Sin esa comprobación, un archivo de un giga se convierte en un proceso muerto por falta de memoria. Y como la cabecera puede mentir, se vuelve a medir el tamaño real después de la descarga.
El tipo de archivo tiene tres fuentes que pueden discrepar: lo que declara el webhook, lo que dice el endpoint de medios, que es la dirección de Meta donde se piden los archivos, y lo que trae la respuesta HTTP. Manda el del archivo que de verdad llegó.
Con una excepción aprendida a golpes. En los audios, WhatsApp a veces devuelve application/octet-stream en la respuesta HTTP, y eso convertiría una nota de voz en un «documento» genérico. Ahí manda el tipo que declaró el endpoint de medios.
Lo que acepta cada tipo, y lo que rompe el envío
| Tipo (WhatsApp) | Pie de foto (caption) | Nombre de archivo (filename) |
|---|---|---|
| Imagen | Sí | No |
| Video | Sí | No |
| Documento | Sí | Sí |
| Audio | No | No |
Un pie de foto en un audio no es una advertencia: es un 400 que tumba el envío entero.
En Messenger e Instagram hay otra trampa al subir por multipart/form-data. El objeto message tiene que ir dentro del formulario como una cadena JSON, y el archivo aparte. Si se manda como partes sueltas, el error dice «falta message» sin aclarar que el problema es el formato.
Al subir forzamos, además, is_reusable: false. Así Meta no guarda copias de las fotos de los clientes en una biblioteca de la página que nadie de aquí administra ni limpia.
Dónde se guardan
- En disco, no en la base de datos. Un PDF metido en una columna hincha cada copia de seguridad, cada réplica y cada consulta que toque esa fila.
- Con nombre aleatorio y extensión saneada, nunca con el nombre original.
../../etc/passwdyfactura.pdf.exeson nombres válidos para un formulario. El original se guarda aparte, solo para mostrarlo. - En una carpeta por negocio. Así borrar un negocio es borrar una carpeta, y nadie tiene que confiar en un WHERE para no leer lo ajeno.
- Fuera de las carpetas del despliegue azul/verde: lo que esté dentro se va con cada versión.
- Los stickers no se guardan. Son un webp animado que se manda solo, no lo que un cliente usa para enseñar el producto, y llenarían el almacén.
Qué hacer entonces
- Descarga los adjuntos de WhatsApp dentro del webhook, con plazo corto y texto de respaldo.
- Manda
User-Agenta la CDN. - Comprueba
content-lengthantes de leer, y el tamaño real después. - Ten una tabla de restricciones por tipo, con la regla de que el pie de foto en un audio es un 400.
- Guarda en disco, con nombre aleatorio, en una carpeta por negocio y fuera del despliegue.
Esta nota sale de nuestro trabajo en AI-CRM Connection.