Pipeline webhook médias WhatsApp — de l'ID média à l'URL de téléchargement vers le fichier converti, avec conversion note vocale OGG en MP3 et transcription Whisper
Dans ce guide : Tableau de référence des types de médias · L'expiration en 5 minutes de l'URL · Pipeline de téléchargement complet · Tous les schémas de payload médias · Gestion des images et documents · Conversion note vocale OGG→MP3 · Transcription Whisper · Modèles de stockage S3/GCS · Handler complet pour tous les types · Format normalisé SocialHook

Types de médias : tableau de référence complet

La Cloud API fournit 8 types de messages média, chacun avec des formats différents, des limites de taille et des exigences de gestion. Connaissez les contraintes avant de construire le handler.

valeur type Formats acceptés Limite de taille Notes spéciales
image JPEG, PNG 5 Mo Optionnel caption. GIF non supporté en ligne — envoyer comme document.
audio AAC, AMR, MP3, OGG/Opus 16 Mo voice: true si enregistré dans l'app (OGG/Opus). Les fichiers audio réguliers peuvent varier. Toujours convertir OGG avant transcription.
video MP4, 3GPP 16 Mo Optionnel caption. Vidéo H.264 + audio AAC recommandés pour une meilleure compatibilité.
document Tout type MIME accepté par Meta 100 Mo Inclut filename — conservez-le. PDF, DOCX, XLSX, images en tant que documents, etc.
sticker WebP Statique 500 Ko / Animé 100 Ko animated: true si animé. Format WebP — les navigateurs le supportent nativement désormais.

L'expiration en 5 minutes — le détail qui brise la plupart des implémentations

C'est le détail le plus souvent manqué dans la gestion des médias WhatsApp, et il cause la plupart des bugs en production. Lorsque vous appelez l'endpoint média de la Cloud API pour résoudre un ID média, vous recevez une URL de téléchargement temporaire. Cette URL est valide pendant environ 5 minutes.

Les deux patterns utilisés par les développeurs — et pourquoi l'un échoue :

  • ❌ Stocker l'URL, télécharger plus tard — vous recevez le webhook, appelez l'endpoint média, stockez l'URL temporaire dans votre base de données pour un traitement asynchrone. Au moment où votre worker la récupère, l'URL a expiré. Vous obtenez un 403. Ce pattern échoue.
  • ✓ Télécharger immédiatement, stocker le fichier — vous recevez le webhook, résolvez immédiatement l'ID média en URL, téléchargez immédiatement les octets, stockez le fichier sur votre propre stockage (S3, GCS, disque), enregistrez uniquement le chemin de stockage dans votre base de données. Le traitement asynchrone fonctionne sur le fichier stocké. Ce pattern est correct.
Alternative : stocker l'ID média, résoudre à la demande. Si vous n'avez pas besoin du fichier immédiatement, vous pouvez stocker uniquement l'ID média et le résoudre lorsque vous avez besoin du fichier. Les IDs médias sont valides pendant 30 jours — bien plus longtemps que l'URL de téléchargement temporaire. C'est utile quand vous n'êtes pas sûr d'avoir jamais besoin du binaire (ex. stickers que vous pourriez ignorer). Sachez simplement que l'appel de résolution ajoute de la latence quand vous en avez finalement besoin.

Le pipeline de téléchargement : ID → URL → octets → stockage

1
Recevoir webhook — extraire l'ID média
msg.image.id / msg.audio.id / msg.document.id — PAS une URL, juste une référence
2
Retourner HTTP 200 immédiatement faites ceci en premier
Accusez réception à Meta avant tout téléchargement. Poussez le traitement média vers la file asynchrone.
3
Résoudre l'ID média → URL de téléchargement temporaire
GET graph.facebook.com/v21.0/{media_id} — nécessite Authorization: Bearer {token} — retourne url (expire ~5min) + mime_type + file_size
4
Télécharger les octets du fichier dans les 5 minutes
GET {download_url} — nécessite AUSSI Authorization: Bearer {token} — retourne les octets binaires bruts
5
Stocker le fichier sur S3 / GCS / disque local
Organiser par : media/{client_id}/{media_type}/{date}/{media_id}.{ext}
6
Traiter (spécifique au type)
Image : extraire les métadonnées, générer une miniature. Audio/voix : convertir OGG→MP3, transcrire. Document : extraire le texte pour la recherche. Vidéo : générer une frame miniature.

Schémas de payload pour chaque type de média

Voici la structure exacte de l'objet value du webhook pour chaque type de média. L'imbrication depuis l'enveloppe de niveau supérieur (entry[0].changes[0].value.messages[0]) est déjà extraite lors de l'utilisation du format normalisé de SocialHook.

Gestion des images et documents

Les images et les documents partagent le même pattern de téléchargement en deux étapes. La différence clé : les documents incluent un champ filename que vous devez conserver dans votre clé de stockage — c'est le nom que le client a donné au fichier et ce que vous voudrez afficher dans votre 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, }

Notes vocales : conversion OGG/Opus en MP3

Les notes vocales enregistrées dans WhatsApp sont encodées au format OGG/Opus. Vous pouvez les identifier par le drapeau voice: true dans le payload audio et la valeur mime_type: "audio/ogg; codecs=opus". Ce format n'est pas supporté par OpenAI Whisper pour la transcription, et a un support de lecture limité dans les navigateurs.

