WhatsApp Cloud API 模板消息:从“审核被拒“到“稳定触达“的完整工程笔记
先说结论如果你做企业级 WhatsApp 出海模板消息Message Templates是绕不开的坎。它不是你写一段文案就能发的东西而是 Meta 给你画的一道合规门槛。跨过去了批量触达、订单通知、验证码都能跑顺跨不过去Cloud API 调得再熟也是白搭。这篇把我过去半年踩过的坑、改过的代码、被 Meta 打回过四次的模板全部摊开来写。目录为什么模板消息是 WhatsApp 出海的第一道闸门模板的三类身份与四个组件用 API 创建模板一个能被审过的 JSON 长什么样发送模板动态变量、头图、按钮的完整构造审核状态与质量评分的 webhook 处理一个真实的出海案例复盘常见被拒原因与自检清单FAQ参考与延伸阅读为什么模板消息是 WhatsApp 出海的第一道闸门WhatsApp Business API 有一个硬规则用户最后一次回复的 24 小时之后企业只能发已预审批的模板消息。这个窗口期官方叫Customer Service Window outside window 的自由消息会被 API 直接拒绝。这意味着两件事主动触达必须模板化营销、通知、召回、OTP 全部要走模板。模板不是企业说了算文案结构、变量数量、按钮类型、甚至语气都要过 Meta 的审核。很多团队第一次接入 Cloud API 时把 80% 的精力放在调通/messages接口上结果到了真正发消息才发现模板审不过、变量填不上、或者营销类模板直接把账号质量分拉崩。正确的顺序应该是先搞定模板生命周期再写发送逻辑。模板的三类身份与四个组件Meta 把模板按用途分成三类 conversation pricing 也按这个分。类别典型场景单价相对审核严格度示例Utility订单确认、物流更新、账户提醒、预约通知中中“您的订单 #{{2}} 已发货预计 {{3}} 送达。”Marketing促销、新品、优惠券、用户召回高高“{{1}}本周限时 8 折code: {{2}}。”AuthenticationOTP、两步验证、登录验证码低极高“{{1}} 是您的验证码5 分钟内有效。”每类模板最多由四个组件拼成组件可选内容变量支持注意点Header文本 / 图片 / 视频 / 文档文本头支持 1 个变量图片需先上传至 Meta CDN 拿 media IDBody主文案最多支持多个{{n}}变量建议正文变量不超过 4 个否则读起来像填空Footer底部小字灰色显示不支持常用来放退订说明或免责声明ButtonsURL / 电话 / 快速回复URL 按钮可含 1 个变量快速回复按钮最多 3 个用户点击会回一条固定文本这里最容易栽跟头的是Marketing 模板混 Utility 内容。比如你在订单确认消息里塞一个优惠券链接Meta 会把它重新归类为 Marketing按更高的 conversation 单价收费而且审核更严。用 API 创建模板一个能被审过的 JSON 长什么样模板可以在 WhatsApp Business Manager 的 UI 里创建也可以走/message_templates接口。后者适合有版本化、多语言、CI/CD 需求的团队。下面是一个 Utility 类订单确认模板的完整创建请求变量、示例值、按钮都配齐了importrequestsimportjson BUSINESS_ACCOUNT_IDYOUR_WABA_IDACCESS_TOKENYOUR_ACCESS_TOKENAPI_VERSIONv22.0defcreate_template():urlfhttps://graph.facebook.com/{API_VERSION}/{BUSINESS_ACCOUNT_ID}/message_templatespayload{name:order_confirmation_v2,category:UTILITY,language:zh_CN,components:[{type:HEADER,format:TEXT,text:订单确认 #{{1}},example:{header_text:[WB-2026-0711]}},{type:BODY,text:你好 {{1}}你的订单 #{{2}} 已确认。\n\n商品{{3}}\n预计送达{{4}}\n\n如有问题请点击下方按钮联系客服。,example:{body_text:[[ Alex,WB-2026-0711,机械键盘 K8,2026-07-15]]}},{type:FOOTER,text:本消息由官方账号发送回复 STOP 退订。},{type:BUTTONS,buttons:[{type:URL,text:查看物流,url:https://tracking.example.com/{{1}},example:[WB20260711001]}]}]}resprequests.post(url,headers{Authorization:fBearer{ACCESS_TOKEN},Content-Type:application/json},datajson.dumps(payload))returnresp.json()# 返回示例# {# id: 1234567890123456,# status: PENDING,# category: UTILITY# }几个关键细节name只能是小写字母、数字和下划线不能有大写和空格。example字段在审核时必填Meta 需要知道变量大概填什么。创建后是PENDING状态通常几分钟到 24 小时内变成APPROVED或REJECTED。被拒绝后可以修改并重新提交但name 不能复用同一个建议用v2、v3后缀管理版本。发送模板动态变量、头图、按钮的完整构造模板审核通过后才能通过/messages发送。发送时最关键的是把变量按顺序填对顺序错了文案就全乱。importrequestsimportjson PHONE_NUMBER_IDYOUR_PHONE_NUMBER_IDACCESS_TOKENYOUR_ACCESS_TOKENAPI_VERSIONv22.0defsend_template(to:str,template_name:str,language:str,params:dict): params 结构示例 { header: [{type: text, text: WB-2026-0711}], body: [ {type: text, text: Alex}, {type: text, text: WB-2026-0711}, {type: text, text: 机械键盘 K8}, {type: text, text: 2026-07-15} ] } urlfhttps://graph.facebook.com/{API_VERSION}/{PHONE_NUMBER_ID}/messagescomponents[]ifparams.get(header):components.append({type:header,parameters:params[header]})ifparams.get(body):components.append({type:body,parameters:params[body]})payload{messaging_product:whatsapp,recipient_type:individual,to:to,type:template,template:{name:template_name,language:{code:language},components:components}}resprequests.post(url,headers{Authorization:fBearer{ACCESS_TOKEN},Content-Type:application/json},datajson.dumps(payload))returnresp.json()# 调用示例resultsend_template(to85291234567,template_nameorder_confirmation_v2,languagezh_CN,params{header:[{type:text,text:WB-2026-0711}],body:[{type:text,text:Alex},{type:text,text:WB-2026-0711},{type:text,text:机械键盘 K8},{type:text,text:2026-07-15}]})print(result)# {messaging_product: whatsapp, contacts: [...], messages: [{id: wamid..., message_status: accepted}]}发送时还要注意语言代码必须和模板创建时一致。如果模板是zh_CN发送时填zh可能匹配失败。按钮里的 URL 变量不需要在发送时填Meta 会自动用模板定义里的example做替换但如果是动态 URL需要确认模板设计时已经声明了变量占位符。返回的wamid一定要落库后续跟踪送达、已读、失败状态全靠它。审核状态与质量评分的 webhook 处理模板不是一劳永逸的。Meta 会持续监控用户反馈模板质量评分下降会被暂停PAUSED。你需要在 webhook 里监听两类事件模板审核状态变化message_template_status_update模板质量评分更新message_template_quality_updatefromfastapiimportFastAPI,RequestfromtypingimportDict,Any appFastAPI()app.post(/webhook/whatsapp)asyncdefwhatsapp_webhook(request:Request):payloadawaitrequest.json()forentryinpayload.get(entry,[]):forchangeinentry.get(changes,[]):valuechange.get(value,{})# 1. 模板审核状态变化ifmessage_template_status_updateinvalue:status_updatevalue[message_template_status_update]handle_template_status_update(status_update)# 2. 模板质量评分变化ifmessage_template_quality_updateinvalue:quality_updatevalue[message_template_quality_update]handle_template_quality_update(quality_update)# 3. 普通消息状态送达/已读/失败forstatusinvalue.get(statuses,[]):handle_message_status(status)return{status:ok}defhandle_template_status_update(data:Dict[str,Any]): 示例数据结构 { event: APPROVED, message_template_id: 1234567890123456, message_template_name: order_confirmation_v2, message_template_language: zh_CN, reason: NONE, disable_info: null } eventdata.get(event)# APPROVED / REJECTED / PAUSED / DISABLEDnamedata.get(message_template_name)reasondata.get(reason)print(f[TEMPLATE]{name}-{event}, reason{reason})# 生产环境建议写入数据库并告警ifeventREJECTED:# 发告警给运营/文案准备改版本send_alert(f模板{name}审核被拒:{reason})elifeventPAUSED:# 自动从发送队列降级该模板pause_template_in_queue(name)defhandle_template_quality_update(data:Dict[str,Any]): 示例数据结构 { message_template_id: 1234567890123456, message_template_name: order_confirmation_v2, message_template_language: zh_CN, previous_quality_score: GREEN, new_quality_score: YELLOW } namedata.get(message_template_name)prevdata.get(previous_quality_score)newdata.get(new_quality_score)print(f[QUALITY]{name}:{prev}-{new})ifnewin(YELLOW,RED):# 黄/红表示用户投诉或屏蔽率上升throttle_template(name)defhandle_message_status(status:Dict[str,Any]):wamidstatus.get(id)msg_statusstatus.get(status)# sent / delivered / read / failedifmsg_statusfailed:errorstatus.get(errors,[{}])[0]print(f[FAILED] wamid{wamid}, code{error.get(code)}, title{error.get(title)})# 常见失败码# 131047 用户拉黑或不在服务区# 131026 模板未通过审核# 132000 超出 24 小时窗口且未使用模板else:print(f[STATUS] wamid{wamid}-{msg_status})defsend_alert(msg:str):# 接你的告警通道钉钉/飞书/企业微信passdefpause_template_in_queue(name:str):# 把模板从发送队列中暂时移除passdefthrottle_template(name:str):# 降低该模板的发送频率并触发人工复核pass建议把模板状态和质量评分单独存一张表至少保留 90 天。复盘的时候你会发现很多送达失败不是代码问题而是模板质量分变红导致的。一个真实的出海案例复盘去年帮一家做东南亚跨境电商的朋友做通知系统重构。他们之前用的是一个非官方的群发方案封号率高、到达率不稳定而且无法接入订单系统的真实数据。迁移到 WhatsApp Cloud API 后我们把整个链路拆成三段环节改造前改造后消息触发人工定时批量导入手机号订单状态变更自动触发内容生成固定文案无法个性化模板变量 订单数据动态填充发送通道非官方客户端模拟WhatsApp Cloud API 队列限流失败处理基本无追踪wamid 级状态回执 失败码分类合规风险高易封号低官方 BSP 已审批模板整个方案里模板消息是核心卡点。我们花了整整两周才把 6 个核心模板全部审过其中营销类模板被打回三次原因是文案里用了限时立即这类诱导性词汇。在具体落地时我们用的是WASender作为模板发送编排层它负责把订单系统的事件翻译成对应的模板名称、填充变量、按优先级入队再把 wamid 回写到订单表。Cloud API 只负责最终投递。这个分工让业务层不用关心 WhatsApp 的会话窗口、质量评分这些细节。三个月后的数据订单确认消息送达率从 78% 涨到 96%用户回复率涨了 40%账号没有一次被封。常见被拒原因与自检清单被拒是常态不要慌。下面这张表是我们内部用的自检清单提交审核前过一遍能省很多事。检查项通过标准常见翻车点变量示例每个{{n}}都有 realistic example示例值填123“abc”Meta 会判为无效文案语气不恐吓、不诱导、不冒充系统用立即点击“限时”“账号即将冻结”类别一致Utility 里不能塞营销内容订单确认里加优惠券链接域名必须是企业自有或已备案域名用短链、第三方跳转、或个人博客联系方式电话按钮号码需真实可拨填了客服座机但无人接听退订说明Marketing 类建议 footer 放 STOP 提示完全不提退订语言匹配模板语言和发送语言一致模板是 en_US发送用 en变量数量Body 变量建议 ≤ 4 个一段正文塞 7、8 个变量读起来破碎如果你被拒了不要只改文案就重提。先看 rejection reasonMeta 返回的 reason 通常很具体比如INVALID_FORMAT、PROMOTIONAL_CONTENT、MISSING_EXAMPLE等对因下药才能一次过。FAQQ1模板审核一般需要多久AUtility 和 Authentication 通常几分钟到几小时Marketing 审核更严可能需要 24 小时。高峰期会延长。Q2一个 Business Account 最多能有多少个模板A最多 250 个已批准模板所有语言合并计算。被拒绝或暂停的不计入。建议定期清理废弃版本。Q3模板变量可以嵌套吗比如{{1}}里再塞一段 URLA不能。变量会被整体替换为字符串不能包含额外格式化。如果需要动态 URL使用 URL 按钮的变量占位符。Q4发送模板时用户没有订阅过会违规吗AWhatsApp 要求用户必须同意接收消息opt-in。虽然 API 不会每次校验 opt-in但被投诉或屏蔽会拉低质量分严重时会暂停模板或号码。Q5能不能在 24 小时窗口内用模板发消息A可以但没必要。窗口内用普通消息更灵活、成本更低。模板主要用于窗口外或需要固定格式的高合规场景。参考与延伸阅读Meta WhatsApp Business Platform Documentation - Message TemplatesMeta WhatsApp Cloud API Reference - Message TemplatesMeta WhatsApp Pricing - Conversation-Based PricingGitHub - fbsamples/whatsapp-api-examples写在最后模板消息这件事技术上不复杂难的是在合规框架里把业务表达清楚。希望这篇笔记能让你少踩几个坑。如果你有更奇葩的拒审理由欢迎在评论区交流。