现在很多独立 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 在第五秒超时,然后重试。第一个小时内这会发生大约十次,每一次都让你的处理程序再跑六秒,而面对的正是那个本来就是问题根源的数据库。
把它和我们的 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 上平稳运行了两年的处理程序,到这里不会自动没问题,而沙盒环境也不会是告诉你这一点的那个东西。