WhatsApp Cloud API 媒体消息全链路:上传协议、类型约束、图片/视频预处理与发送管线

别再直接把 8MB 的原图往 /messages 接口里怼了------WhatsApp 的媒体管线比你想象的要挑剔得多。


1. 为什么媒体消息值得单独拆一篇

文本消息是字节流,媒体消息是资源状态机。一张图片从你的磁盘到对方的 WhatsApp 收件箱,中间至少经历:本地预处理 → HTTP multipart 上传 → 云端转码 → media_id 分配 → /messages 引用发送 → 客户端下载渲染。

这条链路里至少六个环节会出错:格式不匹配、尺寸超限、编解码器版本差异、moov atom 位置错误、media_id 过期、Webhook 乱序到达------任何一个都能让你的"群发活动"变成"群发事故"。

本文不聊"怎么调用 API"(这类教程太多了),而是聚焦管线设计和工程化约束:当你需要以每天数万条媒体消息的规模运行时,什么会炸,为什么炸,以及怎么让它不炸。


2. 媒体类型全景:支持什么、不支持什么

首先把约束摊开。WhatsApp Cloud API 当前支持的媒体类型如下表:

类型 扩展名 MIME 类型 大小上限 关键约束
图片 .jpeg .jpg image/jpeg 5 MB 8-bit, RGB 或 RGBA
图片 .png image/png 5 MB 8-bit, RGB 或 RGBA
视频 .mp4 video/mp4 16 MB H.264 视频 + AAC 音频;仅单音频流
视频 .3gp video/3gpp 16 MB 同上
音频 .aac audio/aac 16 MB ---
音频 .mp3 audio/mpeg 16 MB ---
音频 .ogg audio/ogg 16 MB 仅 OPUS 编解码器;仅单声道
音频 .amr .m4a audio/amr audio/mp4 16 MB ---
文档 .pdf application/pdf 100 MB ---
文档 .doc .docx application/msword 100 MB ---
文档 .xls .xlsx application/vnd.ms-excel 100 MB ---
文档 .ppt .pptx application/vnd.ms-powerpoint 100 MB ---
文档 .txt text/plain 100 MB ---
贴纸(静态) .webp image/webp 100 KB 512x512 像素
贴纸(动态) .webp image/webp 500 KB 512x512 像素

三样东西 WhatsApp 明确不支持

  1. GIF 动画 --- 想发动图?先 ffmpeg 转成 MP4 再用视频类型发送。
  2. HEIC/HEIF --- iPhone 默认格式不在支持列表里,上传前必须转 JPEG/PNG。
  3. SVG/BMP/TIFF --- 只有 JPEG 和 PNG 能走图片通道。

一个自检命令:file -I your_asset.jpg 看 MIME 类型,别信文件扩展名。


3. 上传链路:multipart/form-data 的正确姿势

3.1 两阶段模型

媒体发送拆成两步不是"设计",是物理约束

复制代码
┌──────────────┐     POST /media      ┌──────────────┐
│  你的服务器   │ ──────────────────→ │  Meta 媒体存储 │
│              │ ←── { id: "123.." } │              │
└──────────────┘                      └──────┬───────┘
        │                                    │
        │   POST /messages                    │
        │   { type: "image",                 │
        │     image: { id: "123.." } }       │
        ▼                                    ▼
┌──────────────────────────────────────────────┐
│              最终用户手机                      │
└──────────────────────────────────────────────┘

为什么不直接把文件塞进 /messages payload? 三个原因:① 文件可能远大于 API payload 的合理上限;② 上传可复用------同一个 media_id 在 30 天内重复引用;③ Meta 需要对上传文件做病毒扫描和转码,异步解耦是合理架构。

3.2 上传端点的实际行为

python 复制代码
import requests

