跳轉到內容
全部文章

在 Next.js 中實現冪等的 Stripe 與 Lemon Squeezy Webhook

如何校驗原始 Webhook 簽名、在資料庫邊界對重試投遞去重,並防止重疊訂閱誤撤消付費訪問許可權。

supastack 團隊約 3 分鐘閱讀

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

常見問題

14 個問題

supastack 是什麼?

supastack 是用於構建 AI 驅動 SaaS 產品的模組化基礎。它把通用基礎設施整合在一起,讓你專注於產品專屬的功能。

它支援 Web 和移動端嗎?

支援。supastack 包含 Next.js 與 Expo 應用,作為 Web 與移動端體驗的起點。

包含哪些認證方式?

Better Auth 覆蓋郵箱/密碼、OTP 與社交登入。個人工作區隨賬戶一起開通,產品記錄從第一天起就有邊界。

AI 是如何支援的?

supastack 包含可配置的多供應商 AI、用量追蹤、預算,以及加密的自帶金鑰(BYOK)支援。

訂閱如何處理?

對於基於該套件構建的產品,Stripe 計費、訂閱與許可權構成付費訪問的基礎。供應商金鑰僅存於服務端。

我的產品專屬程式碼放在哪裡?

產品專屬功能保留在獨立的程式碼庫中,產品通過配置接入 supastack 的基礎模組。

supastack 如何定價?

套件為一次性購買、終身更新:單棧 249 美元(Next.js(Web)、Expo(iOS 與 Android)、Tauri(macOS 與 Windows 桌面)倉庫任選其一),雙棧 398 美元(任選兩個倉庫),或完整套裝 399 美元(全部三個倉庫與完整 monorepo),每個額外開發者席位 75 美元。套件本身沒有訂閱。本站結賬後通過郵件傳送許可證金鑰,倉庫訪問通過 GitHub 邀請授予。

工作區如何界定我的資料?

使用者記錄按 workspaceId 劃定邊界。身份、許可權、AI 用量、計費事件與審計條目都留在個人工作區邊界內。

後臺任務如何工作?

Trigger.dev 是明確的任務執行時。JobRunner 契約只承諾 enqueue、status 與 cancel——工作流語義保持 Trigger.dev 專有且顯式。

郵件與營銷觸達有何不同?

事務性郵件覆蓋產品工作流,如任務完成通知。冷觸達與營銷活動屬於產品自身的範疇,不在本套件的郵件模組內。

移動端購買如何運作?

移動端已支援 RevenueCat 原生購買和恢復購買,訪問許可權由服務端驗證。釋出前需配置商店賬戶並完成裝置測試。Web 計費保持獨立。

AI 供應商金鑰存放在哪裡?

自帶金鑰憑證經過加密且僅存於服務端。金鑰在日誌中被脫敏;用量與預算在工作區級別保持可見。

結賬支援哪些付款方式?

Stripe 託管結賬接受銀行卡,在 Stripe 賬戶啟用後還可提供支付寶與微信支付——方便中國買家。錢包授權為非同步確認,付款完成後許可證頁面會立即更新。

我能申請退款嗎?

獲得程式碼倉庫訪問許可權後,supastack 將永久歸你所有,因此無法退款。但請放心,你將獲得包含終身更新的完整原始碼,可為你自己或客戶構建併發布無限數量的產品。你只需注意不能轉賣程式碼,也不能將倉庫作為啟動模板二次分發。唯一的例外是上線返現——上線一款用 supastack 打造的真實產品並公開分享,在購買後 60 天內達到 5,000 次自然瀏覽或 50 個自然按讚,即可退還已付款項的 50%;詳見條款。

TW