为排队回复实现 WhatsApp 24 小时发送护栏
排队中的 WhatsApp 回复可能在等待审核、重试或 worker 领取期间跨过 24 小时窗口。可靠的发送护栏必须在真正发送前重新读取服务端状态,用最近一条已验证的用户消息计算到期时间,并阻止已经过期的自由文本。消息类别与计费规则可先查阅服务消息与实用消息决策树;本文只聚焦如何实现不会误发的执行边界。
要点速览
- 保存两个到期时间和单调递增的
state_version,不要只存一个布尔状态。 - 只有已验签、已去重的用户入站消息才能重置 24 小时窗口。
- worker 领取任务后,应在同一事务或锁内重新读取状态并判断发送路径。
- 已过期的排队草稿应停止并重新分流,不能静默改成模板。
- 用重复事件、乱序事件、并发 worker 和精确到秒的边界测试验证护栏。
围绕排队回复构建 WhatsApp 发送护栏
WhatsApp 官方商业平台计费页面说明,用户消息会开启或重置 24 小时客服窗口。实现层只需把这条许可边界作为最终发送条件;2026 年 10 月的消息类别和计费变化由现有决策指南单独解释,本文不再复述。
发送器在执行每个排队任务时必须回答三个实施问题:
- 读取的是最新状态版本吗? 旧 worker 不能覆盖更新后的窗口。
- 在实际发送时窗口仍开放吗? 草稿创建或审批时的判断不能复用。
- 选择的发送路径仍获授权吗? 窗口关闭后只能使用真正匹配且已审批的模板,否则停止并交还人工。
用两个时钟,而不是一个状态
请保存明确的时间戳,让重试、延迟任务和人工接管都能重新计算。
| 保存字段 | 何时开启或重置 | 控制什么 |
|---|---|---|
service_window_expires_at | 每一条用户入站消息 | 是否允许发送非模板服务回复 |
free_entry_expires_at | 用户从符合条件的 Click-to-WhatsApp 广告或 Facebook 页面行动按钮进入 | 72 小时内是否免除消息投递费 |
last_user_message_id | 每一条被接受的用户入站消息 | 当前窗口的幂等与审计依据 |
state_version | 每一个改变状态的入站事件 | 防止排队发送器覆盖更新状态 |
72 小时免费入口不是延长版客服窗口。用户在该期间再次发消息,可以重置 24 小时许可时钟,但原来的免费入口到期点仍然独立。
实现发送护栏
下面是应用层 TypeScript 示例,不是 Meta webhook 载荷。它刻意把“是否允许”与“是否预计收费”分开返回:
type WindowState = {
serviceWindowExpiresAt: Date | null;
freeEntryExpiresAt: Date | null;
};
function evaluateWhatsAppSend(state: WindowState, now: Date) {
const serviceWindowOpen =
state.serviceWindowExpiresAt !== null && now < state.serviceWindowExpiresAt;
const freeEntryActive =
state.freeEntryExpiresAt !== null && now < state.freeEntryExpiresAt;
return {
maySendNonTemplate: serviceWindowOpen,
expectedDeliveryCharge: serviceWindowOpen && !freeEntryActive,
requiredPath: serviceWindowOpen ? "service" : "approved_template",
} as const;
}
这段判断必须成为最终发送护栏,不能只用于界面提示。10:00 写好的回复可能在审核队列里停留到窗口关闭。发送器应在领取任务的同一事务或锁内重新读取状态,再选择:
- 窗口开放时发送非模板服务回复。
- 窗口关闭后,在业务意图与已审批模板匹配时改走模板。
- 两条路径都不合法时停止发送并交还人工处理。
不要静默把自由文本改造成模板,也不要为了适配模板而截断内容。模板类别、变量和审批是独立契约。
处理入站事件时不要误延长窗口
只有用户发来的消息才能重置客服窗口。投递回执、消息状态、客服草稿、内部备注、重试和企业侧消息回显都不能延长它。
建议按以下顺序处理:
- 先验证 provider webhook,再修改状态。
- 用稳定的消息或事件标识去重。
- 确认事件确实是用户发送的入站消息,而不是回执或回显。
- 将
service_window_expires_at设为已接受的用户消息时间加 24 小时。 - 只有符合条件的 referral 才单独建立 72 小时免费入口。
- 增加
state_version,并保存来源事件作为审计证据。 - 每次真正发送前重新计算两个时钟。
不要在旧到期时间上继续加 24 小时。用户 09:00 发消息、12:00 又发一条时,新到期点应是次日 12:00,而不是在旧到期点后再延一天。
测试排队回复与事件顺序边界
使用固定时钟做验收测试,确保一秒钟的边界也可复现。
| 场景 | 预期结果 |
|---|---|
| 用户 10:00 发消息;次日 09:59:59 回复 | 允许非模板服务回复 |
| 同一消息;次日刚好 10:00 回复 | 按窗口已关闭处理 |
| 次日 09:50 用户又发消息 | 到期点重置为再下一日 09:50 |
| 回复在到期前获批、到期后才被任务领取 | 阻止非模板发送并重新路由 |
| 同一用户消息被重复投递两次 | 去重后只产生一次状态迁移 |
| 较旧的用户消息晚于新消息到达 | 保留较新的到期点,不让状态倒退 |
| 两个 worker 同时领取同一排队回复 | 只有一个 worker 通过版本检查并发送 |
上线后,应把系统预判与官方状态和计费记录对账。服务消息费用追踪指南说明了如何把投递量与各市场费率分开。如果你正在选择 Meta Business Agent 或自建 AI 流程,请阅读计费与架构对比,不要继续给这个状态机堆叠不相关的 AI 标记。
UnifyPort 适合放在哪里
上述状态机适用于官方 WhatsApp Business Platform。UnifyPort 的非官方接口是普通消息账号的另一条接入路径,不提供 Meta 的客服窗口或 Pricing Analytics 状态。
在这条独立路径上,WhatsApp 入站消息会以标准 message.received 事件到达。若 webhook 端点配置了 signing_secret,应先用原始请求体验证 X-Device-Timestamp 与 X-Device-Signature;标准支持范围内的回复统一使用 POST /v1/messages。具体契约见webhook 投递与签名文档和 message.received 事件文档。
不要把 UnifyPort 事件送入官方 Cloud API 计费状态机后声称它已被 Meta 分类。若团队同时使用两条路径,请保存明确的 transport 或 control_plane 字段,并拆分账本。
限制与取舍
需要已审批模板、营销活动工具、Click-to-WhatsApp 归因、Meta 原生分析或 BSP 托管能力时,应选择官方 WhatsApp Business Platform。官方平台也是判断官方投递是否收费的事实来源。
非官方接口不能审批模板、延长 Meta 窗口、提供 Meta Pricing Analytics 或改变 WhatsApp 政策。它的价值是为普通消息账号提供另一条接入路径,并统一多个平台的事件格式。无论选择哪条路径,都需要服务端幂等、并发控制和审计日志。
费率与产品规则仍具有时效性。10 月 1 日切换前请重新核对 Meta 官方文档,也不要把规划阶段费率写死在状态迁移逻辑里。
常见问题
发送护栏应该使用哪个时间戳?
使用通过验证并被系统接受的最新用户消息时间;不要使用 webhook 接收时间、任务创建时间或客服界面中的倒计时。
乱序 webhook 会把窗口缩短吗?
不应。用稳定事件标识去重,并只在入站消息时间晚于当前记录时更新到期点和 state_version。
排队回复在发送前过期怎么办?
停止自由文本发送并重新分流。只有业务意图与已审批模板真正匹配时才能改走模板,否则交还人工处理。
可以自动把过期自由文本转换成模板吗?
不可以静默转换。模板类别、变量和审批是独立契约,需要明确选择和验证。
如何防止重复事件触发两次发送?
持久化消息或事件标识,并让 worker 在事务或锁内校验幂等键与 state_version 后再发送。
下一步
先把入站边界做对:阅读webhook 投递与签名文档,再测试重复或乱序的入站事件不会错误延长发送窗口。状态迁移可靠后,再接入消息分类决策。
来源
官方来源核验日期:2026 年 7 月 27 日。