def upload_media(access_token: str, phone_number_id: str,
                 file_path: str, mime_type: str) -> str:
    """
    上传媒体文件到 WhatsApp Cloud API,返回 media_id。
    
    Args:
        access_token: 长期或短期访问令牌
        phone_number_id: 业务电话号码 ID
        file_path: 本地文件路径
        mime_type: 正确的 MIME 类型(不是扩展名!)
    
    Returns:
        media_id: 26 位数字字符串
    
    Raises:
        UploadError: 上传或校验失败
    """
    url = f"https://graph.facebook.com/v22.0/{phone_number_id}/media"
    
    with open(file_path, "rb") as f:
        response = requests.post(
            url,
            headers={"Authorization": f"Bearer {access_token}"},
            files={
                "file": (file_path, f, mime_type)
            },
            data={"messaging_product": "whatsapp"}
        )
    
    if response.status_code != 200:
        raise UploadError(
            f"上传失败 HTTP {response.status_code}: {response.text}"
        )
    
    media_id = response.json().get("id")
    if not media_id:
        raise UploadError("响应中未包含 media_id")
    
    return media_id

关键细节:

  • Content-Type 由 files 参数的第三元组决定,不是自动推断的。填错 MIME 类型 → 上传成功但后续发送静默失败。
  • messaging_product=whatsapp 必须作为 form field 传入,不是 header。
  • 上传是同步的 ,大文件(接近 100MB 的文档)可能需要 10-15 秒,务必设置合理的 timeout

4. 图片预处理管线:不做就等着被拒

拿着手机拍的照片就往 API 里怼,是最常见的失败模式。原因很简单:

  • iPhone 默认 HEIC 格式 → API 不认识
  • 一张照片 8-12 MB → 超过 5 MB 上限
  • 4096x3072 像素 → 客户端加载缓慢且浪费带宽

生产环境应该有一个强制通行的预处理管线:

python 复制代码
from PIL import Image
import io
from typing import Tuple, Optional

class WhatsAppImagePreprocessor:
    """
    WhatsApp 图片预处理管线:
    HEIC → JPEG 转换 → 尺寸缩放 → 压缩 → 大小校验
    """
    
    MAX_DIMENSION = 1920          # 长边上限
    MAX_SIZE_BYTES = 4.5 * 1024 * 1024  # 留 0.5MB 余量(加密开销)
    JPEG_QUALITY_START = 85
    JPEG_QUALITY_MIN = 40
    
    def __init__(self, input_path: str):
        self.input_path = input_path
    
    def process(self, output_path: Optional[str] = None) -> Tuple[str, int]:
        """
        执行完整预处理流水线。
        
        Returns:
            (输出路径, 最终文件大小 bytes)
        """
        img = self._load_and_convert()
        img = self._resize_if_needed(img)
        img = self._compress_to_target(img)
        
        dest = output_path or self.input_path.rsplit(".", 1)[0] + "_wa.jpg"
        img.save(dest, "JPEG", quality=self._final_quality)
        
        actual_size = __import__("os").path.getsize(dest)
        if actual_size > self.MAX_SIZE_BYTES:
            # 兜底:极端尺寸的图二压
            img.save(dest, "JPEG", quality=self.JPEG_QUALITY_MIN)
            actual_size = __import__("os").path.getsize(dest)
        
        return dest, actual_size
    
    def _load_and_convert(self) -> Image.Image:
        """加载图片,自动处理 HEIC 等非标准格式。"""
        img = Image.open(self.input_path)
        if img.mode not in ("RGB", "RGBA"):
            img = img.convert("RGB")
        if img.mode == "RGBA":
            # WhatsApp 不支持透明通道的 JPEG,垫白底
            background = Image.new("RGB", img.size, (255, 255, 255))
            background.paste(img, mask=img.split()[3])
            img = background
        return img
    
    def _resize_if_needed(self, img: Image.Image) -> Image.Image:
        """长边超过阈值时等比缩放。"""
        w, h = img.size
        if max(w, h) <= self.MAX_DIMENSION:
            return img
        ratio = self.MAX_DIMENSION / max(w, h)
        new_size = (int(w * ratio), int(h * ratio))
        return img.resize(new_size, Image.LANCZOS)
    
    def _compress_to_target(self, img: Image.Image) -> Image.Image:
        """二分搜索 JPEG quality,趋近目标大小。"""
        low, high = self.JPEG_QUALITY_MIN, self.JPEG_QUALITY_START
        best = img
        
        for _ in range(6):  # 最多 6 次二分
            mid = (low + high) // 2
            buf = io.BytesIO()
            img.save(buf, "JPEG", quality=mid)
            
            if buf.tell() <= self.MAX_SIZE_BYTES:
                best = img
                self._final_quality = mid
                high = mid - 1
            else:
                low = mid + 1
        
        return best

