Pipeline de webhook de medios de WhatsApp — de ID de medio a URL de descarga a archivo convertido, con conversión de nota de voz OGG a MP3 y transcripción con Whisper
En esta guía: Tabla de referencia de tipos de medios · El vencimiento de 5 minutos de la URL · Pipeline de descarga completo · Todos los esquemas de payload de medios · Manejo de imágenes y documentos · Conversión de nota de voz OGG→MP3 · Transcripción con Whisper · Patrones de almacenamiento S3/GCS · Handler completo con todos los tipos · Formato normalizado de SocialHook

Tipos de medios: tabla de referencia completa

La Cloud API entrega 8 tipos de mensajes multimedia, cada uno con diferentes formatos, límites de tamaño y requisitos de manejo. Conoce las restricciones antes de construir el handler.

valor type Formatos aceptados Límite de tamaño Notas especiales
image JPEG, PNG 5 MB Opcional caption. GIF no admitido en línea — enviar como documento.
audio AAC, AMR, MP3, OGG/Opus 16 MB voice: true si se grabó en la app (OGG/Opus). Los archivos de audio regulares pueden variar. Siempre convierte OGG antes de la transcripción.
video MP4, 3GPP 16 MB Opcional caption. Se recomienda video H.264 + audio AAC para mayor compatibilidad.
document Cualquier tipo MIME que Meta acepte 100 MB Incluye filename — guárdalo. PDF, DOCX, XLSX, imágenes como documentos, etc.
sticker WebP Estático 500KB / Animado 100KB animated: true si es animado. Formato WebP — los navegadores lo admiten de forma nativa ahora.

El vencimiento de 5 minutos — el detalle que arruina la mayoría de las implementaciones

Este es el detalle más comúnmente omitido en el manejo de medios de WhatsApp, y causa la mayoría de los errores en producción. Cuando llamas al endpoint de medios de la Cloud API para resolver un ID de medio, recibes una URL de descarga temporal. Esa URL es válida por aproximadamente 5 minutos.

Los dos patrones que usan los desarrolladores — y por qué uno falla:

  • ❌ Almacenar la URL, descargar después — recibes el webhook, llamas al endpoint de medios, almacenas la URL temporal en tu base de datos para procesamiento asíncrono. Para cuando tu worker la recoge, la URL ha expirado. Obtienes un 403. Este patrón falla.
  • ✓ Descargar inmediatamente, almacenar el archivo — recibes el webhook, inmediatamente resuelves el ID de medio a una URL, inmediatamente descargas los bytes, almacenas el archivo en tu propio almacenamiento (S3, GCS, disco), guarda solo la ruta de almacenamiento en tu base de datos. El procesamiento asíncrono trabaja con el archivo almacenado. Este patrón es correcto.
Alternativa: almacenar el ID de medio, resolver bajo demanda. Si no necesitas el archivo de inmediato, puedes almacenar solo el ID de medio y resolverlo cuando necesites el archivo. Los IDs de medio son válidos por 30 días — mucho más que la URL de descarga temporal. Esto es útil cuando no estás seguro de si alguna vez necesitarás el binario (p. ej. stickers que podrías ignorar). Solo ten en cuenta que la llamada de resolución agrega latencia cuando finalmente lo necesites.

El pipeline de descarga: ID → URL → bytes → almacenamiento

1
Recibir webhook — extraer ID de medio
msg.image.id / msg.audio.id / msg.document.id — NO es una URL, solo una referencia
2
Devolver HTTP 200 inmediatamente haz esto primero
Confirma a Meta antes de cualquier descarga. Envía el procesamiento de medios a la cola asíncrona.
3
Resolver ID de medio → URL de descarga temporal
GET graph.facebook.com/v21.0/{media_id} — requiere Authorization: Bearer {token} — devuelve url (vence en ~5min) + mime_type + file_size
4
Descargar los bytes del archivo dentro de 5 minutos
GET {download_url} — TAMBIÉN requiere Authorization: Bearer {token} — devuelve bytes binarios sin procesar
5
Almacenar archivo en S3 / GCS / disco local
Organizar por: media/{client_id}/{media_type}/{date}/{media_id}.{ext}
6
Procesar (específico por tipo)
Imagen: extraer metadatos, generar miniatura. Audio/voz: convertir OGG→MP3, transcribir. Documento: extraer texto para búsqueda. Video: generar fotograma miniatura.

