WhatsApp Cloud API 踩坑:webhook 验证成功,消息却收不到

你按文档一步步配好了 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=subscribehub.verify_tokenhub.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)审核时,含内部词、技术词、过于通用的名字(如带 ProdTestAI 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 验证成功,却收不到消息"时,按这个顺序走:

  1. 先查 GET /{waba_id}/subscribed_apps ------返回空就是它,POST 补上立刻通。(90% 的情况到这一步就解决了)
  2. 别假设"测试号自动、生产号才要配"------订阅是 WABA 级关系,不会跨环境自动延续,也可能是别人替你配过却没记录。直接对生产 WABA 跑 GET /{waba_id}/subscribed_apps 自查,缺了就 POST 补。
  3. debug_tokenaccess_tokentypeexpires_at(生产要 SYSTEM_USER + 0)。
  4. 日志而非状态码判断验签------验签失败也返回 200。
  5. 别被"支付方式"告警带偏------客服窗口内回复免费,不需要支付。
  6. 号码"未注册"走 Cloud API /register(设好 PIN)。
  7. 真机测试不可省------它是唯一能验证 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 是多租户路由的依据------一个号服务一个商户时,用它反查是哪个租户。