← 所有文章
教程

为排队回复实现 WhatsApp 24 小时发送护栏

排队中的 WhatsApp 回复可能在等待审核、重试或 worker 领取期间跨过 24 小时窗口。可靠的发送护栏必须在真正发送前重新读取服务端状态,用最近一条已验证的用户消息计算到期时间,并阻止已经过期的自由文本。消息类别与计费规则可先查阅服务消息与实用消息决策树;本文只聚焦如何实现不会误发的执行边界。

要点速览

  • 保存两个到期时间和单调递增的 state_version,不要只存一个布尔状态。
  • 只有已验签、已去重的用户入站消息才能重置 24 小时窗口。
  • worker 领取任务后,应在同一事务或锁内重新读取状态并判断发送路径。
  • 已过期的排队草稿应停止并重新分流,不能静默改成模板。
  • 用重复事件、乱序事件、并发 worker 和精确到秒的边界测试验证护栏。

围绕排队回复构建 WhatsApp 发送护栏

WhatsApp 官方商业平台计费页面说明,用户消息会开启或重置 24 小时客服窗口。实现层只需把这条许可边界作为最终发送条件;2026 年 10 月的消息类别和计费变化由现有决策指南单独解释,本文不再复述。

发送器在执行每个排队任务时必须回答三个实施问题:

  1. 读取的是最新状态版本吗? 旧 worker 不能覆盖更新后的窗口。
  2. 在实际发送时窗口仍开放吗? 草稿创建或审批时的判断不能复用。
  3. 选择的发送路径仍获授权吗? 窗口关闭后只能使用真正匹配且已审批的模板,否则停止并交还人工。

用两个时钟,而不是一个状态

请保存明确的时间戳,让重试、延迟任务和人工接管都能重新计算。

保存字段何时开启或重置控制什么
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 写好的回复可能在审核队列里停留到窗口关闭。发送器应在领取任务的同一事务或锁内重新读取状态,再选择:

  1. 窗口开放时发送非模板服务回复。
  2. 窗口关闭后,在业务意图与已审批模板匹配时改走模板。
  3. 两条路径都不合法时停止发送并交还人工处理。

不要静默把自由文本改造成模板,也不要为了适配模板而截断内容。模板类别、变量和审批是独立契约。

处理入站事件时不要误延长窗口

只有用户发来的消息才能重置客服窗口。投递回执、消息状态、客服草稿、内部备注、重试和企业侧消息回显都不能延长它。

建议按以下顺序处理:

  1. 先验证 provider webhook,再修改状态。
  2. 用稳定的消息或事件标识去重。
  3. 确认事件确实是用户发送的入站消息,而不是回执或回显。
  4. service_window_expires_at 设为已接受的用户消息时间加 24 小时。
  5. 只有符合条件的 referral 才单独建立 72 小时免费入口。
  6. 增加 state_version,并保存来源事件作为审计证据。
  7. 每次真正发送前重新计算两个时钟。

不要在旧到期时间上继续加 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-TimestampX-Device-Signature;标准支持范围内的回复统一使用 POST /v1/messages。具体契约见webhook 投递与签名文档message.received 事件文档

不要把 UnifyPort 事件送入官方 Cloud API 计费状态机后声称它已被 Meta 分类。若团队同时使用两条路径,请保存明确的 transportcontrol_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 日。