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 與移動端購買不會互相覆蓋——釋出前檢查項請參閱上線檢查清單。