How do I handle Stripe and Lemon Squeezy webhooks idempotently in Next.js?
It depends on your billing providers, but the core architecture is identical: verify the raw payload signature before parsing JSON, normalize provider events into a shared internal contract, and enforce idempotency at the database boundary using the provider's event or order ID. Never grant product entitlements from a client-side checkout redirect; let the verified webhook transactionally update subscription state and entitlements once. supastack prewires this pipeline for Stripe, Lemon Squeezy, and mobile RevenueCat, though single-plan apps can start with a simpler handler.
在解析 JSON 之前校验原始请求体
每个支付服务商都会对网络传输的原始字节序列进行签名。如果路由处理器先调用 await request.json() 解析再重新序列化对象去验签,键顺序或空白字符的微小差异都会导致合法的 Webhook 验签失败——甚至诱使开发者在预发环境中跳过签名校验。
在 Next.js App Router 的 POST 处理器中用 await request.text() 一次性读取原始请求体,使用仅限服务端访问的 Webhook 密钥校验加密签名,并在执行任何数据库查询之前以 HTTP 400 拒绝验签失败的请求。计费模块指南展示了 supastack 如何将各服务商的签名校验隔离在统一的 BFF 入口之后。
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
await handleNormalizedBillingEvent(event);
return Response.json({ received: true });
}- 使用
request.text()读取原始请求体——切勿对已解析的 JSON 重新序列化。 - 将
STRIPE_WEBHOOK_SECRET和 Lemon Squeezy 签名密钥严格保留在服务端配置中。 - 手动校验 HMAC 签名时,使用恒定时间比较(
crypto.timingSafeEqual)。
在数据库边界去重,而不是在内存中去重
每当部署重启、Serverless 函数超时或网络丢包导致响应丢失时,Stripe 和 Lemon Squeezy 都会重发 Webhook。进程内存中的 Set 或短生命周期缓存键在多实例之间并不共享,无法阻止两个并发重试发放重复许可证或发送两封收据邮件。
在修改订阅访问权限的同一个 Postgres 事务内记录服务商事件 ID 或订单结算状态。原子的 INSERT ... ON CONFLICT DO NOTHING 或针对 status != 'paid' 的条件更新,能将并发重放安全地转化为无副作用的空操作。在设计这些 Webhook 所履约的套餐时,可以先用 SaaS 定价页面生成器 规划套餐档位与席位规则。
const settled = await db.transaction(async (tx) => {
const [inserted] = await tx
.insert(billingWebhookEvents)
.values({ providerEventId: event.id, provider: "stripe" })
.onConflictDoNothing({ target: billingWebhookEvents.providerEventId })
.returning({ id: billingWebhookEvents.id });
if (!inserted) return false; // Already processed
await applyWorkspaceEntitlement(tx, event);
return true;
});处理乱序事件与重叠订阅
Webhook 并不保证按照用户点击的顺序到达。customer.subscription.updated 可能先于 checkout.session.completed 抵达,而不小心打开两个结账标签页的客户也可能在同一个工作区中短暂拥有两份订阅。
将取消和扣款失败限定在触发该事件的特定订阅 ID 上。在撤销工作区的付费权益之前,先检查是否还有另一个活跃订阅覆盖该工作区——否则取消重复订阅会悄无声息地剥夺客户仍在付费的访问权限。
- 按
providerSubscriptionId而非仅按workspaceId记录订阅状态。 - 只要工作区至少有一个订阅处于
active或trialing状态,就保持其付费权益有效。 - 在履约一次性订单之前,校验实付金额与币种是否与预期 SKU 完全一致。
SaaS 计费 Webhook 常见问题
每当团队将计费从沙盒测试切换到真实生产流量时,都会遇到以下问题。
- 可以在结账成功跳转页直接解锁付费功能吗? — 不可以。跳转 URL 可以被收藏、分享,或在异步支付方式完成结算前被访问。应当让成功页面轮询服务端状态,由经过验签的 Webhook 真正授予访问权限。
- 重复的 Webhook 应该返回什么状态码? — 一旦数据库确认该事件已处理完毕,就返回
200 OK。返回4xx或5xx会让支付服务商持续重试。 - 如何在本地测试 Webhook 重试? — 编写确定性的单元测试,连续两次发送完全相同的签名载荷,并断言只生成了一份许可证、一条权益记录或一封事务邮件。
- 移动端应用内购买如何融入同一模型? — 将 RevenueCat 服务端通知路由到同一张幂等权益表中,确保 Web 与移动端购买不会互相覆盖——发布前检查项请参阅上线检查清单。