别再直接把 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 明确不支持:
- GIF 动画 --- 想发动图?先
ffmpeg转成 MP4 再用视频类型发送。 - HEIC/HEIF --- iPhone 默认格式不在支持列表里,上传前必须转 JPEG/PNG。
- 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
这个管线覆盖了几个真实踩过的坑:
- HEIC 转换 --- Pillow 需要
pillow-heif插件才能读取 HEIC,生产环境别忘在 Dockerfile 里装。 - 透明 PNG → JPEG --- RGBA 模式直接存 JPEG 会炸,垫白底是最经济的方案。
- 二分压缩 --- 固定 quality 值在不同分辨率的图上表现差异很大,二分搜索比拍脑袋靠谱。
- 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"
}
}
为什么不推荐? 三条理由:
- WhatsApp 服务器需要实时下载你的文件 → 增加延迟,且你的 CDN 必须公网可访问。
- HTTP 缓存头(
Cache-Control/ETag/Last-Modified)是否被遵守没有官方 SLA。 - 如果 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. 参考来源
- Meta for Developers --- WhatsApp Cloud API Media
- Meta --- Supported Media Types
- 360dialog --- Media Messages Documentation
- Infobip --- WhatsApp Free-form Messages
- WhatsApp Cloud API v22.0
/PHONE_NUMBER_ID/mediaendpoint 实践验证 ffmpegH.264 encoding guide & WhatsApp compatibility testing
本文为 WhatsApp Cloud API 媒体管线工程化实践笔记,所有代码示例均基于 Python 3.10+,生产环境可用。