Webhook 总是返回 200?支付回调静默丢失的坑与修法

webhook 端点过早返回 200 会让事件静默丢失,正确做法是入队后再 ack。
文章通过一个真实排查案例揭示了 webhook 集成中常见的致命误解:端点收到事件后立即返回 HTTP 200,导致 Stripe 误判投递成功而不再重试,最终扣款成功但履约任务从未执行。作者指出,问题的根源在于混淆了 received 与 processed 两种确认语义——200 应代表事件已持久化入队,而非业务已完成。修复方法包括:仅在入队后返回 2xx、处理失败抛 5xx 触发重试、消费与 ack 解耦。文章还强调可观测的接收端工具对定位「事件消失」类问题的价值,并提出四个自查问题。最终结论是:webhook 本质上是一份关于失败的契约,端点必须能区分「拿到了」与「处理完了」。
一个让支付流程「假成功」的 bug
有天早上,我花了大半天排查一个诡异现象:支付流程在 staging 环境跑得好好的,一到真实测试却怎么都不触发履约任务。
最后的结论让人有点后怕——我的 webhook 端点收到事件后,第一件事就是回了一个 HTTP 200,然后才慢悠悠地去处理。Stripe 的 retry 机制看到 2xx,判定投递成功,永远不会再发第二次。于是:扣款成功了,履约任务一次都没跑。

这个问题不是「代码写错了某个循环」,而是对 webhook 语义的理解从根上就偏了。
「收到了」不等于「处理完了」
真正让我改代码的,不是把处理逻辑挪到响应之前,而是接受一个事实:收到(received)和处理完(processed)是两种完全不同的确认。
大多数 webhook 协议里,HTTP 状态码承担的是「这次投递是否被接受」的信号,而不是「业务是否完成」的信号。把这两件事混为一谈,就会写出那种「看起来很稳、其实在吞事件」的端点。
修法其实很朴素:
- 只有当事件被持久化入队之后,才返回 200;
- 任何处理阶段的错误都抛成 5xx,让发送方重试;
- 队列消费与响应解耦,消费者失败不影响 ack 逻辑。
改完之后我才意识到之前有多危险:Stripe 的重试窗口长达 3 天,而我的旧代码在第一次尝试时就把唯一的一份事件「吃掉」了。
幂等性(Idempotency) 是 webhook 集成中另一个容易被忽视却同样致命的概念。由于发送方在未收到 2xx 时会不断重试,你的端点很可能同一个事件收到多次。如果处理逻辑不是幂等的——比如每次收到 charge.succeeded 都创建一条履约记录——那么重试反而会制造重复扣款或重复发货。常见做法是在持久化入队时以事件 ID(如 Stripe 的 event.id)作为唯一键做去重,或在业务层使用「先查询再写入」的乐观锁。也就是说,正确处理重试的前提是承认重复投递是常态而非异常,这与「收到即处理完」的错误假设是同一枚硬币的两面。
把接收端做成一个可观测工具
调试过程中最难受的,是不知道发送方到底重试了几次、每次带了什么 header 和 payload。你只能靠猜。
后来我把接收端单独打包成了一个工具:webhook_catch.html。它的思路很简单——给你一个一次性的 HTTPS URL,能在 24 小时内保存最多 100 个事件,这样你就能真实地看到:发送方重试了没有、什么时候重试的、每次 payload 长什么样。
我踩的那个「过早 ack」的坑,恰恰是这类工具最擅长暴露的问题。当你能看到每一次重试的完整 header 和 body 时,就不用再猜事件为什么「消失」了。
Webhook 是一份关于失败的契约
这件事给我最大的启发,不是某个具体的修法,而是一个视角的转变。
Webhook 不是一份关于「投递」的契约,而是一份关于「失败」的契约。 它默认网络会抖、消费者会挂、消息会重复。发送方设计重试逻辑,是因为它假设你随时可能失败——而你要做的,是在失败时明确地告诉它「我没处理完,请再发一次」。
如果你的端点分不清「我拿到了」和「我处理完了」,那你手里就不是一个 webhook 集成,而是一个多绕了几步的竞态条件。
几个值得自查的点
- 你的 ack 是在入队前还是入队后发出的?
- 处理失败时,返回的是 2xx 还是 5xx?
- 事件是否做了幂等处理,以应对发送方的多次重试?
- 你有没有办法观测到「第 N 次重试」的原始 payload?
这四个问题里,只要有一个答不上来,线上就可能已经在悄悄丢事件了。
在分布式系统中,这类问题被称为 at-least-once 投递语义——发送方保证事件至少被投递一次,但不保证只投一次。与之相对的 at-most-once(可能丢)和 exactly-once(理论最优、工程极难)构成了消息投递的三档语义。几乎所有主流 webhook 提供方(Stripe、GitHub、Shopify 等)都选择 at-least-once:宁可让你收到重复事件,也不能让你漏掉关键通知。理解这一点后,接受「重试是常态、幂等是义务」就不再是被动妥协,而是与协议设计者的显式契约对齐。
相关推荐

Kimi K3.1实测:开源模型逼近Opus,视频生成意外抢镜
Kimi K3.1在直播实测中完成运动图形、3D渲染、小游戏与视频生成等复合任务。本文分析其2.8万亿参数MoE架构的真实表现、与Opus/GPT的体感差距,以及被官方忽略的视频生成能力,并讨论多模型并用时代的工作流变化。

Sam Altman:人生重大建议,为何仍需来自人类?
OpenAI CEO Sam Altman 谈 AI 建议的边界:AI 能劝阻冲动邮件,但人生重大建议仍应来自人类。他期待 AI 成为会反驳的资深同事,而非谄媚工具。

“黑色十月”:AI挖出史上3%严重漏洞?
Reddit 热帖称过去一个月发现了史上 3% 的严重软件漏洞,被称为「黑色十月」。本文拆解这一说法的统计口径问题,分析 AI 辅助代码审计如何加速漏洞发现,以及开发者和企业该如何应对补丁窗口缩短的挑战。