这个管线覆盖了几个真实踩过的坑:

  1. HEIC 转换 --- Pillow 需要 pillow-heif 插件才能读取 HEIC,生产环境别忘在 Dockerfile 里装。
  2. 透明 PNG → JPEG --- RGBA 模式直接存 JPEG 会炸,垫白底是最经济的方案。
  3. 二分压缩 --- 固定 quality 值在不同分辨率的图上表现差异很大,二分搜索比拍脑袋靠谱。
  4. 4.5 MB 上限 --- WhatsApp 的 5 MB 限制包含 TLS 加密开销,实测留 0.5 MB 安全余量。

5. 视频预处理:H.264 + AAC,一个字都不能改

视频是最容易翻车的媒体类型------它对编解码器的要求精确到了 profile 级别

5.1 官方约束逐条翻译

约束 含义 错了的后果
H.264 video codec 别用 H.265/HEVC/AV1 消息发送成功,对方播放黑屏
AAC audio codec 别用 MP3/Opus/Vorbis 作为音轨 同上
Single audio stream or none 不能有多音轨 API 直接拒绝
Baseline or Main profile 不能用 High profile Android 客户端无法播放
No B-frames 不能用双向预测帧 部分设备解码花屏
moov atom before mdat QuickTime 的原子排序 视频加载需等待整个文件下载完毕

5.2 ffmpeg 预处理命令模板

bash 复制代码
# WhatsApp 兼容视频转码(单行命令,可直接塞进 pipeline)
ffmpeg -i input.mp4 \
  -c:v libx264 -profile:v baseline -level 3.0 \
  -pix_fmt yuv420p -bf 0 \
  -c:a aac -b:a 128k -ac 1 \
  -movflags +faststart \
  -vf "scale='min(1280,iw)':min'(1280,iw)*ih/iw,format=yuv420p" \
  -maxrate 2M -bufsize 4M \
  -fs 15M \
  output_wa.mp4

参数逐行解释:

参数 作用
-c:v libx264 强制 H.264 编码
-profile:v baseline 使用 Baseline profile,无 B-frames
-pix_fmt yuv420p 确保像素格式兼容
-bf 0 显式禁用 B-frames
-c:a aac -ac 1 AAC 单声道编码
-movflags +faststart moov atom 前置,支持流式播放
-fs 15M 输出文件不超过 15 MB(留 1MB 安全余量)

如果用 Python 调用,建议走 subprocess.run() 而非封装库,因为你需要精确控制每个参数。

5.3 Python 视频预处理封装

python 复制代码
import subprocess
import os
from dataclasses import dataclass
from typing import Optional

@dataclass
class VideoTranscodeResult:
    success: bool
    output_path: str
    file_size: int
    duration_seconds: float
    error_message: Optional[str] = None

def transcode_for_whatsapp(
    input_path: str,
    output_path: Optional[str] = None,
    max_size_mb: int = 15,
    max_width: int = 1280
) -> VideoTranscodeResult:
    """
    将任意视频转码为 WhatsApp 兼容格式。
    """
    dest = output_path or input_path.rsplit(".", 1)[0] + "_wa.mp4"
    
    cmd = [
        "ffmpeg", "-y", "-i", input_path,
        "-c:v", "libx264", "-profile:v", "baseline", "-level", "3.0",
        "-pix_fmt", "yuv420p", "-bf", "0",
        "-c:a", "aac", "-b:a", "128k", "-ac", "1",
        "-movflags", "+faststart",
        "-vf", f"scale='min({max_width},iw)':'min({max_width},iw)*ih/iw',format=yuv420p",
        "-maxrate", "2M", "-bufsize", "4M",
        "-fs", f"{max_size_mb}M",
        dest
    ]
    
    try:
        subprocess.run(cmd, check=True, capture_output=True, timeout=120)
        size = os.path.getsize(dest)
        return VideoTranscodeResult(
            success=True, output_path=dest, file_size=size,
            duration_seconds=_probe_duration(dest)
        )
    except subprocess.CalledProcessError as e:
        return VideoTranscodeResult(
            success=False, output_path=dest, file_size=0,
            duration_seconds=0,
            error_message=e.stderr.decode()[-500:]
        )

