WhatsApp-Medien-Webhook-Pipeline — von der Medien-ID zur Download-URL zur konvertierten Datei, mit Sprachnachricht OGG zu MP3 Konvertierung und Whisper-Transkription
In diesem Leitfaden: Referenztabelle der Medientypen · Das 5-Minuten-URL-Ablaufen · Vollständige Download-Pipeline · Alle Medien-Payload-Schemas · Bild- und Dokumentenhandling · Sprachnachricht OGG→MP3 Konvertierung · Whisper-Transkription · S3/GCS-Speichermuster · Vollständiger Handler für alle Typen · Normalisiertes SocialHook-Format

Medientypen: vollständige Referenztabelle

Die Cloud API liefert 8 Mediennachrichtentypen, jeweils mit unterschiedlichen Formaten, Größenlimits und Handlinganforderungen. Kennen Sie die Einschränkungen, bevor Sie den Handler erstellen.

type-Wert Akzeptierte Formate Größenlimit Besondere Hinweise
image JPEG, PNG 5 MB Optionales caption. GIF nicht inline unterstützt — als Dokument senden.
audio AAC, AMR, MP3, OGG/Opus 16 MB voice: true wenn in der App aufgenommen (OGG/Opus). Reguläre Audiodateien können variieren. Immer OGG vor der Transkription konvertieren.
video MP4, 3GPP 16 MB Optionales caption. H.264-Video + AAC-Audio für breiteste Kompatibilität empfohlen.
document Beliebiger von Meta akzeptierter MIME-Typ 100 MB Enthält filename — speichern. PDF, DOCX, XLSX, Bilder als Dokumente usw.
sticker WebP Statisch 500 KB / Animiert 100 KB animated: true wenn animiert. WebP-Format — Browser unterstützen es jetzt nativ.

Das 5-Minuten-URL-Ablaufen — das Detail, das die meisten Implementierungen zum Scheitern bringt

Dies ist das am häufigsten übersehene Detail beim WhatsApp-Medienhandling und verursacht die meisten Produktionsfehler. Wenn Sie den Cloud API Media-Endpunkt aufrufen, um eine Medien-ID aufzulösen, erhalten Sie eine temporäre Download-URL. Diese URL ist für ca. 5 Minuten gültig.

Die zwei Muster, die Entwickler verwenden — und warum eines scheitert:

  • ❌ URL speichern, später herunterladen — Sie empfangen den Webhook, rufen den Media-Endpunkt auf, speichern die temporäre URL in Ihrer Datenbank für asynchrone Verarbeitung. Bis Ihr Worker sie aufgreift, ist die URL abgelaufen. Sie erhalten einen 403. Dieses Muster scheitert.
  • ✓ Sofort herunterladen, Datei speichern — Sie empfangen den Webhook, lösen sofort die Medien-ID zu einer URL auf, laden sofort die Bytes herunter, speichern die Datei in Ihrem eigenen Speicher (S3, GCS, Festplatte), speichern nur den Speicherpfad in Ihrer Datenbank. Die asynchrone Verarbeitung arbeitet mit der gespeicherten Datei. Dieses Muster ist korrekt.
Alternative: Medien-ID speichern, bei Bedarf auflösen. Wenn Sie die Datei nicht sofort benötigen, können Sie nur die Medien-ID speichern und sie bei Bedarf frisch auflösen. Medien-IDs sind 30 Tage gültig — viel länger als die temporäre Download-URL. Dies ist nützlich, wenn Sie nicht sicher sind, ob Sie die Binärdatei jemals benötigen werden (z. B. Sticker, die Sie möglicherweise ignorieren). Beachten Sie jedoch, dass der Auflösungsaufruf Latenz hinzufügt, wenn Sie ihn schließlich benötigen.

Die Download-Pipeline: ID → URL → Bytes → Speicher

