那封邮件是在一个周日早上收到的。一家欧洲非营利基金会的运营总监转发过来,只附了一句话:请查看下方内容并处理。下面是 Stripe 的自动通知:几天来它一直无法把 Webhook 事件送达基金会的捐赠网站,已经失败了十九次;如果情况不变,一周内 Stripe 将彻底停止向这个 endpoint 发送通知。
对于一个通过 WordPress 接受单次和月度捐赠的机构来说,这绝不是小问题。Webhook 是网站得知一笔付款真正成功的唯一途径。没有它,捐赠会一直停留在待处理状态,捐赠者收不到收据,月度续费也不会被记录,而钱其实一直在进入 Stripe。
下面是我们排查这次故障的经过:为什么最先发现的问题确实存在却不是根源,以及最终那个没有关闭任何安全防护的小改动。
Stripe Webhook 的作用,以及失败为什么要紧
捐赠者付款时,浏览器会跳转到 Stripe 再返回。网站不能相信浏览器带回来的付款结果,所以 Stripe 还会从服务器直接调用网站,发送类似 payment_intent.succeeded 的事件。网站必须返回 200 到 299 之间的 HTTP 状态码,否则就算失败。Stripe 会以逐渐拉长的间隔重试,最长三天,之后放弃该事件。
这个网站使用的捐赠插件是 GiveWP,它在以 ?give-listener=stripe 结尾的地址上接收事件。网站是多语言的,因此有两个活跃的 endpoint,一个在 /de/ 下,一个在 /fr/ 下。两个都在失败,尽管 Stripe 的邮件只提到了其中一个。

第一个发现:问题真实存在,但不是原因
WordPress 后台一打开就显示激活错误:Recurring Donations 扩展被停用了,因为它需要比当前安装版本更新的 GiveWP。扩展已经自动更新,主插件却没有,于是 WordPress 悄悄停用了扩展。而且它的许可证被设置为不再续订,已经收不到更新。
这看起来是个完美的解释,而且无论如何都需要修复。我们先做了完整备份,更新 GiveWP 并重新启用扩展,结果网站前台立刻出现致命错误。错误堆栈指向一个弹窗,里面嵌入了一个早已删除的旧捐赠表单短代码。我们先停用扩展恢复网站,修正弹窗,再重新启用,并检查了首页、捐赠页和每一个语言版本。
网站恢复正常,按月捐赠的选项也回来了。可 Webhook 依然失败。
读懂真正的错误,而不是靠猜
停止猜测最快的方法,是在 Stripe 后台的 Developers → Webhooks 中打开一次失败的投递,看两样东西:状态码和响应正文。这里的状态码是 403 Forbidden,而响应正文根本不是 WordPress 的错误,而是一个标题为 Just a moment… 的 HTML 页面,加载着来自 challenges.cloudflare.com 的脚本。

这个页面就是 Cloudflare 的浏览器质询。它要求浏览器先证明自己是真人,才放行请求。用 Chrome 的人一秒钟就能通过,但 Stripe 的服务器不是浏览器,无法完成质询,所以每一个 Webhook 都在边缘节点被拦下。请求从未到达 WordPress,这也是网站日志里什么都看不到的原因。
找出是 Cloudflare 的哪项功能拦截了 Stripe
Cloudflare 有多项功能都可能质询流量,修复方式取决于具体是哪一项。我们在 Security → Analytics → Events 中按查询字符串 give-listener 过滤,每条匹配记录的动作都是 Managed Challenge,服务都是 Security level。

同一个列表还揭示了另一件事:来自瑞士、法国和德国的普通访客在普通页面上也被质询。该区域的安全级别被调得很高,可能是为了应对之前的一次攻击,但没人注意到它把支付服务商也一起挡住了。
解决方法:一条精准的 Skip 规则,而不是削弱整站防护
降低安全级别当然也能解决,但那是为了一个 URL 改变整个网站的防护。更干净的做法是写一条自定义规则,只放行 Webhook 地址,其他一切保持不变。
- 进入 Security → Security rules,新建一条自定义规则。
- 取一个清晰的名字,例如 Allow Stripe webhooks。
- 匹配条件设为 URI Query String、contains、
give-listener=stripe。 - 动作选择 Skip,并勾选 All remaining custom rules。
- 展开 More components to skip,勾选 Security Level 和 Browser Integrity Check。
- 将规则放在第一位并部署。
(http.request.uri.query contains "give-listener=stripe")