Esquemas de payload para cada tipo de medio

Esta es la estructura exacta del objeto value del webhook para cada tipo de medio. El anidamiento desde el envoltorio de nivel superior (entry[0].changes[0].value.messages[0]) ya está extraído cuando se usa el formato normalizado de SocialHook.

Manejo de imágenes y documentos

Las imágenes y los documentos comparten el mismo patrón de descarga en dos pasos. La diferencia clave: los documentos incluyen un campo filename que deberías preservar en tu clave de almacenamiento — es el nombre que el cliente dio al archivo y lo que querrás mostrar en tu UI.

Node.js
downloadMedia.js
const GRAPH = 'https://graph.facebook.com/v21.0'; const TOKEN = process.env.WA_TOKEN; // Step 1: Resolve media ID → temporary download URL (~5 min expiry) async function resolveMediaUrl(mediaId) { const res = await fetch(`${GRAPH}/${mediaId}`, { headers: { 'Authorization': `Bearer ${TOKEN}` }, }); if (!res.ok) throw new Error(`Media resolve failed: ${res.status}`); const { url, mime_type, file_size } = await res.json(); return { url, mime_type, file_size }; } // Step 2: Download the file bytes from the temporary URL async function downloadMedia(downloadUrl) { const res = await fetch(downloadUrl, { headers: { 'Authorization': `Bearer ${TOKEN}` }, // required! }); if (!res.ok) throw new Error(`Media download failed: ${res.status}`); return Buffer.from(await res.arrayBuffer()); } // Complete handler: resolve → download → store async function handleMediaMessage(msg, from) { const type = msg.type; // 'image' | 'document' | 'video' | 'sticker' const media = msg[type]; const mediaId = media.id; const filename = media.filename ?? mediaId; // documents have filename const caption = media.caption ?? null; // Resolve then download immediately — URL expires in ~5 min const { url, mime_type } = await resolveMediaUrl(mediaId); const bytes = await downloadMedia(url); // Store the file — see storage patterns section const storagePath = await storeFile(bytes, type, mediaId, mime_type, filename); return { mediaId, storagePath, mimeType: mime_type, filename, caption, from, type, }; }
Python
download_media.py
import os, requests GRAPH = "https://graph.facebook.com/v21.0" TOKEN = os.environ["WA_TOKEN"] HEADERS = { "Authorization": f"Bearer {TOKEN}" } def resolve_media_url(media_id: str) -> dict: """Step 1: Resolve media ID → temporary download URL.""" res = requests.get(f"{GRAPH}/{media_id}", headers=HEADERS) res.raise_for_status() return res.json() # { url, mime_type, file_size, id } def download_media(download_url: str) -> bytes: """Step 2: Download file bytes — URL expires in ~5 minutes.""" res = requests.get(download_url, headers=HEADERS) # auth required here too! res.raise_for_status() return res.content def handle_media_message(msg: dict, sender: str) -> dict: media_type = msg["type"] media = msg[media_type] media_id = media["id"] filename = media.get("filename", media_id) caption = media.get("caption") # Resolve then download immediately — URL valid ~5 min only media_info = resolve_media_url(media_id) file_bytes = download_media(media_info["url"]) storage_path = store_file(file_bytes, media_type, media_id, media_info["mime_type"], filename) return { "media_id": media_id, "storage_path": storage_path, "mime_type": media_info["mime_type"], "filename": filename, "caption": caption, "sender": sender, "type": media_type, }

Notas de voz: conversión de OGG/Opus a MP3

Las notas de voz grabadas en WhatsApp están codificadas en formato OGG/Opus. Puedes identificarlas por el indicador voice: true en el payload de audio y el valor mime_type: "audio/ogg; codecs=opus". Este formato no es compatible con OpenAI Whisper para transcripción, y tiene soporte limitado de reproducción en navegadores.

La solución: convierte a MP3 usando ffmpeg. ffmpeg es la herramienta universal de conversión de audio, disponible en todos los principales sistemas operativos y entornos en la nube.