La solution : convertir en MP3 avec ffmpeg. ffmpeg est l'outil universel de conversion audio, disponible sur tous les principaux OS et tous les environnements cloud.

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 }

Transcription des notes vocales avec OpenAI Whisper

Une fois que vous avez le MP3, envoyez-le à l'API Whisper d'OpenAI. Whisper supporte 57 langues, gère bien le bruit de fond, et coûte environ $0,006 par minute d'audio — une note vocale de 30 secondes coûte moins d'un centime à transcrire. La transcription devient ensuite du texte interrogeable et traitable par 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}
Que faire avec la transcription : Stockez-la aux côtés du fichier audio. Transmettez-la à votre agent IA comme message de l'utilisateur (au lieu de « message audio reçu »). Indexez-la dans votre base de données de recherche. Enregistrez-la dans votre CRM comme texte de conversation. Les clients envoient des notes vocales parce que la saisie est plus lente — traiter leurs notes vocales comme du texte consultable améliore considérablement la capacité de votre agent IA à comprendre et répondre correctement.

Modèles de stockage : organisez vos fichiers médias

La façon dont vous organisez les fichiers dans S3 ou GCS est importante pour les performances, la facturation et le débogage. Voici le pattern de clé de stockage qui s'adapte proprement à plusieurs clients et types de messages :

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 webhook média complet (tous types)

Une fonction qui reçoit un événement média normalisé et route vers le handler correct selon le type :

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 médias pré-extraits en format normalisé

Lorsque vous utilisez SocialHook, le payload média arrive déjà extrait de l'enveloppe imbriquée de la Cloud API de Meta. Au lieu de naviguer dans entry[0].changes[0].value.messages[0], vous recevez un événement plat :

Votre fonction dispatchMedia(event) ci-dessus reçoit ce format directement — aucune analyse supplémentaire nécessaire. L'ID média est à event.message.image.id (ou .audio.id, .document.id, etc.), prêt à alimenter resolveMediaUrl().

Questions fréquentes

Comment télécharger des médias depuis un webhook WhatsApp Cloud API ?
Deux étapes : (1) Appelez GET graph.facebook.com/v21.0/{media_id} avec votre token d'accès pour obtenir une URL de téléchargement temporaire. (2) Récupérez cette URL (également avec votre token d'accès dans l'en-tête Authorization) pour obtenir les octets du fichier. Téléchargez immédiatement — l'URL expire en environ 5 minutes. Stockez les octets du fichier sur S3/GCS/disque, pas l'URL temporaire.
Pourquoi mon URL de téléchargement de médias WhatsApp retourne-t-elle 403 ?
Deux causes : (1) URL expirée — les URL de téléchargement de médias WhatsApp sont valides environ 5 minutes. Si vous avez stocké l'URL et l'avez récupérée plus tard, elle a expiré. Re-résolvez depuis l'ID média. (2) En-tête Authorization manquant — l'URL de téléchargement elle-même nécessite aussi Authorization: Bearer {ACCESS_TOKEN} dans la requête. Les URL de médias WhatsApp ne sont pas des URL CDN publiques.
Dans quel format sont les notes vocales WhatsApp et comment les convertir ?
Les notes vocales WhatsApp sont au format OGG/Opus (identifiées par voice: true dans le payload et mime_type: "audio/ogg; codecs=opus"). Convertissez en MP3 avec ffmpeg : ffmpeg -i input.ogg -codec:a libmp3lame -qscale:a 2 output.mp3. OGG/Opus n'est pas supporté par OpenAI Whisper ni par la plupart des navigateurs — convertissez toujours avant la transcription ou la lecture. Le code complet Node.js et Python est dans la section notes vocales ci-dessus.
Comment transcrire des notes vocales WhatsApp avec Whisper ?
Téléchargez le OGG → convertissez en MP3 avec ffmpeg → envoyez à l'API Audio Transcriptions d'OpenAI avec model: "whisper-1". Whisper retourne le texte de transcription. Le coût est ~$0,006 par minute d'audio. Une note vocale de 30 secondes coûte moins d'un tiers de centime. La transcription peut ensuite être traitée comme un message texte ordinaire par votre agent IA ou CRM.
Quelle est la durée de validité des IDs médias WhatsApp ?
Les IDs médias sont valides 30 jours. L'URL de téléchargement temporaire obtenue en résolvant l'ID est valide environ 5 minutes. Cela signifie que vous pouvez stocker en toute sécurité uniquement l'ID média dans votre base de données et le résoudre lorsque vous avez besoin du fichier (dans les 30 jours). Après 30 jours, le média est supprimé des serveurs de Meta et l'ID est invalide.
Quelles sont les limites de taille de fichier pour les médias WhatsApp ?
Par type : Image (JPEG, PNG) — 5 Mo. Audio — 16 Mo. Vidéo — 16 Mo. Document — 100 Mo. Sticker — 500 Ko statique, 100 Ko animé. Les messages avec des médias dépassant ces limites sont rejetés avant que votre webhook se déclenche — l'expéditeur voit un échec, pas vous.

Votre pipeline commence avec
un événement webhook propre.

SocialHook livre chaque événement média WhatsApp — image, note vocale, document — en JSON pré-extrait à votre handler. Vous obtenez l'ID média prêt à résoudre, le type MIME et le drapeau voix. Pas d'analyse Cloud API. Branchez-le directement à votre fonction de téléchargement.

Aucune carte de crédit requise · 50 $/mois après l'essai · Annulez à tout moment