最容易忽略的是第五步。规则的第一个版本只跳过了 Browser Integrity Check,API 预览显示 "products": ["bic"],这样 Webhook 仍会失败。事件日志已经说明拦截来自 Security level,所以必须跳过的就是这一项。免费套餐还要注意:如果日志里显示的是 Bot Fight Mode,Skip 规则无法绕过它,只能直接关闭该设置。
这样安全吗?这条规则并不是盲目信任请求。GiveWP 仍会用 Stripe 的签名密钥验证每一个 Webhook,伪造的请求即使到达接收地址,也会被 WordPress 拒绝。验证 Stripe 的身份从来都不是 Cloudflare 的职责,而是签名的职责。
确认修复,并找回遗漏的捐赠
我们在 Stripe 后台重新发送了一个失败的事件,返回 200 OK。一小时内,Stripe 的自动重试也开始成功,后台将之前的失败标记为 Delivery recovered。第二天,Stripe 还发来邮件确认该 endpoint 已经恢复。

清理阶段有两点实用经验。第一,重新发送按钮只对每个事件最近一次的投递有效;对于 Stripe 已经放弃的事件,要打开事件本身再从那里重新发送。第二,要核对的是钱,而不只是 Webhook:把故障开始以来 Stripe 的每一笔付款与 WordPress 里的捐赠记录逐一比对。这次所有成功的付款都已记录在案,有一笔确实因余额不足而失败并被标记为失败,另外两笔排查期间产生的小额测试付款也被标注出来,避免被误算为捐赠。
给所有接受付款的 WordPress 网站的经验
- 先看响应正文。包含 CDN HTML 的 403 不是程序缺陷,再怎么调试插件也修不好。
- 安全设置和支付 Webhook 必须协调一致。遭受攻击后提高安全级别合情合理,但忘了它同样作用于服务器之间的回调,捐赠就会悄悄丢失。
- 放行路径,而不是放行所有人。只针对接收地址的 Skip 规则能让网站其余部分继续受到保护。
- 过期的扩展许可证是慢性风险。它会停止更新,下一次核心更新就可能毫无预警地停用扩展。
- 关注告警。Stripe 会在停用 endpoint 前大约一周持续发出警告,务必确保这些邮件能送到有能力处理的人手里。
我们还写过一个类似的边缘案例,缓存与 WordPress 在时间上产生分歧:缓存 TTL 导致的间歇性 403 错误;以及能及早发现此类问题的例行检查:网站维护究竟包含什么。
常见问题
网站在我的浏览器里正常,为什么 Stripe Webhook 返回 403?
因为你的浏览器能通过 Cloudflare 的质询,而 Stripe 的服务器不能。如果失败响应的正文中出现 Just a moment 或 challenges.cloudflare.com,说明请求在到达 WordPress 之前就被 Cloudflare 拦下了。
是否应该改为把 Stripe 的 IP 地址加入白名单?
可以使用 Stripe 公布的 IP 列表,但列表会变化,需要持续维护。针对 Webhook 路径的 Skip 规则更简单,而且由于插件会验证签名,实际上同样安全。
Webhook 失败期间会丢失捐赠吗?
通常 Stripe 照常扣款,但网站不会记录。捐赠一直处于待处理状态,收据不会发出,定期续费也不会出现在记录中,直到事件成功送达或手动修正记录。
Stripe 会重试多久?
在正式模式下,最长重试三天,间隔逐渐拉长。如果 endpoint 持续失败,Stripe 会发邮件警告,最终将其停用。
你的 WordPress 网站在处理捐赠、电商或会员付款,并收到看不懂的支付告警?这正是我们 WordPress 开发和网站维护服务的日常工作。联系我们,我们会直接告诉你问题出在哪里、修复需要什么。