def _probe_duration(path: str) -> float:
    """用 ffprobe 获取视频时长(秒)。"""
    result = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries",
         "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path],
        capture_output=True, text=True
    )
    return float(result.stdout.strip())

6. Media ID 的生命周期与缓存策略

media_id 不是一个永久资源。它的生命周期有两条规则:

来源 有效期 说明
POST /media 上传返回 30 天 从上传时刻起算
Webhook 中收到的媒体 7 天 用户发来的图片/视频等

6.1 "30 天"对工程意味着什么

如果你在做模板消息------比如固定推送一张优惠券图片------把 media_id 硬编码进代码会静静地坏掉。正确的做法:

python 复制代码
import time
import functools
from typing import Dict, Tuple

class MediaIdCache:
    """
    带 TTL 的 media_id 缓存。
    
    - 每个 media_id 记录上传时间
    - 访问时检查是否快过期(< 25 天即触发刷新)
    - 支持按文件哈希去重:同一文件不重复上传
    """
    
    TTL_SECONDS = 25 * 86400  # 25 天,提前刷新
    
    def __init__(self):
        self._store: Dict[str, Tuple[str, float, str]] = {}
        # key: file_sha256 → (media_id, uploaded_at, file_path)
    
    def get_or_upload(self, file_path: str, upload_fn) -> str:
        """获取 media_id,过期或不存在则重新上传。"""
        file_hash = self._sha256(file_path)
        now = time.time()
        
        if file_hash in self._store:
            mid, uploaded, _ = self._store[file_hash]
            if now - uploaded < self.TTL_SECONDS:
                return mid
        
        # 过期或不存在 → 重新上传
        mid = upload_fn(file_path)
        self._store[file_hash] = (mid, now, file_path)
        return mid
    
    @staticmethod
    def _sha256(path: str) -> str:
        import hashlib
        h = hashlib.sha256()
        with open(path, "rb") as f:
            for chunk in iter(lambda: f.read(8192), b""):
                h.update(chunk)
        return h.hexdigest()

6.2 你还可以主动删除

python 复制代码
def delete_media(access_token: str, media_id: str):
    """删除已上传的媒体(释放存储 / 隐私合规)。"""
    url = f"https://graph.facebook.com/v22.0/{media_id}"
    requests.delete(url, headers={"Authorization": f"Bearer {access_token}"})

实际场景:用户要求删除其个人数据时,你需要遍历所有曾经发给该用户的 media_id 并逐一清理。


7. 发送管线:从 media_id 到收件箱

7.1 基础发送

上传完拿了 media_id 之后,发送就简单了:

python 复制代码
def send_media_message(
    access_token: str,
    phone_number_id: str,
    to: str,
    media_type: str,       # "image" | "video" | "audio" | "document" | "sticker"
    media_id: str,
    caption: str = None
) -> dict:
    """
    使用 media_id 发送媒体消息。
    caption 仅对 image/video/document 有效。
    """
    url = f"https://graph.facebook.com/v22.0/{phone_number_id}/messages"
    
    payload = {
        "messaging_product": "whatsapp",
        "recipient_type": "individual",
        "to": to,
        "type": media_type,
        media_type: {"id": media_id}
    }
    
    if caption and media_type in ("image", "video", "document"):
        payload[media_type]["caption"] = caption
    
    resp = requests.post(
        url,
        headers={
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json"
        },
        json=payload
    )
    resp.raise_for_status()
    return resp.json()

7.2 URL 直链模式(不推荐但存在)

理论上你可以跳过上传步骤,直接用 URL 发送:

json 复制代码
{
  "type": "image",
  "image": {
    "link": "https://cdn.example.com/product.jpg"
  }
}

