调试2026年8月6日8 分钟阅读

WooCommerce Webhook 不是在下单时发出的

WooCommerce Webhook 回调靠 Action Scheduler 和 WP-Cron 发出,只在有人访问网站时才运行。连续失败超过五次,Webhook 就会被禁用。

订单已下,Webhook 却没发出已下单Action Schedulerwoocommerce_deliver_webhook_async等待等一次页面访问已发出WP-Cron 不是时钟。有人访问网站时它才运行。访问量小的店铺要等几分钟。非常冷清的店铺要等几个小时。连续失败超过五次,WooCommerce 就会直接停用这个 Webhook
订单是立即创建的。投递却要等下一次页面访问,因为 WP-Cron 不是时钟。

其他所有讲 Webhook 的文章,包括我们自己的其他文章,都基于同一个假设:平台及时发出了事件,之后某个环节出了问题。WooCommerce 在请求离开服务器之前,就打破了这个假设。

事件并不是在下单时发出的。它被放进一个队列,队列由 WordPress cron 消费,而 WordPress cron 并不是真正的 cron。它在有人加载页面时才运行。

如果你的店铺很繁忙,延迟最多约一分钟,你永远不会注意到。如果你的店铺每小时只有几个访客,“实时”订单通知要等下一个访客碰巧出现时才会到达。

TL;DR

  • 投递以 woocommerce_deliver_webhook_async 动作的形式在 Action Scheduler 中排队
  • Action Scheduler 由 WP-Cron 驱动,而 WP-Cron 在页面加载时触发,而不是按时间表触发
  • 因此低流量店铺的 Webhook 投递又慢又不可预测,而这其中没有任何东西是坏的
  • “WooCommerce 会在连续投递失败超过五次后自动禁用 Webhook”
  • 凡是不是 2xx、301 或 302 的响应,都算失败
  • 在自托管的 WordPress 上,真正的修复办法是禁用 WP-Cron,改由系统 cron 驱动

先确认它到底有没有发出

在调试你的端点之前,先弄清你遇到的是两个完全不同的问题中的哪一个:投递失败了,还是投递还没有发生。

WooCommerce → Settings → Advanced → Webhooks 显示每个 Webhook 的状态,WooCommerce → Status → Logs(来源为 webhooks-delivery)显示它最近的投递和响应码。如果列出了投递而且都失败了,直接跳到下面的“清单的其余部分”。如果日志是空的,而订单明明存在,那投递还在队列里。

订单已下,Webhook 却没发出已下单Action Schedulerwoocommerce_deliver_webhook_async等待等一次页面访问已发出WP-Cron 不是时钟。有人访问网站时它才运行。访问量小的店铺要等几分钟。非常冷清的店铺要等几个小时。连续失败超过五次,WooCommerce 就会直接停用这个 Webhook

要直接看队列,打开 Tools → Scheduled Actions 下的 Action Scheduler 管理页面,筛选 woocommerce_deliver_webhook_async。时间戳已经过去、却仍在等待的动作就是铁证:它们早该执行了,却没有任何东西运行它们。

WP-Cron 为什么会这样

WordPress 没有后台进程。它所谓的 cron,是一张在请求进来时检查的到期任务清单,所以只有流量到来时,时钟才会往前走。对 2005 年的共享主机来说,这是合理的设计;对任何讲究时效的事情来说,这是糟糕的设计。

具体到 Webhook,后果是:

投递延迟跟着你的流量走,而不是跟着事件走。流量稳定的店铺大约一分钟内就能投递。一个 B2B 店铺,订单在夜里进来,直到早上都没人浏览,排队的投递就可能一压几个小时。

积压会越滚越大。失败的动作会留在表里,在运行了一段时间的店铺上,Action Scheduler 的表会大到拖慢那些本该消化它们的请求。

有缓存时,靠页面加载触发的任务并不可靠。WordPress 前面如果有整页缓存或 CDN,很多访问根本到不了 PHP,也就永远推动不了 cron。

修复办法是标准的 WordPress 运维操作,任何 Webhook 重要的店铺都值得做:

// wp-config.php
define('DISABLE_WP_CRON', true);
# then drive it from a real scheduler, every minute
* * * * * cd /var/www/store && wp cron event run --due-now >/dev/null 2>&1

如果装了对应的 WP-CLI 包,wp action-scheduler run 可以直接消化 Action Scheduler 队列,作用相同。一旦 cron 按真正的定时器运行,投递延迟就不再取决于有没有人在浏览。

失败超过五次,Webhook 就没了

这一部分会把一个暂时的问题变成永久的问题。

“WooCommerce 会在连续投递失败超过五次后自动禁用 Webhook。”失败的定义很宽:“任何不是 2xx、301 或 302 的 HTTP 状态码。例如,4xx 或 5xx 响应都算失败。”