Shell
install ffmpeg
# Ubuntu / Debian apt-get install -y ffmpeg # macOS brew install ffmpeg # Docker — add to Dockerfile RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* # Node.js wrapper (optional — avoids shell exec) npm install fluent-ffmpeg
Node.js + fluent-ffmpeg
convertVoiceNote.js
const ffmpeg = require('fluent-ffmpeg'); const { Readable } = require('stream'); const path = require('path'); const os = require('os'); const fs = require('fs/promises'); async function convertOggToMp3(oggBuffer) { const tmpDir = os.tmpdir(); const inputPath = path.join(tmpDir, `voice-${Date.now()}.ogg`); const outPath = path.join(tmpDir, `voice-${Date.now()}.mp3`); // Write OGG buffer to temp file await fs.writeFile(inputPath, oggBuffer); // Convert OGG/Opus → MP3 await new Promise((resolve, reject) => { ffmpeg(inputPath) .audioCodec('libmp3lame') .audioBitrate('128k') // good quality at reasonable size .audioFrequency(44100) // standard sample rate for Whisper .save(outPath) .on('end', resolve) .on('error', reject); }); // Read MP3 bytes const mp3Buffer = await fs.readFile(outPath); // Clean up temp files await Promise.all([fs.unlink(inputPath), fs.unlink(outPath)]); return mp3Buffer; } // Voice note handler: download → detect → convert → store async function handleAudioMessage(msg, from) { const { id: mediaId, voice, mime_type } = msg.audio; const isVoiceNote = voice === true; const { url } = await resolveMediaUrl(mediaId); const rawBytes = await downloadMedia(url); let finalBytes = rawBytes; let finalMime = mime_type; if (isVoiceNote || mime_type.includes('ogg')) { // Convert OGG/Opus → MP3 for compatibility + Whisper transcription finalBytes = await convertOggToMp3(rawBytes); finalMime = 'audio/mpeg'; } const storagePath = await storeFile(finalBytes, 'audio', mediaId, finalMime); return { mediaId, storagePath, isVoiceNote, mime_type: finalMime }; }
Python + subprocess
convert_voice_note.py
import subprocess, tempfile, os from pathlib import Path def convert_ogg_to_mp3(ogg_bytes: bytes) -> bytes: """Convert OGG/Opus voice note bytes → MP3 bytes via ffmpeg.""" with tempfile.TemporaryDirectory() as tmp_dir: input_path = Path(tmp_dir) / "input.ogg" output_path = Path(tmp_dir) / "output.mp3" # Write OGG bytes to temp file input_path.write_bytes(ogg_bytes) # Run ffmpeg conversion result = subprocess.run([ "ffmpeg", "-y", # overwrite without asking "-i", str(input_path), "-codec:a", "libmp3lame", # MP3 encoder "-qscale:a", "2", # ~190kbps quality "-ar", "44100", # sample rate for Whisper compatibility str(output_path) ], capture_output=True) if result.returncode != 0: raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()}") return output_path.read_bytes() def handle_audio_message(msg: dict, sender: str) -> dict: audio = msg["audio"] media_id = audio["id"] is_voice = audio.get("voice", False) mime_type = audio.get("mime_type", "") media_info = resolve_media_url(media_id) raw_bytes = download_media(media_info["url"]) if is_voice or "ogg" in mime_type: final_bytes = convert_ogg_to_mp3(raw_bytes) final_mime = "audio/mpeg" else: final_bytes = raw_bytes final_mime = mime_type storage_path = store_file(final_bytes, "audio", media_id, final_mime) return { "media_id": media_id, "path": storage_path, "is_voice": is_voice }

Transcripción de notas de voz con OpenAI Whisper

Una vez que tienes el MP3, envíalo a la API Whisper de OpenAI. Whisper admite 57 idiomas, maneja bien el ruido de fondo y cuesta aproximadamente $0.006 por minuto de audio — una nota de voz de 30 segundos cuesta menos de un centavo para transcribir. La transcripción luego se convierte en texto consultable y procesable por IA.

Node.js + OpenAI SDK
transcribeVoiceNote.js
const OpenAI = require('openai'); const { Readable } = require('stream'); const oai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); async function transcribeAudio(mp3Buffer, language = 'en') { // Whisper expects a File-like object — wrap the buffer const file = new File([mp3Buffer], 'voice_note.mp3', { type: 'audio/mpeg' }); const response = await oai.audio.transcriptions.create({ model: 'whisper-1', file, language, // optional — auto-detect if omitted response_format: 'verbose_json', // includes confidence + segments }); return { text: response.text, // full transcript language: response.language, // detected language duration: response.duration, // audio duration in seconds segments: response.segments, // word-level timing (verbose_json only) }; } // Full voice note pipeline: download → convert → transcribe → store async function processVoiceNote(msg, from) { const { mediaId, storagePath, isVoiceNote } = await handleAudioMessage(msg, from); if (!isVoiceNote) return { mediaId, storagePath, transcript: null }; // Read the stored MP3 for transcription const mp3Buffer = await readFromStorage(storagePath); const transcript = await transcribeAudio(mp3Buffer); // Store the transcript alongside the audio await saveTranscript(mediaId, from, transcript); console.log(`[${from}] Voice note: "${transcript.text}"`); return { mediaId, storagePath, transcript }; }
Python + OpenAI SDK
transcribe_voice_note.py
import io from openai import OpenAI client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) def transcribe_audio(mp3_bytes: bytes, language: str = "en") -> dict: """Transcribe MP3 audio bytes using OpenAI Whisper.""" audio_file = io.BytesIO(mp3_bytes) audio_file.name = "voice_note.mp3" # SDK reads .name for MIME detection response = client.audio.transcriptions.create( model="whisper-1", file=audio_file, language=language, # omit for auto-detect response_format="verbose_json" # includes segments + confidence ) return { "text": response.text, "language": response.language, "duration": response.duration, } def process_voice_note(msg: dict, sender: str) -> dict: result = handle_audio_message(msg, sender) if not result["is_voice"]: return {**result, "transcript": None} mp3_bytes = read_from_storage(result["path"]) transcript = transcribe_audio(mp3_bytes) save_transcript(result["media_id"], sender, transcript) return {**result, "transcript": transcript}
Qué hacer con la transcripción: Almacénala junto al archivo de audio. Pásala a tu agente de IA como el mensaje del usuario (en lugar de "mensaje de audio recibido"). Indexla en tu base de datos de búsqueda. Regístrala en tu CRM como el texto de la conversación. Los clientes envían notas de voz porque escribir es más lento — tratar sus notas de voz como texto buscable mejora drásticamente la capacidad de tu agente de IA para entender y responder correctamente.

