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 とモバイルの購入が互いを上書きしないようにします。リリース前の確認項目はローンチチェックリストを参照してください。