1
Webhook empfangen — Medien-ID extrahieren
msg.image.id / msg.audio.id / msg.document.id — KEINE URL, nur eine Referenz
2
Sofort HTTP 200 zurückgeben das zuerst
An Meta bestätigen, bevor Downloads beginnen. Medienverarbeitung in asynchrone Warteschlange schieben.
3
Medien-ID → temporäre Download-URL auflösen
GET graph.facebook.com/v21.0/{media_id} — erfordert Authorization: Bearer {token} — gibt url (läuft ~5min ab) + mime_type + file_size zurück
4
Datei-Bytes herunterladen innerhalb von 5 Minuten
GET {download_url} — erfordert AUCH Authorization: Bearer {token} — gibt rohe Binär-Bytes zurück
5
Datei auf S3 / GCS / lokale Festplatte speichern
Organisieren nach: media/{client_id}/{media_type}/{date}/{media_id}.{ext}
6
Verarbeiten (typspezifisch)
Bild: Metadaten extrahieren, Miniaturansicht erstellen. Audio/Sprache: OGG→MP3 konvertieren, transkribieren. Dokument: Text für Suche extrahieren. Video: Miniaturansichtsframe erstellen.

Payload-Schemas für jeden Medientyp

Hier ist die genaue Webhook-Value-Objektstruktur für jeden Medientyp. Die Verschachtelung aus der obersten Ebene des Envelopes (entry[0].changes[0].value.messages[0]) ist bereits extrahiert, wenn das normalisierte Format von SocialHook verwendet wird.

Bild- und Dokumentenhandling

Bilder und Dokumente teilen dasselbe zweistufige Download-Muster. Der wesentliche Unterschied: Dokumente enthalten ein filename-Feld, das Sie in Ihrem Speicherschlüssel beibehalten sollten — es ist der Name, den der Kunde der Datei gegeben hat, und was Sie in Ihrer UI anzeigen möchten.

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, }

Sprachnachrichten: OGG/Opus zu MP3 Konvertierung

In WhatsApp aufgenommene Sprachnachrichten sind im OGG/Opus-Format kodiert. Sie können sie am voice: true-Flag im Audio-Payload und dem Wert mime_type: "audio/ogg; codecs=opus" erkennen. Dieses Format wird von OpenAI Whisper für die Transkription nicht unterstützt und hat begrenzte Browser-Wiedergabeunterstützung.

Die Lösung: Mit ffmpeg in MP3 konvertieren. ffmpeg ist das universelle Audio-Konvertierungstool, auf jedem wichtigen Betriebssystem und in allen Cloud-Umgebungen verfügbar.

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 }

Sprachnachrichten mit OpenAI Whisper transkribieren