这个数字很小。在订单持续进来的店铺上,一次让端点停机两分钟的部署,轻轻松松就能耗掉这么多次投递。一旦发生,Webhook 的状态就会变成已禁用,并一直保持。没有邮件,没有后台提示,没有任何会出现在人眼前的东西。订单照常创建,却什么都没发出去,看起来和一个销售清淡的日子没有任何区别。

这些问题并非 WooCommerce 独有。n8n Webhook 不工作把自动化一侧的七个原因按出现频率排了序;而一个消化得这么不均匀的队列,乱序投递是常态,这正是 HubSpot 说它的 eventId 可能不唯一的主题。当重试真的落地两次时,重复预订讲的就是这件事在下单的那个人眼里是什么样子。

这和 Shopify 删除订阅是同一种失败形态,但有两点不同,让情况更糟:阈值是连续失败超过五次,而不是 Shopify 的八次;而且根本没有警告邮件。

如果你在生产环境中运行 WooCommerce,给 Webhook 的状态加一个检查。用一个定时任务通过 REST API 读取 Webhook,在 status !== 'active' 时告警,几乎没有成本,也是唯一会告诉你出事了的东西。

签名验证

WooCommerce 会发送 X-WC-Webhook-Signature,它是用你在 Webhook 上配置的密钥对原始请求体计算的 HMAC-SHA256,经过 base64 编码。

是 Base64 而不是十六进制,这和 Shopify 的约定相同,和 Stripe、GitHub 相反。各平台选用的编码见 Webhook 平台参考。如果你有一个跨平台共用的验证辅助函数,这种编码差异就是“请求体看起来完全没问题,签名却始终对不上”的常见来源。

import crypto from "node:crypto";

function verifyWooCommerce(raw: string, header: string | null, secret: string) {
  if (!header) return false;
  const expected = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("base64");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

和其他地方一样,对你收到的字节计算哈希,而不是对重新序列化后的对象。各平台的对比见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。

有一个 WooCommerce 特有的坑:如果创建 Webhook 时没有设置密钥,签名会用站点自己的密钥材料生成,而不是你选定的值,你的验证会因为和代码毫无关系的原因失败。显式设置一个密钥。

清单的其余部分

当投递存在而且在失败时,逐项排查以下几点。

状态是 active,而不是 paused 或 disabled。先查这一项,因为禁用状态一下子就能解释其他所有现象。

主题和你以为的事件一致。order.created 在订单创建时触发,而对很多支付网关来说,这时订单还处于 pending 状态。如果你在等付款,通常需要的是 order.updated 加上状态检查。很多“Webhook 不触发”的报告,实际上是“在一个和预期不同的事件上触发了”。

投递 URL 要能从服务器访问到,而不只是从你的笔记本电脑。位于企业内网之后,或者托管在出站规则严格的主机上的店铺,可能会在这里无声地失败。

某个插件让这个动作短路了。WooCommerce 的钩子可以被过滤,而缓存、安全或性能插件都有干扰 Action Scheduler 的先例。禁用插件后再测试,做法不怎么体面,却常常能直接找到答案。

Webhook 的 API 版本。WooCommerce 的 Webhook 请求体有版本之分,几年前创建的 v1 Webhook 发出的数据结构和 v3 不同。如果你的处理程序在 WooCommerce 升级后开始失败,检查一下这个 Webhook 固定在哪个版本上。

AnyHook 能做什么

上面的问题中有两个是结构性的,哪一个都不是靠更好的处理程序能解决的。

第一个是连续失败后的禁用。把 Webhook 指向 in.anyhook.net/you/woo,AnyHook 约 1 秒就从边缘节点返回 200,所以连续失败计数器不再取决于你自己的端点情况。对你端点的重试按 AnyHook 的计划进行,而不是 WooCommerce 的,Webhook 也就一直保持启用。

第二个是 WooCommerce 自己的投递日志存在店铺内部,遵循店铺的保留期,只能在后台页面里看。AnyHook 保存每个事件的完整请求头和请求体,做了静态加密,并支持按时间范围重放,所以一段部署窗口内的事件不用碰 WordPress 就能恢复。

  • 连续失败 1 次、5 次和 20 次时发出失败告警,这正是 WooCommerce 不发的通知
  • 投递按指数退避重试,远不止一次尝试
  • 如果你从多个平台接收,用一个 AnyHook-Signature 请求头取代 base64 的变体

转发服务修不了的,是它前面的那个队列。如果 WP-Cron 没在运行,事件哪里都到不了。先修好 cron;之后的一切交给转发服务。

总结

在各大主流平台里,只有 WooCommerce 上的“Webhook 来晚了”通常既不是 bug,也不是你端点的问题。先查 Action Scheduler 队列,再查其他任何东西;如果店铺流量低,把 cron 换到真正的调度器上。

然后设置密钥,用 base64 对原始字节做验证,再给 Webhook 自身的状态加上监控。几次连续失败的余地并不大,而禁用发生时不会有任何通知。

全部文章2026年8月6日 · 8 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。