Webhook2026年8月6日8 分钟阅读

Paddle 两天内尝试 60 次,Stripe 则拉开间隔

记录商家(MoR)的 Webhook 回调响应时限更短,重试曲线更密。独立 SaaS 迁到 Paddle 或 Lemon Squeezy 时,哪些地方要改。

Paddle · 约 2 天内最多 60 次尝试,第一个小时约 10 次第一个小时约 2 天3 天Stripe · 指数退避,最长 3 天一个只是慢了些的处理程序,在一边被猛砸,在另一边只是被轻轻试探
窗口更短,压力却完全不同。Paddle 把约 10 次尝试集中在第一个小时。

现在很多独立 SaaS 都跑在记录商家(Merchant of Record)上。Paddle 和 Lemon Squeezy 替你处理销售税、增值税(VAT)和拒付,对一个不想研究欧盟 VAT 门槛的独立创始人来说,这确实是笔划算的交易。

它们的 Webhook 和 Stripe 的并不一样,而这些差异只会在高负载下暴露出来。如果你把 Stripe 的处理程序搬过来,只改了签名验证,那有两个数字值得了解。

TL;DR

  • Paddle 要求五秒内返回 200;Stripe 根本没有公布数字
  • Paddle 正式环境在约 2 天内最多尝试 60 次,第一个小时约 10 次,第一天约 45 次
  • Paddle 沙盒环境的行为完全不同:约 2 分钟内尝试 3 次
  • Lemon Squeezy 用 X-Signature 签名,这是对原始请求体计算的 HMAC-SHA256 十六进制摘要;它的控制台支持手动重发
  • 慢的处理程序在这里受到的惩罚比在 Stripe 上重得多,因为 Paddle 的重试从间隔一分钟开始,之后才慢慢拉开

写进文档的五秒

Stripe 从不公布具体数字,社区一般按二十秒左右来做。Paddle 公布了:你的端点要“在收到请求后五秒内返回 200”。

五秒让 Paddle 和 Shopify 归为一类,而不是和 Stripe 一类,这大约只有你编写 Stripe 处理程序时所依据时限的四分之一。一个履约处理程序,要发放许可证密钥、写数据库、再通过第三方 API 发欢迎邮件,可以轻松控制在二十秒以内,却远远超出五秒。

能扛住的结构和在其他地方一样:验证、持久化、应答,之后再处理。

export async function POST(req: Request) {
  const raw = await req.text();                       // raw bytes, before parsing
  if (!verifyPaddleSignature(raw, req.headers.get("paddle-signature"))) {
    return new Response("unauthorized", { status: 401 });
  }

  await queue.publish(raw);                           // durable, ~10ms
  return new Response(null, { status: 200 });         // inside 5s
}

真正的区别在重试曲线

Paddle 正式环境的计划是:“我们在约两天内总共最多尝试 60 次。约 10 次尝试发生在第一个小时内,第一天内约 45 次。”

Stripe 用指数退避,把少数几次尝试分散在最长三天里。Paddle 的等待从一分钟开始,增长得很慢,所以它的六十次尝试大部分落在前期。

Paddle · 约 2 天内最多 60 次尝试,第一个小时约 10 次第一个小时约 2 天3 天Stripe · 指数退避,最长 3 天一个只是慢了些的处理程序,在一边被猛砸,在另一边只是被轻轻试探

想想这对一个没坏、只是慢的处理程序意味着什么。因为数据库正好卡了一下,它要花六秒。Paddle 在第五秒超时,然后重试。第一个小时内这会发生大约十次,每一次都让你的处理程序再跑六秒,而面对的正是那个本来就是问题根源的数据库。

把它和我们的 Webhook 平台参考里的其他发送方放在一起看,Paddle 是个异类。指数退避的存在,是为了给陷入困境的系统留出恢复的空间。Paddle 也把自己的计划称为指数退避,但这样集中在前期的曲线,恰恰在你的端点最扛不住的时候施加最大的压力。Paddle 这样做并非不讲道理,因为对计费事件来说,你本来就希望投递足够执着;但这确实意味着,慢处理程序的代价累积得比同样的处理程序在 Stripe 上快得多。

这也意味着,一个不幂等的处理程序可能在一小时内把同一件事做大约十次,而不是一天内做几次。如果这件事是“发放许可证”或“发送收据”,你的客户会注意到。

沙盒环境不会让你看到这些

“对于沙盒账号,我们总共尝试 3 次,全部在约两分钟内完成。”