Wenn Sie das MP3 haben, senden Sie es an die Whisper-API von OpenAI. Whisper unterstützt 57 Sprachen, verarbeitet Hintergrundgeräusche gut und kostet ca. $0,006 pro Audiominute — eine 30-Sekunden-Sprachnachricht kostet weniger als einen Cent zum Transkribieren. Die Transkription wird dann zu abfragbarem, KI-verarbeitbarem Text.

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}
Was mit der Transkription zu tun ist: Speichern Sie sie neben der Audiodatei. Übergeben Sie sie als Benutzernachricht an Ihren KI-Agenten (anstatt „Audionachricht empfangen"). Indizieren Sie sie in Ihrer Suchdatenbank. Protokollieren Sie sie in Ihrem CRM als Konversationstext. Kunden senden Sprachnachrichten, weil Tippen langsamer ist — das Behandeln ihrer Sprachnachrichten als durchsuchbaren Text verbessert die Fähigkeit Ihres KI-Agenten, korrekt zu verstehen und zu antworten, erheblich.

Speichermuster: Mediendateien organisieren

Wie Sie Dateien in S3 oder GCS organisieren, ist wichtig für Leistung, Abrechnung und Debugging. Hier ist das Speicherschlüsselmuster, das sauber über mehrere Clients und Nachrichtentypen skaliert:

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

Vollständiger Medien-Webhook-Handler (alle Typen)

Eine Funktion, die ein normalisiertes Medienereignis empfängt und je nach Typ zum richtigen Handler weiterleitet:

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: vorab extrahierte Medien-IDs im normalisierten Format

Wenn Sie SocialHook verwenden, kommt der Medien-Payload bereits aus dem verschachtelten Cloud API-Envelope von Meta extrahiert an. Anstatt durch entry[0].changes[0].value.messages[0] zu navigieren, erhalten Sie ein flaches Ereignis:

Ihre obige dispatchMedia(event)-Funktion empfängt dieses Format direkt — keine zusätzliche Analyse erforderlich. Die Medien-ID befindet sich unter event.message.image.id (oder .audio.id, .document.id usw.), bereit zum Einspeisen in resolveMediaUrl().

Häufige Fragen

Wie lade ich Medien von einem WhatsApp Cloud API Webhook herunter?
Zwei Schritte: (1) Rufen Sie GET graph.facebook.com/v21.0/{media_id} mit Ihrem Zugriffstoken auf, um eine temporäre Download-URL zu erhalten. (2) Rufen Sie diese URL ab (ebenfalls mit Ihrem Zugriffstoken im Authorization-Header), um die Datei-Bytes zu erhalten. Sofort herunterladen — die URL läuft in ca. 5 Minuten ab. Speichern Sie die Datei-Bytes auf S3/GCS/Festplatte, nicht die temporäre URL.
Warum gibt meine WhatsApp-Medien-Download-URL 403 zurück?
Zwei Ursachen: (1) URL abgelaufen — WhatsApp-Medien-Download-URLs sind ca. 5 Minuten gültig. Wenn Sie die URL gespeichert und später abgerufen haben, ist sie abgelaufen. Erneut von der Medien-ID auflösen. (2) Fehlender Authorization-Header — die Download-URL selbst erfordert auch Authorization: Bearer {ACCESS_TOKEN} in der Anfrage. WhatsApp-Medien-URLs sind keine öffentlichen CDN-URLs.
In welchem Format sind WhatsApp-Sprachnachrichten und wie konvertiere ich sie?
WhatsApp-Sprachnachrichten sind im OGG/Opus-Format (erkennbar an voice: true im Payload und mime_type: "audio/ogg; codecs=opus"). Mit ffmpeg in MP3 konvertieren: ffmpeg -i input.ogg -codec:a libmp3lame -qscale:a 2 output.mp3. OGG/Opus wird von OpenAI Whisper oder den meisten Browsern nicht unterstützt — immer vor Transkription oder Wiedergabe konvertieren. Vollständiger Node.js- und Python-Code befindet sich im obigen Abschnitt zu Sprachnachrichten.
Wie transkribiere ich WhatsApp-Sprachnachrichten mit Whisper?
OGG herunterladen → mit ffmpeg in MP3 konvertieren → an die OpenAI Audio Transcriptions API senden mit model: "whisper-1". Whisper gibt den Transkriptionstext zurück. Kosten ca. $0,006 pro Audiominute. Eine 30-Sekunden-Sprachnachricht kostet weniger als ein Drittel Cent. Die Transkription kann dann von Ihrem KI-Agenten oder CRM als reguläre Textnachricht behandelt werden.
Wie lange sind WhatsApp-Medien-IDs gültig?
Medien-IDs sind 30 Tage gültig. Die temporäre Download-URL, die Sie durch Auflösen der ID erhalten, ist ca. 5 Minuten gültig. Das bedeutet, Sie können sicher nur die Medien-ID in Ihrer Datenbank speichern und sie bei Bedarf frisch auflösen (innerhalb von 30 Tagen). Nach 30 Tagen werden die Medien von Metas Servern gelöscht und die ID ist ungültig.
Welche Dateigrößenbeschränkungen gelten für WhatsApp-Medien?
Nach Typ: Bild (JPEG, PNG) — 5 MB. Audio — 16 MB. Video — 16 MB. Dokument — 100 MB. Sticker — 500 KB statisch, 100 KB animiert. Nachrichten mit Medien, die diese Limits überschreiten, werden abgelehnt, bevor Ihr Webhook ausgelöst wird — der Absender sieht einen Fehler, nicht Sie.

Ihre Pipeline beginnt mit
einem sauberen Webhook-Ereignis.

SocialHook liefert jedes WhatsApp-Medienereignis — Bild, Sprachnachricht, Dokument — als vorab extrahiertes JSON an Ihren Handler. Sie erhalten die bereite Medien-ID zum Auflösen, den MIME-Typ und das Sprach-Flag. Kein Cloud API-Parsing. Direkt in Ihre Download-Funktion einleiten.

Keine Kreditkarte erforderlich · $50/Monat nach Testzeitraum · Jederzeit kündigen