コンテンツへスキップ
すべての記事

Next.js における Stripe と Lemon Squeezy のべき等な Webhook 実装

生の Webhook 署名を検証し、再送された配信をデータベース境界で重複排除し、重複したサブスクリプションが有料アクセスを取り消してしまう事故を防ぐ方法。

supastack チーム約 4 分で読めます

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 シークレットに対して暗号署名を検証し、データベースクエリを 1 つでも実行する前に失敗したリクエストを 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) を使用する。

メモリ内ではなくデータベース境界で重複排除する

Stripe や Lemon Squeezy は、デプロイの再起動、サーバーレス関数のタイムアウト、ネットワーク経路での応答ロストが発生するたびに Webhook を再送します。プロセスローカルな Set や短命なキャッシュキーはインスタンス間で消えてしまうため、2 つの同時リトライが重複したライセンスを発行したり、領収書メールを 2 通送信したりするのを防げません。

サブスクリプションのアクセス権を更新するのと同じ Postgres トランザクション内で、プロバイダーのイベント ID または注文の決済ステータスを記録します。アトミックな INSERT ... ON CONFLICT DO NOTHING や status != 'paid' を条件とした更新により、並行する再送は安全な no-op になります。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 はユーザーがクリックした順序どおりに届くとは限りません。checkout.session.completed より先に customer.subscription.updated が届くこともありますし、誤って 2 つの決済タブを開いた顧客が同じワークスペース内で一時的に 2 つのサブスクリプションを持つこともあります。

解約や決済失敗は、そのイベントを発生させた特定のサブスクリプション ID に紐付けて処理してください。ワークスペースの有料エンタイトルメントを取り消す前に、同じワークスペースをカバーする別の有効なサブスクリプションが残っていないかを確認します。そうしないと、重複分を解約しただけで顧客が支払い続けているアクセス権まで静かに失われてしまいます。

  • サブスクリプションの状態は workspaceId だけでなく providerSubscriptionId をキーにして管理する。
  • 少なくとも 1 つのサブスクリプションが active または trialing である限り、ワークスペースの有料エンタイトルメントを維持する。
  • 買い切り注文を履行する前に、支払額と通貨が期待される SKU と一致していることを検証する。

SaaS 課金 Webhook に関するよくある質問

サンドボックスでの決済テストから本番トラフィックへ移行する際、どのチームでも次の疑問が浮かびます。

  • 決済完了リダイレクトの時点で有料機能を解放してもよいですか? — いいえ。リダイレクト URL はブックマークや共有が可能であり、非同期決済が完了する前に開かれることもあります。検証済み Webhook がアクセス権を付与する間、完了ページはサーバーの状態をポーリングするために使ってください。
  • 重複した Webhook にはどのステータスコードを返すべきですか? — イベントが処理済みであることをデータベースで確認できたら、200 OK を返します。4xx や 5xx を返すと、プロバイダーは再送を続けてしまいます。
  • ローカルで Webhook の再送をテストするにはどうすればよいですか? — 全く同じ署名付きペイロードを 2 回送信する決定論的なユニットテストを書き、ライセンス、エンタイトルメント、トランザクションメールが 1 つだけ生成されることを検証します。
  • モバイルのアプリ内課金は同じモデルにどう統合しますか? — RevenueCat のサーバー通知を同じべき等なエンタイトルメントテーブルにルーティングし、Web とモバイルの購入が互いを上書きしないようにします。リリース前の確認項目はローンチチェックリストを参照してください。

よくある質問

14 件の質問

supastack とは何ですか?

supastack は AI SaaS 製品を構築するためのモジュラー基盤です。共通インフラをまとめて提供するので、製品固有の機能に集中できます。

Web とモバイルに対応していますか?

はい。supastack には Web とモバイル体験の出発点として Next.js アプリと Expo アプリが含まれています。

どの認証が含まれていますか?

Better Auth がメール/パスワード、OTP、ソーシャルログインをカバーします。個人ワークスペースはアカウントと一緒にプロビジョニングされ、初日からレコードが境界内に収まります。

AI はどうサポートされていますか?

supastack には設定可能なマルチプロバイダー AI、使用量追跡、予算、暗号化された自前キー(BYOK)サポートが含まれています。

サブスクリプションはどう管理されますか?

このキット上に構築された製品では、Stripe 課金・サブスクリプション・権限が有料アクセスの基盤になります。プロバイダーの秘密はサーバー側に留まります。

製品固有のコードはどこに置きますか?

製品固有の機能は別のコードベースに置き、製品側が supastack の基本モジュールを設定・統合します。

supastack の価格は?

キットは生涯アップデート付きの一回払いです:シングルスタック 249 ドル(Next.js(Web)、Expo(iOS・Android)、Tauri(macOS・Windows デスクトップ)の各リポジトリから1つ選択)、デュアルスタック 398 ドル(任意の2リポジトリ)、またはフルバンドル 399 ドル(3つのリポジトリすべてと完全なモノレポ)、追加開発者シート 1 席 75 ドル。キットのサブスクリプションはありません。このサイトのチェックアウトはライセンスキーをメールで配信し、GitHub 招待でリポジトリアクセスを提供します。

ワークスペースはどうデータを囲いますか?

ユーザーレコードは workspaceId でスコープされます。認証、権限、AI 使用量、課金イベント、監査エントリは個人ワークスペースの境界内に留まります。

バックグラウンドジョブはどう機能しますか?

Trigger.dev が明示的なジョブランタイムです。JobRunner コントラクトは enqueue・status・cancel のみを約束し、ワークフロー意味論は Trigger.dev 固有で明示的です。

メールとアウトリーチの違いは?

トランザクションメールはタスク完了通知などの製品ワークフローをカバーします。コールドアウトリーチやキャンペーンは製品自身に属し、このキットのメールモジュールの外です。

モバイルの購入はどのように機能しますか?

モバイルでは RevenueCat によるネイティブ購入と復元に対応し、アクセス権をサーバーで検証します。公開前にストアアカウントを設定し、実機テストを完了してください。Web 課金は別に管理します。

AI プロバイダーのキーはどこにありますか?

BYOK 認証情報は暗号化され、サーバー側に留まります。キーはログでサニタイズされ、使用量と予算はワークスペースレベルで可視です。

チェックアウトで使える支払い方法は?

Stripe ホストのチェックアウトはカードを受け付け、Stripe アカウントでウォレットを有効にすれば Alipay と WeChat Pay も提供できます——中国の購入者に便利です。ウォレット認可は非同期で確定するため、決済完了と同時にライセンスページが更新されます。

返金を受けることはできますか?

リポジトリへのアクセス権を取得した後は、supastack は永久にあなたのものとなるため、返金はできません。ですがご安心ください。生涯アップデートを含む完全なソースコードを入手し、ご自身やクライアントのために無制限にプロダクトを構築・公開できます。コードの転売や、リポジトリをスターターキットやテンプレートとして再配布することのみ禁止されています。例外としてローンチキャッシュバックがあります。supastack で作ったプロダクトを実際に公開して共有し、購入から 60 日以内にオーガニックで 5,000 回表示またはオーガニックないいね 50 件に達すると、支払額の 50% が戻ります。詳しくは利用規約をご覧ください。

JA