Webhook2026年8月3日9 分钟阅读

Stripe 禁用了你的 Webhook 端点。这是恢复步骤。

Stripe Webhook 回调端点被禁用,30 天的恢复窗口比听起来要短。顺序:重新启用,再用 Events API 回填。

还有什么能恢复,还能恢复多久3 天的重试警告停用Events API 仍保留全部事件 · 30 天首次失败+30 天,清除重新启用只会恢复新事件的投递。它不会补发任何事件。回填才是恢复手段:GET /v1/events?delivery_success=false要经过幂等的消费方处理,否则会重复履约
重新启用是简单的那 10%。30 天的 Events API 窗口,一旦错过,才真正会损失金钱。

邮件标题大概是“Your webhook endpoint is failing”之类的,如果你忽略了第一封,后续邮件会说端点已经被禁用。现在什么都收不到了。订阅没有开通,账单没有对账,你也不知道损失要追溯到多久以前。

好消息是:不管投递成没成功,Stripe 都会把每个事件保留 30 天。只要在这个窗口内行动,几乎都能找回来。坏消息是:大多数人重新启用端点,看到新流量都打上了绿色对勾,就再也没去回填缺口。缺口才是花钱的部分。

TL;DR

  • 先重新启用端点,但这只会恢复新事件的投递。它不会补发任何事件
  • 用 GET /v1/events?delivery_success=false 回填,最多能追溯 30 天
  • 回填之前先做到幂等,否则订单会被重复履约
  • 根本原因几乎总是处理程序太慢,而不是写错了
  • 先持久化请求体再处理,这件事就不再是紧急情况

Stripe 为什么会禁用端点

在真实模式(live mode)下,Stripe 会以指数退避重试失败的投递,最长三天。这个窗口很宽裕,它的存在是为了让一次部署或短暂的宕机不给你造成任何损失。Stripe Webhook 重试:生产环境指南详细讲了这个重试计划,以及如何在走到本文这一步之前就按它来设计。

不过三天并不是标准,把它当成标准,下一次集成时就会吃亏。同一个失败的事件,Paddle 会在大约两天内最多发送六十次。Paddle 两天内尝试 60 次,Stripe 则拉开间隔把两套重试计划放在一起对比,如果你同时通过两家收款,这一点最重要。

一个持续失败的端点,则是另一种信号。这个地址已经不能用了,继续猛发请求只会浪费 Stripe 的投递能力,还会让你的事件日志淹没在噪音里。所以 Stripe 会发邮件警告你,告诉你如果情况不变,端点会在哪天被关闭,最后真的关掉它。

值得记住的一点:禁用是第二个通知,而不是第一个。等到它发生时,失败已经在 Stripe 控制台里摆了好几天了。如果警告邮件发到了一个没人看的创始人邮箱,或者一个退信的别名地址,那才是真正的 bug。

第 1 步:重新启用之前,先查清哪里坏了

忍住马上点 Enable(启用)的冲动。如果根本原因还在,你会第二次耗尽重试窗口。

找原因本身就是一项工作,拖得越久越难,因为失败的投递记录会在你还在查看的时候就从控制台里过期消失。在生产环境排查失败的 Webhook,不用搭上整个周末给出了值得逐步走一遍的排查顺序。

在 Stripe 控制台里进入 Developers → Webhooks(开发者 → Webhook),或者 Workbench → Webhooks,打开这个端点,查看最近的投递记录。Stripe 会记录每次尝试收到的响应。你要找的是下面哪一种:

你看到的含义
Timed out你的处理程序太慢。这是最常见的情况,远超其他原因
500 / 502你的代码抛出了异常,或者它前面的托管平台出了错
503你的应用宕机了,或者缩容到零之后一直没被唤醒
401 / 403你的应用前面有东西拦住了 Stripe。WAF、Cloudflare 规则、认证中间件
404重构时路由换了位置,却没人更新端点 URL

401/403 这种情况,常在加了机器人防护或新的认证层之后让人中招,因为 Webhook 请求不带会话 cookie,会被当作匿名流量。404 这种情况通常出现在框架迁移之后。

先修好这个。然后再启用。

第 2 步:重新启用,并弄清它不会做什么

重新启用后,从那一刻起新产生的事件会恢复投递。已经被放弃的事件不会自动重发。有一个很窄的例外,Stripe 的文档里写了:如果你在 Stripe 下一次重试之前禁用又重新启用,进行中的重试仍会送达。如果端点已经被禁用了一周,就不会有任何事件自己回来。

所以你现在的状态是:新事件在正常流入,历史缺口仍然原样留着。

第 3 步:用 Events API 回填

还有什么能恢复,还能恢复多久3 天的重试警告停用Events API 仍保留全部事件 · 30 天首次失败+30 天,清除重新启用只会恢复新事件的投递。它不会补发任何事件。回填才是恢复手段:GET /v1/events?delivery_success=false要经过幂等的消费方处理,否则会重复履约

这是大家会跳过的一步。Stripe 的 List Events API 提供最近 30 天内的所有事件,并且支持 delivery_success 过滤条件:

curl -G https://api.stripe.com/v1/events \
  -u "$STRIPE_SECRET_KEY:" \
  -d delivery_success=false \
  -d "types[]=payment_intent.succeeded" \
  -d "types[]=customer.subscription.created" \
  -d "types[]=invoice.paid" \
  -d limit=100

这里有几点要注意:

按类型过滤,而不是全部拉取。一个繁忙的账户会产生很多你根本没有处理程序的事件类型。只拉取你真正会处理的那些。

配合自动分页使用 ending_before。传入宕机开始前不久的一个事件的 ID,自动分页就会按时间顺序返回结果。这很重要:customer.subscription.created 应该先于 customer.subscription.updated 处理,而默认顺序是最新的在前。

const missed = stripe.events.list({
  delivery_success: false,
  ending_before: "evt_lastKnownGood",
  types: ["payment_intent.succeeded", "invoice.paid"],
  limit: 100,
});

for await (const event of missed) {
  await handleEvent(event);   // must be idempotent (see below)
}

30 天的边界是硬性的。更早的事件在 API 里已经没有了。如果你的端点五周前就被禁用了,第一周的事件无法通过 Stripe 找回,你只能改用 charges 和 subscriptions 对象来对账,那个下午会难熬得多。

第 4 步:先做到幂等,否则别跑回填

你的回填里可能包含这样的事件:Stripe 已经记了超时,你的处理程序却在之后处理完了;而且你正在手动处理的事件里,有一些 Stripe 可能还在重试。如果 handleEvent 不是幂等的,你会发出两份收据、发放两个许可证密钥,或者把订单发两次货。

最省事的做法,而且确实够用:

INSERT INTO processed_events (stripe_event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (stripe_event_id) DO NOTHING;

影响行数为零,说明你已经见过这个事件,应该停下。能做到的话,把这次插入和副作用放在同一个事务里。如果副作用是一次无法加入事务的外部 API 调用,就先把这一行标记为 processing,完成后再标记为 processed,这样中途崩溃是看得见的,而不是被悄悄地无限重试。

这个问题更完整的版本,包括乱序投递,以及为什么至少一次(at-least-once)是刻意的设计选择,我们写在了同一个 Webhook 收到了两次里。

第 5 步:修好真正的原因

根据我们的经验,端点被禁用几乎从来不是因为处理程序写错了。原因是处理程序太慢。典型写法是这样的:

// The shape that eventually gets your endpoint disabled
export async function POST(req: Request) {
  const event = await verifyStripeSignature(req);
  await db.insertOrder(event);        // 40ms
  await salesforce.sync(event);       // 900ms, sometimes 8s
  await resend.sendReceipt(event);    // 300ms, sometimes times out
  await slack.notify(event);          // 200ms
  return Response.json({ ok: true });
}

在一个清闲的周二,这要 1.5 秒,没人会注意到。等到 Salesforce 哪天下午状态不好,它就会挂住,所有投递同时超时,离端点被禁用只剩几天。出问题的依赖甚至都不是你的。

修复要从结构上改,而不是把超时调大:

export async function POST(req: Request) {
  const event = await verifyStripeSignature(req);
  await queue.publish(event);   // durable write, ~10ms
  return new Response(null, { status: 200 });
}

接收、持久化、应答。Salesforce 同步放到 worker 里做,它可以按自己的节奏重试,不用 Stripe 在旁边掐着表。这样一来,慢的第三方只是一个慢任务,而不是一次投递失败。

另一半是告警。你希望在连续第五次失败时就知道,而不是几天后 Stripe 发邮件告诉你禁用日期时才知道。如果你唯一的监控就是 Stripe 自己的警告邮件,一定要确保它发到有人看的地方。

AnyHook 能做什么

AnyHook 位于 Stripe 和你的服务器之间,让慢的源站不会变成被禁用的端点。Stripe 请求的是 in.anyhook.net/you/stripe,而不是你的应用,不管你的服务器在做什么,我们都约 1 秒返回 200。

  • Stripe 永远看不到超时,所以永远不会走到禁用那一步
  • 每个事件在任何投递尝试之前,都会连同完整的请求头和请求体一起持久化,并做静态加密
  • 重放就是一个按钮。如果你的服务器 14:00 到 16:00 宕机,就重放这个时间段。重放的数据来自你自己的日志,而不是 Stripe 的;一个事件在 Free 套餐下可重放 3 天,Pro 30 天,Scale 90 天。重放失败的事件不计入你的月度配额,不过有每日上限,并且会消耗你的计算额度
  • 连续失败 1 次、5 次和 20 次时触发连续失败告警,第 20 次时自动暂停,避免一次出错的部署演变成重试风暴

实际的区别是:前面有一层转发服务,上面这整套恢复流程就简化为选一个时间范围,然后点重放。见服务器宕机期间的 Webhook 怎么重放。

总结

重新启用端点只是恢复工作的 10%。另外 90% 是在 30 天窗口关闭之前补上缺口,并确保处理程序足够快,让 Stripe 再也没有理由禁用它。如果你的 Webhook 处理程序在请求里直接调用第三方 API,你已经走在这条路上了。只是那个糟糕的下午还没来。

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

别再丢 Webhook 了。

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