为什么不推荐? 三条理由:

  1. WhatsApp 服务器需要实时下载你的文件 → 增加延迟,且你的 CDN 必须公网可访问。
  2. HTTP 缓存头(Cache-Control / ETag / Last-Modified)是否被遵守没有官方 SLA。
  3. 如果 URL 在消息投递时已失效(比如你清过 CDN),用户会收到一个"媒体不可用"的错误------这个错误不回传 Webhook。

结论:URL 模式仅适用于 demo 和一次性测试。生产环境统一走 media_id。


8. 常见错误码与归因

错误码 含义 根因 修复
131052 媒体文件过大 超过该类型的最大尺寸 压缩或分片
131053 MIME 类型不匹配 扩展名是 .jpg 但实际是 PNG file -I 检查实际类型
131047 不支持的媒体格式 HEIC / GIF / SVG 等 走预处理管线转码
100 参数错误 payload 中 type 和 media key 不一致 type="image" 必须有 "image" key
368 模板消息缺少媒体 header 模板定义了 header 为 IMAGE 但你没传 补充 media id 或改用文本 header

额外注意 :视频消息的回调中,即使 API 返回了 messages[0].id(看起来成功),实际投递可能因为这个 id 对应了格式不合规的文件而失败。判断"用户真正收到了"只能依赖 Webhook 中的 delivered 状态------不能只看 API 200。


9. 出海实战:从线索提取到富媒体触达的完整闭环

讲完了管线,回到真实场景。假设你是一家跨境 B2B 贸易公司的运营负责人,面临两个典型任务:

任务 A --- 建联前准备:你加入了十几个 WhatsApp 行业群组("中东建材采购群"、"拉美快消品买家群"等),里面有上千条潜在客户号码。手动逐条保存号码再逐个聊,时间成本不可接受。

任务 B --- 营销触达:拿到清洗过的目标客户名单后,你需要用带产品图册、报价单 PDF 和介绍视频的富媒体消息进行首次问候------而不是一条干巴巴的文字。

这是一个典型的 数据沉淀 → 精准触达 闭环:

复制代码
┌─ 第一步:号码提取 ──────────────────────────────────┐
│  使用 WAExport(WhatsApp 号码提取与备份工具)          │
│  从目标群组中一键提取成员号码和联系人信息               │
│  导出为 XLSX 表格,支持按国家代码过滤,自动去重        │
│  100% 本地运行,保障数据隐私                           │
└──────────────────┬──────────────────────────────────┘
                   ▼
┌─ 第二步:号码清洗 ──────────────────────────────────┐
│  通过号码验证工具批量检测哪些号码已注册 WhatsApp        │
│  过滤无效号码,输出"精准可触达名单"                    │
│  验证全程本地化,人类行为模拟防封                       │
└──────────────────┬──────────────────────────────────┘
                   ▼
┌─ 第三步:媒体预处理 ────────────────────────────────┐
│  产品图片 → JPEG 压缩至 < 5MB                         │
│  介绍视频 → H.264 Baseline + AAC 转码                 │
│  报价单 PDF → 直接上传(100MB 上限绰绰有余)           │
└──────────────────┬──────────────────────────────────┘
                   ▼
┌─ 第四步:批量发送 ──────────────────────────────────┐
│  使用 WASender(WhatsApp 群发营销工具),多账号并行    │
│  每条消息包含:产品图 + 报价单 + 个性化问候语           │
│  内置随机发送间隔、账号轮换、防封策略                   │
│  实时追踪送达率和已读率                                │
└─────────────────────────────────────────────────────┘

在这个闭环里,媒体预处理管线(本文的核心)恰好卡在第三步。如果预处理掉了链子------HEIC 未转码、视频编码用错了 profile、图片超了 5 MB------前两步提取再准、第四步发送再稳都是白搭。

举个例子:一家深圳的蓝牙耳机外贸商通过 WAExport 从巴西、墨西哥的电子消费品群组里洗出了 2,400 个精准买家号码。预处理管线自动将所有产品图裁剪为 1200x1200、JPEG 质量 82 的版本,演示视频统一转码为 H.264 Baseline 格式,最后通过 WASender 的 5 个账号并行推送富媒体消息。两天内,送达率 97.3%,已读率 41.2%,询盘回复 63 条------而预处理失败率(上传阶段被拒绝)为 0。

