你按文档一步步配好了 WhatsApp Cloud API 的 webhook,Meta 控制台明明白白显示"验证成功",真机发消息也显示 ✓✓(已送达)------可后端日志里入站记录一条都没有 。
如果你正卡在这一步,这篇能帮你省下几个小时:问题几乎都出在一个文档不强调、却决定消息走不走的开关------
subscribed_apps。本文把 WhatsApp Cloud API 里三个都叫"订阅"、却完全不同的东西讲清楚,并给一套不靠猜的系统化排查路径。
一、先看现象:一个反直觉的"已送达"
接入 WhatsApp Cloud API,按官方 Quickstart 配置完 webhook 后,常见的卡点是这样的:
- 在 Meta 开发者控制台点 Verify and save ,提示 webhook 验证成功;
- 用真机给你的 WhatsApp 号发一条
hello,消息显示 ✓✓(已送达); - 但你的后端服务没有收到任何 POST 回调,日志里入站记录为空。
"验证成功"了、消息也"送达"了,回调却没来。这时候很多人会怀疑:是不是反向代理配错了?是不是验签把消息拦了?是不是要先绑支付方式?
大概率都不是。 真正的原因,是你漏配了 WhatsApp Cloud API 三个"订阅"里最关键、也最隐蔽的那一个。
二、核心知识点:三个都叫「订阅」,却是三件事
要让一条入站消息最终到达你的后端,Meta 侧需要同时满足三个条件。坑就坑在------这三个东西在文档和控制台里都带"subscribe / 订阅"字样,极易混为一谈,以为做了一个就等于做了全部。
| # | 名称 | 在哪做 | 它到底在干什么 |
|---|---|---|---|
| ① | Webhook URL 验证 (hub.mode=subscribe 的 challenge) |
你的代码(GET 接口) | 证明"这个回调地址可达 + verify_token 正确" |
| ② | App 订阅 messages 字段 |
Meta 控制台 → App 的 Webhooks 配置 | App 声明"我想接收哪几类事件" |
| ③ | WABA → App 订阅 (subscribed_apps) |
Meta 平台 API(不是控制台勾选) | 把某个 WhatsApp Business Account 的事件路由到这个 App |
下面这张图把"消息要进你后端必须穿过的三道闸门"画出来------缺了 ③,Meta 会静默丢弃,发送方完全无感:
#mermaid-svg-4ADIfaqkHfLYBf4G{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4ADIfaqkHfLYBf4G .error-icon{fill:#552222;}#mermaid-svg-4ADIfaqkHfLYBf4G .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4ADIfaqkHfLYBf4G .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4ADIfaqkHfLYBf4G .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4ADIfaqkHfLYBf4G .marker.cross{stroke:#333333;}#mermaid-svg-4ADIfaqkHfLYBf4G svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4ADIfaqkHfLYBf4G p{margin:0;}#mermaid-svg-4ADIfaqkHfLYBf4G .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4ADIfaqkHfLYBf4G .cluster-label text{fill:#333;}#mermaid-svg-4ADIfaqkHfLYBf4G .cluster-label span{color:#333;}#mermaid-svg-4ADIfaqkHfLYBf4G .cluster-label span p{background-color:transparent;}#mermaid-svg-4ADIfaqkHfLYBf4G .label text,#mermaid-svg-4ADIfaqkHfLYBf4G span{fill:#333;color:#333;}#mermaid-svg-4ADIfaqkHfLYBf4G .node rect,#mermaid-svg-4ADIfaqkHfLYBf4G .node circle,#mermaid-svg-4ADIfaqkHfLYBf4G .node ellipse,#mermaid-svg-4ADIfaqkHfLYBf4G .node polygon,#mermaid-svg-4ADIfaqkHfLYBf4G .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4ADIfaqkHfLYBf4G .rough-node .label text,#mermaid-svg-4ADIfaqkHfLYBf4G .node .label text,#mermaid-svg-4ADIfaqkHfLYBf4G .image-shape .label,#mermaid-svg-4ADIfaqkHfLYBf4G .icon-shape .label{text-anchor:middle;}#mermaid-svg-4ADIfaqkHfLYBf4G .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4ADIfaqkHfLYBf4G .rough-node .label,#mermaid-svg-4ADIfaqkHfLYBf4G .node .label,#mermaid-svg-4ADIfaqkHfLYBf4G .image-shape .label,#mermaid-svg-4ADIfaqkHfLYBf4G .icon-shape .label{text-align:center;}#mermaid-svg-4ADIfaqkHfLYBf4G .node.clickable{cursor:pointer;}#mermaid-svg-4ADIfaqkHfLYBf4G .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4ADIfaqkHfLYBf4G .arrowheadPath{fill:#333333;}#mermaid-svg-4ADIfaqkHfLYBf4G .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4ADIfaqkHfLYBf4G .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4ADIfaqkHfLYBf4G .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4ADIfaqkHfLYBf4G .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4ADIfaqkHfLYBf4G .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4ADIfaqkHfLYBf4G .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4ADIfaqkHfLYBf4G .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4ADIfaqkHfLYBf4G .cluster text{fill:#333;}#mermaid-svg-4ADIfaqkHfLYBf4G .cluster span{color:#333;}#mermaid-svg-4ADIfaqkHfLYBf4G div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4ADIfaqkHfLYBf4G .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4ADIfaqkHfLYBf4G rect.text{fill:none;stroke-width:0;}#mermaid-svg-4ADIfaqkHfLYBf4G .icon-shape,#mermaid-svg-4ADIfaqkHfLYBf4G .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4ADIfaqkHfLYBf4G .icon-shape p,#mermaid-svg-4ADIfaqkHfLYBf4G .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4ADIfaqkHfLYBf4G .icon-shape .label rect,#mermaid-svg-4ADIfaqkHfLYBf4G .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4ADIfaqkHfLYBf4G .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4ADIfaqkHfLYBf4G .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4ADIfaqkHfLYBf4G :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
用户真机发消息
Meta Cloud API
① 回调地址已验证?
(可达 + verify_token 对)
回调验证失败
② App 订阅 messages 字段?
App 不接收该类事件
③ subscribed_apps:
WABA 是否订阅到该 App?
⚠ Meta 静默不投递
(最易漏的那一个)
✅ POST 到你的后端 webhook
用一个寄信的类比就清楚了:
① = 邮局确认"你家这个地址真实存在、能收信";
② = 你声明"我只想收挂号信,不收广告单";
③ = 把某个发件人 登记成"他寄出的信,投递到你家"。
缺了 ③,地址验过了、信件类型也声明了,但没有任何发件人被登记到你名下,信永远不会来。
绝大多数"验证成功却收不到消息"的案例,都是 ①② 做了,唯独缺 ③。
为什么 ③ 最容易漏?
因为 ① 和 ② 在控制台里都有显眼的 UI 入口(填 URL、点验证、勾字段),做完还有"成功"提示;而 ③ 在控制台里往往没有对应的勾选项,需要你显式调用 Graph API 才能完成。文档里它通常只是 API Reference 里轻描淡写的一节,不在"Quickstart 五步走"的主路径上,于是很自然地被跳过。
三、为什么"测试时好好的,一上生产就收不到"?
这是另一个高频困惑,也是理解 ③ 的关键。很多人在测试阶段 用 Meta 提供的测试号码 ,消息收发一切正常;等换成自己的正式号码 + 自有 WABA 上生产,立刻收不到了。
要理解这点,先记住一个事实:subscribed_apps 是 WABA 级别的订阅关系,绑定的是"某个 WABA → 某个 App",不会因为你换号码、换环境就自动延续过去。
那"测试时为什么是好的"?------这恰恰是最不该想当然的地方。 这层订阅可能在更早的某个环节就已经被建立过:也许是某个接入引导流程顺手做了,也许是别人搭测试环境时手工配过、却没写进交接文档。无论哪种,你都"感觉不到它存在",于是误以为"本来就好好的"。它既不在你的代码里、也未必在任何人的操作记录里------所以别去考证"测试为什么能通"(很多时候根本无从考证),直接对生产环境实测自查(见第四节)。
结论 :测试环境能跑通 ≠ 链路完整。涉及平台侧订阅/注册这类不在代码里、也不在你显式操作里的状态,换环境时必须逐项确认,不能假设"测试通了生产就通"。
四、怎么查、怎么补 ③
排查口诀一句话:WhatsApp 配了 webhook 却收不到消息 → 先查 GET /{waba_id}/subscribed_apps。
4.1 查:当前 WABA 订阅了哪些 App
bash
curl -s "https://graph.facebook.com/v17.0/<WABA_ID>/subscribed_apps" \
-H "Authorization: Bearer <ACCESS_TOKEN>"
- 返回
{"data": []}(空数组)→ 就是它:这个 WABA 没订阅任何 App,消息当然不会投递。 - 返回里有你的 App → ③ 已具备,问题在别处(回头查验签、反代、字段订阅)。
4.2 补:把 WABA 订阅到当前 App
bash
curl -s -X POST "https://graph.facebook.com/v17.0/<WABA_ID>/subscribed_apps" \
-H "Authorization: Bearer <ACCESS_TOKEN>"
这里订阅的"App",就是 <ACCESS_TOKEN> 所属的那个 App。返回 {"success": true} 后,再用真机发一条消息,回调通常立刻就来了。
注意
<ACCESS_TOKEN>要用对:生产环境建议用 System User 永久 token(下文 5.2 会讲怎么验证它是不是永久的),别用会在 24 小时后失效的临时 token,否则订阅当时成功、过两天又"莫名"失效。
五、不靠猜:一套分层排查方法
"先查 subscribed_apps"是结论。但如果你想真正定位问题、而不是碰运气,建议按下面这套分层 + 用工具验事实的路径走一遍------每一层只验证一件事,逐层缩小范围。它对任何"第三方 webhook 收不到回调"的问题都通用。
5.1 第一层:证明你的 app 和反代没问题(本地 + 公网各打一次 GET)
WhatsApp 的 webhook 验证是一个 GET 请求,带 hub.mode=subscribe、hub.verify_token、hub.challenge,要求你原样回显 hub.challenge。先在本地直接打:
bash
curl "http://127.0.0.1:8000/your/webhook?hub.mode=subscribe&hub.verify_token=<YOUR_VERIFY_TOKEN>&hub.challenge=test123"
# 期望输出: test123
再对公网域名 打一次同样的请求。两次都回显 challenge,就证明:你的应用逻辑、反向代理、verify_token 全部正确。对应的服务端实现(以 FastAPI 为例):
python
@router.get("/your/webhook")
async def verify_webhook(request: Request):
mode = request.query_params.get("hub.mode")
token = request.query_params.get("hub.verify_token")
challenge = request.query_params.get("hub.challenge")
if mode == "subscribe" and token == EXPECTED_VERIFY_TOKEN:
return Response(content=challenge or "", media_type="text/plain")
return Response(status_code=403)
这一层验证通过,恰恰是最容易让人误以为"全配好了"的地方------它只证明回调地址有效,完全不代表消息会进来。 别被"订阅验证成功"这句话骗了。
5.2 第二层:用 debug_token 验证你的 access_token
很多"间歇性收不到/发不出"其实是 token 类型或有效期不对。用 Graph API 的 debug_token 一查便知:
bash
curl -s "https://graph.facebook.com/debug_token?input_token=<ACCESS_TOKEN>&access_token=<APP_ID>|<APP_SECRET>"
重点看返回里的两个字段:
type:生产建议是SYSTEM_USER(系统用户 token,可设永久);expires_at:值为0表示永不过期。若是个未来时间戳,说明它会过期,生产环境迟早出问题。
附带好处:这个调用用 <APP_ID>|<APP_SECRET> 作为调用方凭据,它能成功返回,也就顺带证明了你的 app_secret 是对的(验签要用到它)。
5.3 第三层:自签一个 webhook POST,验证"代码链路"
想确认"如果消息真进来了,我的代码能不能正确处理",可以自己构造一个带合法签名 的 POST 打到本地 webhook。WhatsApp 用 app_secret 对原始请求体 做 HMAC-SHA256,放在 X-Hub-Signature-256 头里:
python
import hmac, hashlib
raw_body = open("sample_inbound.json", "rb").read() # 一条样例入站 payload
sig = "sha256=" + hmac.new(APP_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
# 然后用 curl / httpx 把 raw_body 以这个签名头 POST 到 http://127.0.0.1:8000/your/webhook
服务端验签的标准写法(注意用常数时间比较防时序侧信道):
python
expected = "sha256=" + hmac.new(APP_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(request_signature, expected):
logger.warning("Webhook 验签失败")
return {"status": "ok"} # 注意:失败也返回 200,见第六节
如果这个自签 POST 能让你的服务正常解析、处理、回复,就证明**"验签 → 解析 → 业务分发"整条代码链路是通的**。
这里有个认知盲区,务必记住 :自签模拟 POST 绕过了 Meta 。它能证明"消息进来后代码能处理",但证明不了"Meta 会不会真的把消息推过来"。前者是你的代码问题,后者是平台投递问题(③ 就属于后者)。
5.4 第四层:真机测试,不可省略
前三层全过,只能说明"代码和配置在你这一侧都对"。唯一能验证 Meta 真实投递的,是用真机发一条消息看回调是否到达。 这次缺失的 ③ subscribed_apps,恰恰只有真机才能暴露------因为它是平台投递环节的开关,前三层(本地 GET、token 校验、自签 POST)全都绕过了它。
本地 GET 回显 challenge → 验:app + 反代 + verify_token
debug_token 查 token → 验:access_token 类型/有效期 + app_secret
自签 HMAC 模拟 POST → 验:代码链路(验签→解析→分发)
真机发消息看回调 → 验:Meta 真实投递(subscribed_apps 等平台状态)★ 不可省
六、顺带澄清几个让人走弯路的坑
排查这类问题时,常被这几个"看似相关、其实无关"的现象带偏,一并说清:
6.1 验签失败为什么也返回 200?------别看状态码,看日志
Meta 对非 2xx 响应会重试 。如果你在验签失败时返回 4xx/5xx,一旦验签逻辑本身有 bug,会招来重试风暴 。所以正确做法是:无论验签成功失败都返回 200 ,靠日志 而不是 HTTP 状态码来暴露异常。这意味着排查时,你不能靠状态码判断验签有没有过,必须去看日志有没有"验签失败"告警。
6.2 "未配置支付方式"的告警 ≠ 收不到消息的原因
Meta 控制台经常提示"未配置支付方式可能限制消息功能",很容易让人误判成"收不到消息是没绑支付"。实际上:客服窗口内(用户主动发起后 24 小时内)的回复属于免费的服务消息,不需要支付方式。 被动应答型 bot 正常都在这个窗口内工作。支付方式只有在主动推送 / 模板消息 / 放量触及免费档上限 时才需要。真正阻塞入站的是 ③,不是支付。
教训:平台最显眼的告警,未必是你当前问题的阻塞点。要用现象(日志、真机表现)定位,而不是顺着最扎眼的提示下结论。
6.3 号码"待审核 / 未注册"------走 Cloud API /register,也不是支付问题
如果你的号码在 wa.me 显示"未注册"、或一直"待审核",根因通常是没走 Cloud API 的号码注册:
bash
curl -s -X POST "https://graph.facebook.com/v17.0/<PHONE_NUMBER_ID>/register" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"messaging_product": "whatsapp", "pin": "<你自定义的6位PIN>"}'
这个 pin 是号码的两步验证 PIN,自己设、必须记牢存好------换机、迁移、重新注册都要用到。
6.4 显示名审核:别用内部 / 技术词
提交号码显示名(Display Name)审核时,含内部词、技术词、过于通用的名字(如带 Prod、Test、AI Bot 之类)容易被拒。用真实、规范的品牌名通常很快通过。
6.5 webhook 必须 5 秒内返回 200
Meta 要求 webhook 5 秒内响应 200 ,否则视为失败并重试。所以别在 webhook 里同步做耗时操作(调 LLM、查库、发消息)。正确姿势是:解析完 payload 立刻返回 200,把耗时处理丢到后台异步任务里:
python
@router.post("/your/webhook")
async def webhook(request: Request):
body = await request.body()
# ... 验签 ...
payload = parse(body)
for msg in extract_messages(payload):
# 幂等去重:Meta 可能重投同一条 message_id
if already_processed(msg.id):
continue
mark_processed(msg.id, ttl=60)
asyncio.create_task(handle_message(msg)) # 耗时处理全异步
return {"status": "ok"} # 立刻 200
七、总结:一张排查清单
收到"WhatsApp Cloud API webhook 验证成功,却收不到消息"时,按这个顺序走:
- 先查
GET /{waba_id}/subscribed_apps------返回空就是它,POST补上立刻通。(90% 的情况到这一步就解决了) - 别假设"测试号自动、生产号才要配"------订阅是 WABA 级关系,不会跨环境自动延续,也可能是别人替你配过却没记录。直接对生产 WABA 跑
GET /{waba_id}/subscribed_apps自查,缺了就POST补。 - 用
debug_token验access_token的type和expires_at(生产要SYSTEM_USER+0)。 - 看日志而非状态码判断验签------验签失败也返回 200。
- 别被"支付方式"告警带偏------客服窗口内回复免费,不需要支付。
- 号码"未注册"走 Cloud API
/register(设好 PIN)。 - 真机测试不可省------它是唯一能验证 Meta 真实投递的手段。
把三个"订阅"记牢
| 订阅 | 验证的是 | 在哪做 |
|---|---|---|
| ① Webhook URL 验证 | 回调地址可达 + verify_token | 你的 GET 接口 |
② messages 字段订阅 |
App 想收哪类事件 | 控制台勾选 |
③ subscribed_apps |
把 WABA 事件路由到 App | Graph API(最易漏) |
一句话收尾 :WhatsApp Cloud API 里"验证成功"只验地址,"消息送达"只说明发到了对方手机------这两件事都不等于"回调会进你的后端"。让消息真正流到你服务里的那个开关,叫
subscribed_apps。
附:一个可直接跑的最小 FastAPI Webhook 示例
pip install fastapi uvicorn 后执行 uvicorn app:app --port 8000 即可启动;两个环境变量:WA_VERIFY_TOKEN(你在 Meta 配 webhook 时自定义的串)、WA_APP_SECRET(Meta App 的 App Secret,用于 HMAC 验签)。它把前文要点串成一份能跑的骨架:GET 回显 challenge、POST 验签、幂等去重、5 秒内异步返回 200。
python
"""最小可运行 WhatsApp Cloud API Webhook · uvicorn app:app --port 8000"""
import os, hmac, hashlib, asyncio, time, json, logging
from fastapi import FastAPI, Request, Response
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("wa")
VERIFY_TOKEN = os.environ.get("WA_VERIFY_TOKEN", "") # Meta 配 webhook 时自定义
APP_SECRET = os.environ.get("WA_APP_SECRET", "") # Meta App → App Secret
app = FastAPI()
_seen = {} # 内存去重(生产换 Redis,设 60s 过期)
def already_processed(msg_id, ttl=60):
now = time.time()
for k in [k for k, t in _seen.items() if now - t > ttl]:
_seen.pop(k, None)
if msg_id in _seen:
return True
_seen[msg_id] = now
return False
@app.get("/chat/whatsapp/webhook")
async def verify(request: Request):
p = request.query_params
if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == VERIFY_TOKEN:
return Response(content=p.get("hub.challenge", ""), media_type="text/plain")
return Response(status_code=403)
@app.post("/chat/whatsapp/webhook")
async def receive(request: Request):
body = await request.body()
if APP_SECRET: # HMAC 验签:失败也返回 200,防重试风暴,靠日志暴露
expected = "sha256=" + hmac.new(APP_SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(request.headers.get("X-Hub-Signature-256", ""), expected):
logger.warning("[WA] HMAC 验签失败")
return {"status": "ok"}
try:
payload = json.loads(body)
except Exception as e:
logger.error(f"[WA] payload 解析失败: {e}")
return {"status": "ok"}
for entry in payload.get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
metadata = value.get("metadata", {}) # 含 phone_number_id(多租户路由依据)
for msg in value.get("messages", []) or []:
if already_processed(msg["id"]):
continue
asyncio.create_task(handle_message(msg, metadata))
return {"status": "ok"} # 5 秒内立刻 200
async def handle_message(msg, metadata):
"""耗时业务(调 LLM / 查库 / 回消息)放这里------已脱离 5s 窗口"""
try:
t = msg.get("type")
text = msg.get("text", {}).get("body", "") if t == "text" else f"<{t}>"
logger.info(f"[WA] 入站 from={msg.get('from')} type={t} text={text!r} "
f"pnid={metadata.get('phone_number_id')}")
# TODO: 你的业务逻辑 / 调 Graph API 回复
except Exception:
logger.exception("[WA] 处理消息异常")
去重这里用内存字典演示,生产请换 Redis(设 60s 过期)。
metadata里的phone_number_id是多租户路由的依据------一个号服务一个商户时,用它反查是哪个租户。