Patrones de almacenamiento: organiza tus archivos de medios

Cómo organizas los archivos en S3 o GCS importa para el rendimiento, la facturación y la depuración. Aquí está el patrón de clave de almacenamiento que escala limpiamente a través de múltiples clientes y tipos de mensajes:

Node.js + AWS S3
storeFile.js
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); const mime2ext = require('mime-types'); const s3 = new S3Client({ region: process.env.AWS_REGION }); const BUCKET = process.env.S3_BUCKET; async function storeFile(bytes, type, mediaId, mimeType, filename) { const ext = mime2ext.extension(mimeType) || 'bin'; const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD // Organized key: media/{type}/{date}/{mediaId}.{ext} // For documents, preserve original filename in metadata const key = `media/${type}/${date}/${mediaId}.${ext}`; await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: bytes, ContentType: mimeType, Metadata: { 'original-filename': filename || mediaId, 'media-id': mediaId, }, })); return `s3://${BUCKET}/${key}`; // store this path in your database } // S3 key examples: // media/image/2026-05-13/wamid.img123.jpeg // media/audio/2026-05-13/wamid.aud456.mp3 ← converted from OGG // media/document/2026-05-13/wamid.doc789.pdf // media/video/2026-05-13/wamid.vid012.mp4

Handler completo de webhook de medios (todos los tipos)

Una función que recibe un evento de medios normalizado y dirige al handler correcto según el tipo:

Node.js — universal media dispatcher
mediaDispatcher.js
async function dispatchMedia(event) { const { from, message } = event; const { type } = message; // Text messages — not media, handle separately if (type === 'text') return handleText(from, message.text.body); // Non-media message types if (['location', 'contacts', 'reaction', 'interactive'].includes(type)) { return handleNonMedia(from, message); } // Media types — all require download switch (type) { case 'image': case 'video': case 'sticker': case 'document': { const result = await handleMediaMessage(message, from); await saveMediaRecord(from, result); break; } case 'audio': { // Voice notes get transcribed; regular audio files just stored const result = await processVoiceNote(message, from); await saveMediaRecord(from, result); // If voice note was transcribed, treat transcript as text input if (result.transcript?.text) { console.log(`Voice note from ${from}: "${result.transcript.text}"`); await handleText(from, result.transcript.text, { isVoiceNote: true }); } break; } default: console.warn(`Unhandled media type: ${type}`); } }

SocialHook: IDs de medios pre-extraídos en formato normalizado

Cuando usas SocialHook, el payload de medios llega ya extraído del envoltorio anidado de la Cloud API de Meta. En lugar de navegar por entry[0].changes[0].value.messages[0], recibes un evento plano:

Tu función dispatchMedia(event) anterior recibe este formato directamente — sin necesidad de análisis adicional. El ID de medio está en event.message.image.id (o .audio.id, .document.id, etc.), listo para alimentar a resolveMediaUrl().

Preguntas frecuentes

¿Cómo descargo medios de un webhook de WhatsApp Cloud API?
Dos pasos: (1) Llama a GET graph.facebook.com/v21.0/{media_id} con tu token de acceso para obtener una URL de descarga temporal. (2) Haz fetch de esa URL (también con tu token de acceso en el encabezado Authorization) para obtener los bytes del archivo. Descarga inmediatamente — la URL vence en aproximadamente 5 minutos. Almacena los bytes del archivo en S3/GCS/disco, no la URL temporal.
¿Por qué mi URL de descarga de medios de WhatsApp devuelve 403?
Dos causas: (1) URL vencida — las URLs de descarga de medios de WhatsApp son válidas por aproximadamente 5 minutos. Si almacenaste la URL y la obtuviste después, venció. Re-resuelve desde el ID de medio. (2) Encabezado Authorization faltante — la URL de descarga en sí también requiere Authorization: Bearer {ACCESS_TOKEN} en la solicitud. Las URLs de medios de WhatsApp no son URLs de CDN públicas.
¿En qué formato están las notas de voz de WhatsApp y cómo las convierto?
Las notas de voz de WhatsApp están en formato OGG/Opus (identificadas por voice: true en el payload y mime_type: "audio/ogg; codecs=opus"). Convierte a MP3 con ffmpeg: ffmpeg -i input.ogg -codec:a libmp3lame -qscale:a 2 output.mp3. OGG/Opus no es compatible con OpenAI Whisper ni con la mayoría de los navegadores — siempre convierte antes de la transcripción o reproducción. El código completo en Node.js y Python está en la sección de notas de voz arriba.
¿Cómo transcribo notas de voz de WhatsApp con Whisper?
Descarga el OGG → convierte a MP3 con ffmpeg → envía a la API de Transcripciones de Audio de OpenAI con model: "whisper-1". Whisper devuelve el texto de la transcripción. El costo es ~$0.006 por minuto de audio. Una nota de voz de 30 segundos cuesta menos de un tercio de un centavo. La transcripción puede luego ser tratada como un mensaje de texto regular por tu agente de IA o CRM.
¿Cuánto tiempo son válidos los IDs de medios de WhatsApp?
Los IDs de medios son válidos por 30 días. La URL de descarga temporal que obtienes al resolver el ID es válida por aproximadamente 5 minutos. Esto significa que puedes almacenar de forma segura solo el ID de medio en tu base de datos y resolverlo cuando necesites el archivo (dentro de los 30 días). Después de 30 días, los medios se eliminan de los servidores de Meta y el ID es inválido.
¿Cuáles son los límites de tamaño de archivo para los medios de WhatsApp?
Por tipo: Imagen (JPEG, PNG) — 5 MB. Audio — 16 MB. Video — 16 MB. Documento — 100 MB. Sticker — 500 KB estático, 100 KB animado. Los mensajes con medios que superen estos límites son rechazados antes de que tu webhook se active — el remitente ve un fallo, no tú.

Tu pipeline comienza con
un evento de webhook limpio.

SocialHook entrega cada evento de medios de WhatsApp — imagen, nota de voz, documento — como JSON pre-extraído a tu handler. Obtienes el ID de medio listo para resolver, el tipo MIME y el indicador de voz. Sin análisis de la Cloud API. Solo conéctalo directamente a tu función de descarga.

Sin tarjeta de crédito · $50/mes después del período de prueba · Cancela cuando quieras