大约两分钟内尝试三次,和大约两天内尝试六十次是两套完全不同的系统。你在沙盒环境里验证过的每一个时间假设,到了生产环境都是错的,而且错在偏安全的方向,这是最糟的一种错。你的处理程序会一直看起来没问题,直到它开始处理真实的钱。

如果你想测试真正要紧的重试行为,就在正式环境中让一个测试用的通知目标地址故意返回 500,然后观察第一个小时。

签名验证

Paddle 用 Paddle-Signature 请求头签名,其中带有时间戳和 H1 签名,大体上和 Stripe 的 t=/v1= 方案属于同一类。Lemon Squeezy 用 X-Signature 签名,它是用你的 Webhook 签名密钥对原始请求体计算的 HMAC-SHA256 十六进制摘要。

两者都有所有平台共有的那个坑:HMAC 是对到达的原始字节计算的。先解析 JSON 再重新序列化,摘要就变了,而日志里的请求体看起来完全正确。这是所有平台上最常见的 Webhook bug,各平台的具体机制见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。

import crypto from "node:crypto";

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

timingSafeEqual 之前的长度检查很重要,因为长度不一致时它会抛错,而不是返回 false,而攻击者发来的畸形请求头不应该导致 500。

在 Next.js App Router 上,最先执行 await req.text()。更广泛的 Serverless 场景,见在 Vercel 上接收 Webhook,不让事件丢失,其中讲到了会悄悄读掉请求体流的 body parser。

幂等性,以及记录商家带来的一个麻烦

Paddle 会发送一个可以作为键的事件标识符,做法就是常见的唯一约束:

INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'paddle') ON CONFLICT (event_id) DO NOTHING;

影响零行,说明你已经见过它了。返回 200,而不是报错,否则你就造出了一个带六十次尝试的循环。

记录商家特有的麻烦是:同一个客户的订阅生命周期事件可能接连到达,而一次重试就可能打乱它们的顺序。一个一小时后重试的 subscription.updated,可能在 subscription.canceled 之后才到,把订阅又改回生效状态。去重帮不上忙,因为两个事件是不同的,而且都合法。你需要一个以事件自身时间戳为依据的版本校验,这和同一个 Webhook 收到了两次里描述的模式相同。

考虑到 Paddle 的重试密度,一个过时的事件晚于较新事件到达的机会,比在 Stripe 上多得多。在这里,这不是一个理论上的担忧。

从 Stripe 迁过来:要改什么

把处理程序的时间预算缩减到四分之一。凡是不属于“验证、持久化、应答”的工作,都挪到 worker 里。

假定重复是常态,而不是罕见情况。一小时大约十次尝试,会改变你对幂等需要做到多严谨的估算。

给所有有生命周期的东西加上版本校验。订阅尤其如此。

不要按沙盒环境来调优。它的重试行为和生产环境完全不同。

保留原始请求体的日志。Lemon Squeezy 的控制台可以手动重发,Paddle 也保留投递历史,但两者都是平台的副本,遵循平台的保留期,也都回答不了“我们的处理程序到底拿它做了什么”。

AnyHook 能做什么

五秒是传输层面的约束,六十次尝试是传输层面的行为。一旦由别的东西来接住这个连接,两者就都不再作用于你的代码。

把通知目标地址指向 in.anyhook.net/you/paddle。AnyHook 在边缘节点完成验证和持久化,约 1 秒返回 200,所以五秒的时限由一个只做两件事的 Worker 来满足,而六十次尝试的曲线根本不会启动,因为没有什么需要重试。

  • 按套餐不同,并以计算额度为限,你的处理程序有 60 到 300 秒时间,慢的数据库也不会再每隔几分钟招来一次重试
  • 每个事件在投递之前就已保存,并做了静态加密,所以重放一段出问题的部署窗口,只需选中再点一下
  • 对你端点的投递按指数退避重试,这才是适合一个正在恢复的系统的曲线
  • 连续失败 1 次、5 次和 20 次时触发告警,连续失败 20 次时自动暂停投递,坏掉的处理程序不会被反复猛击

你的处理程序仍然需要幂等和版本校验,因为重试和重放都存在。

总结

对独立 SaaS 来说,记录商家计费是笔划算的交易,而它的 Webhook 约定比你多半正在迁出的那个更严格。五秒,约两天内最多六十次尝试,大部分集中在前期。基于原始字节验证,先应答再干活,按平台的事件 ID 去重,并用事件自身的时间戳守住订阅的写入。

在 Stripe 上平稳运行了两年的处理程序,到这里不会自动没问题,而沙盒环境也不会是告诉你这一点的那个东西。

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

别再丢 Webhook 了。

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