这条链路的技术价值不在于哪个单独的工具,而在于管线的集成度:号码提取 → 号码清洗 → 媒体预处理 → 批量发送,四个环节的输入输出必须是无缝的,任何一个环节产出格式不兼容的脏数据,都是全局瓶颈。


10. 生产环境自检清单

在把媒体发送流程推到生产之前,逐条过一遍:

  • 所有用户上传图片强制走预处理管线(HEIC → JPEG + 尺寸缩放 + 压缩)
  • 所有视频强制走 ffmpeg 转码(H.264 Baseline + AAC + faststart)
  • 上传前校验文件大小(图片 < 4.5 MB、视频 < 15 MB、文档 < 95 MB)
  • 上传前用 file -I 校验 MIME 类型,不信任文件扩展名
  • media_id 带 TTL 缓存(25 天自动刷新),不硬编码
  • 发送失败时区分 131052/131053/131047 等媒体专属错误码
  • 视频发送后监控 Webhook delivered 回调,API 200 ≠ 投递成功
  • 会话窗口外(> 24h)不使用 media_id 发送免费消息------必须用已审批的模板消息
  • 为媒体上传设置独立超时(大文件传 100 MB 建议 timeout=30s)
  • GDPR/CCPA 合规:提供"删除所有已发送媒体"的 API 入口

11. FAQ

Q: 用 URL 直接发和上传 media_id 发有什么区别?

A: URL 模式延迟更高(Meta 服务器实时下载),且不可控(你的 CDN 挂了对方就看不到图)。media_id 是上传后 Meta 托管,稳定性和投递率都更好。

Q: media_id 过期后还能恢复吗?

A: 不能。media_id 过期后对应的文件在 Meta 服务器上也不再可用。需要重新上传获取新 ID。

Q: 为什么 JPEG 质量设 85 而不是 95?

A: 这是工程折中。从 95 降到 85,肉眼几乎不可分辨差异,文件体积可能减少 40%-60%。对于每天上万条媒体消息的场景,这直接折算成带宽成本和发送延迟。

Q: 视频必须要有音轨吗?

A: 根据 WhatsApp 官方文档,视频"可以有单音频流或无音频流"。但实测中部分 Android 客户端对无音轨视频支持不稳定,建议带一条静默 AAC 音轨作为兼容方案。

Q: 一次上传能用多久?

A: 30 天。这在模板场景里意味着:如果你的模板 header 是一张固定图片,至少每 25 天刷新一次 media_id。


12. 参考来源


本文为 WhatsApp Cloud API 媒体管线工程化实践笔记,所有代码示例均基于 Python 3.10+,生产环境可用。

相关推荐
对讲机数码科普3 小时前
哈尔滨对讲机无声、音量小、喇叭破音故障修复 香坊双工电子音频组件专项维保
音视频
AIsoft_86883 小时前
iOS端录音转文字工具实测:自媒体创作者高效整理指南
ios·媒体
dualven_in_csdn7 小时前
【摄像头】虚拟摄像头
音视频
小二·9 小时前
多模态RAG实战:用LangChain+Milvus打造支持图文音视频的企业级知识库
langchain·音视频·milvus
田里的水稻9 小时前
EP_局域网传输音视频的主流方案
运维·人工智能·机器人·音视频
hz567899 小时前
手术示教及远程会诊系统一体化方案:赋能医院教学与精准诊疗
安全·音视频·实时音视频·信息与通信
潜创微科技10 小时前
ITE联阳IT6520:Type-C口进、双MIPI出,一颗芯片把PD充电+DP视频+2屏驱动全包了
音视频
禹亮科技10 小时前
中型会议室音视频工程方案|20-40㎡无桌面麦部署:Poly X52+思必驰MCS06+DP44落地实战
音视频·视频会议系统集成·高端会议室设备选择·视频会议解决方案
三易串口屏1 天前
音频控件使用教程
音视频·工控·串口屏·三易串口屏