一位客户发来邮件:“我的 Stripe Webhook 坏了。”你打开 Stripe 控制台,事件都在正常触发。你查服务器日志,什么都没有。Sentry,也什么都没有。事件确实去了某个地方,只是不在你以为的地方。
这篇文章给出一套排查生产环境 Webhook 投递问题的分步框架,依据的是最常出现的那些故障模式。
TL;DR
- 从平台控制台开始:事件有没有触发,它收到的是什么状态码?
4xx说明是你在拒收它(签名、中间件、URL 错误)5xx说明你的处理程序崩溃了。去错误监控里查那个确切的时间点- 超时说明你的处理程序太慢。把繁重的工作改成异步,尽快返回 200
- 要解决“排查花了 2 个小时”,就需要一个能把接收、转发、重试和重放放在一起看的地方,这通常意味着一个 Webhook 转发服务
决策树
从这里开始。它能帮你省下 30 分钟的瞎猜。
Q1:平台真的在发送这个事件吗?
查看平台的控制台:Stripe 在 Developers → Events(开发者 → 事件),GitHub 在 Settings → Webhooks → Recent Deliveries(设置 → Webhook → 最近交付),Shopify 在 Dev Dashboard → Apps → 你的应用 → Logs。
如果事件不在那里,说明触发条件没有生效,去排查本该生成它的上游状态。如果在,进入 Q2。
Q2:平台收到 2xx 了吗?
在同一个控制台里,查看你的端点返回的响应状态码。
2xx:平台认为投递成功了。如果你的应用从没见过这个事件,那它是到达了,却在响应之后被丢掉了。检查你的异步队列、数据库写入和处理程序逻辑。4xx:是你在拒收它。最常见的是签名验证失败、缺少某个请求头,或者中间件(WAF、限流器、认证层)拦下了请求。5xx:你的端点崩溃了。去查错误监控,不要只看“最近一小时”,而要看这次投递的确切时间点。- 超时:你的端点太慢。见 Q4。
- 连接被拒绝或 DNS 错误:你的端点无法访问,这是另一类问题,出在网络、DNS 或防火墙的某个环节。
Q3:如果是 4xx,具体是哪一个?
400,错误请求。通常是请求头不匹配,或者你的签名检查在返回明确错误之前就抛了异常。检查请求体格式错误或缺少签名时,你的处理程序返回的是什么。401/403,你把请求当作未授权拒收了。是签名验证失败?还是中间件在一个本不需要用户会话的端点上检查了用户会话?404,URL 错了。你是不是部署了一次路由变更,把 Webhook 端点挪走了?413,请求体过大。调高你的 body-parser 上限。429,触发了限流。不管是什么在对你的 Webhook 路由限流,都不应该这样做。大多数 Webhook 平台都从一组有限的 IP 发出请求,你的限流器可能把它们全当成了同一个攻击者。
Q4:如果是超时,慢在哪里?
GitHub 给你 10 秒。Shopify 给 5 秒。Slack 和 Discord 给 3 秒。Stripe 从没公布过具体数字;它的文档只说要在任何耗时逻辑之前返回 2xx。完整的表格(每一行都附出处)见 Webhook 平台参考。
大多数超时都来自在处理程序里做了太多事。最常见的原因有:往已经饱和的连接池里同步写数据库,在处理程序里调用另一个服务(Stripe、Salesforce、Notion、你自己的 API),同步发送邮件或 Slack 消息,以及在日志采集端正在施加背压时,把整个请求体打印到 stdout。
修复模式是:验证签名,把事件放进队列,返回 200。所有业务逻辑都在下游异步进行。
通常真正坏掉的是什么
看了几年这类问题,下面是 80/20 法则里最常见的那些情况:
- 签名验证时重新序列化了请求体。见签名验证指南
- 代理或 WAF 在 Webhook 到达处理程序之前就把它吞了。去 Cloudflare、Vercel 或你所用平台的请求日志里查
- 触发了限流。Cloudflare 的 Bot Management 可能会把正常的 Webhook 误判为机器人流量
- 一次部署改了端点 URL,却没人去平台那里更新
- 生产环境缺了某个环境变量(签名密钥、数据库 URL),处理程序静默地返回 500
- 后台任务在 Webhook 返回 200 之后崩溃了。平台以为一切正常,但什么都没有被处理
第 6 条在 Serverless 平台上非常普遍,因为安排在响应之后的工作完全没有持久性保证:见在 Vercel 上接收 Webhook,不让事件丢失。而如果症状是重复而不是缺失,那通常是设计如此:见同一个 Webhook 收到了两次。
真正有用的工具
平台控制台适合一次看一个事件,不适合看规律。如果你想看“过去 24 小时内所有失败的事件”,就只能手动翻页。
ngrok 和 webhook.site 是给本地开发用的。在生产环境里帮不上你。
如果你记录了对的东西,你自己的日志就有用。如果你在签名检查之前记录了“webhook received for event evt_xxx”,至少能知道它到达过。大多数人是在之后记录的,这让签名失败的事件完全不可见。
像 AnyHook 这样的 Webhook 转发服务位于你的处理程序前面,记录请求头、请求体、耗时、响应状态码和尝试次数。排查流程就变成:看事件流,点开失败的那个,检查原始请求体,重放它。
AnyHook 的排查流程
当有用户给 AnyHook 支持发邮件说“我的 Webhook 不工作了”,控制台通常不到一分钟就能让原因变得很明显:
- 打开事件流,筛选
status=failed - 点开失败的事件
- 查看接收到的请求头(签名在不在?)、接收到的请求体(结构和你预期的一样吗?)、转发后的响应(你的服务器说了什么?),以及耗时拆解(边缘节点 → 队列 → 投递)
- 如果只是偶发问题,比如一次糟糕的部署或网络抖动,点击 Replay(重放),看着它成功
- 如果每个事件都在失败,你现在就确切知道该修什么了;修的期间,你的事件会一直保存,在你的套餐的重放窗口内都可以重放(Free 套餐 3 天,Pro 30 天,Scale 90 天)
能把 2 小时的排查缩短到 2 分钟的,是把平台实际发出的请求、你的服务器实际返回的响应,以及两者之间的时间戳,全都放在一个地方。没有这些,你就得从三个控制台和自己的日志里拼凑还原。
想在注册任何东西之前先看看效果,一条命令就能返回一个可用的接收 URL,不需要账号:
curl -X POST https://anyhook.net/api/v1/quickstart
响应里有一个 inbound_url,用来填到平台那里;一个 api_key,用来通过 API 读取事件日志;还有一个 claim_url。这个端点是临时的:除非你打开认领 URL 并登录,否则 7 天后它和它的事件都会被删除;认领之后 URL 保持不变。
总结
如果你的周日下午太常耗在这上面,AnyHook 能把其中大部分变成控制台上的一次点击。