この記事に書いている実装は、THINK YOU LAB の本番環境で実際に動いているコードをそのまま公開したものです。特に Stripe SDK v20 以降での API フィールドの移動(current_period_start/end や invoice.subscription の位置変更)は公式ドキュメントに記載が薄く、実際にはまった点として詳細に解説します。
Stripe Webhook とは何か — なぜ必要か
success_url で DB を更新してはいけない理由
Stripe Checkout が完了すると、ユーザーは success_url(例: /payment/success)にリダイレクトされます。「リダイレクトされたら決済成功 → DB を更新しよう」と思いたくなりますが、これはやってはいけない実装です。
理由1: URL 改ざんリスク
success_url は単なるリダイレクト先の URL です。?session_id=xxx パラメータがついていますが、ユーザーが直接このURLにアクセスすることも、パラメータを書き換えることも技術的に可能です。
理由2: 二重登録・タイミングの問題
ブラウザのリロード、バックボタンの操作、ネットワーク障害によるリトライなど、同一の success_url に複数回アクセスされる可能性があります。
Stripe が推奨するのは Webhook による非同期更新です。 Stripe は決済が完了したことを確認した後、サーバーサイドの Webhook エンドポイントに POST リクエストを送ります。このリクエストには Stripe が発行した署名(stripe-signature ヘッダー)が付いており、不正なリクエストを拒否できます。
Webhook の仕組み
決済完了
↓
Stripe のサーバーが /api/webhooks/stripe に POST
↓
署名検証(constructEvent)
↓
イベント種別に応じて DB 更新
↓
200 OK を返す(200 を返さないと Stripe が再試行する)
受け取るべきイベント一覧
THINK YOU LAB では以下の3イベントを処理しています。
| イベント | 意味 | 処理 |
|----------|------|------|
| checkout.session.completed | 決済成功 | memberships を active に upsert |
| invoice.payment_failed | 月次請求の失敗 | memberships を past_due に更新 |
| customer.subscription.deleted | サブスク解約 | memberships を canceled に更新 |
Checkout セッション作成の実装はこちら → Stripe Checkout を Next.js Server Action で実装する
Next.js App Router での Webhook エンドポイント実装
Webhook エンドポイントの全コード
// app/api/webhooks/stripe/route.ts
import { getStripe } from "@/lib/stripe";
import { createClient } from "@supabase/supabase-js";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
export const dynamic = "force-dynamic";
function createServiceClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}
export async function POST(request: Request) {
const stripe = getStripe();
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const supabase = createServiceClient();
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.user_id;
const planId = session.metadata?.plan_id;
if (!userId || !planId) break;
const sub = session.subscription
? await stripe.subscriptions.retrieve(session.subscription as string)
: null;
// current_period_start/end は v20 では SubscriptionItem に移動
const firstItem = sub?.items?.data[0];
await supabase.from("memberships").upsert(
{
user_id: userId,
plan_id: planId,
status: "active",
stripe_subscription_id: sub?.id ?? null,
stripe_customer_id: session.customer as string ?? null,
current_period_start: firstItem
? new Date(firstItem.current_period_start * 1000).toISOString()
: null,
current_period_end: firstItem
? new Date(firstItem.current_period_end * 1000).toISOString()
: null,
updated_at: new Date().toISOString(),
},
{ onConflict: "user_id,plan_id" }
);
await supabase.from("event_logs").insert({
user_id: userId,
event_name: "payment_complete",
props: { plan_id: planId },
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
// v20: subscription は parent.subscription_details.subscription に移動
const subscriptionId =
invoice.parent?.type === "subscription_details"
? (invoice.parent.subscription_details?.subscription as string | null)
: null;
if (!subscriptionId) break;
await supabase
.from("memberships")
.update({ status: "past_due", updated_at: new Date().toISOString() })
.eq("stripe_subscription_id", subscriptionId);
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await supabase
.from("memberships")
.update({ status: "canceled", updated_at: new Date().toISOString() })
.eq("stripe_subscription_id", sub.id);
break;
}
default:
break;
}
return NextResponse.json({ received: true });
}request.text() で raw body を取得する理由
const body = await request.text();request.json() を使ってはいけません。
Stripe の署名検証(constructEvent)は、受け取った body をバイト列そのまま使って署名を計算します。request.json() を呼ぶと body が一度 JavaScript オブジェクトに変換され、再度文字列化したときに改行やスペースの位置が変わる可能性があります。それだけで署名検証が失敗します。
また、export const dynamic = "force-dynamic" を設定しています。これがないと body が空になるケースがあります。
constructEvent で署名を検証する
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);STRIPE_WEBHOOK_SECRET は Stripe ダッシュボードの Webhook 設定画面から取得できる「署名シークレット」(whsec_ で始まる文字列)です。
署名検証を外してはいけません。 検証なしで処理を続けると、誰でもエンドポイントに POST して DB を書き換えられます。
イベントごとの DB 更新処理
checkout.session.completed — memberships を active に upsert する
Stripe SDK v20 での current_period_start/end の取得方法
// v19 以前: sub.current_period_start で直接取得できた
// v20 以降: SubscriptionItem(firstItem)に移動
const firstItem = sub?.items?.data[0];
current_period_start: firstItem
? new Date(firstItem.current_period_start * 1000).toISOString()
: null,これは Stripe SDK v20 での破壊的変更です。 v19 以前のコードをそのまま使っていると、current_period_start が undefined になります。
また Unix タイムスタンプ(秒)を JavaScript の Date に変換するには * 1000 が必要です。
upsert の設定
{ onConflict: "user_id,plan_id" }同一ユーザー・同一プランの組み合わせが既に存在する場合は UPDATE になります。Webhook の再試行(Stripe はエラー時に最大3日間リトライします)でも安全に動作します。
invoice.payment_failed — past_due に更新する
これも Stripe SDK v20 での破壊的変更です。 v19 以前では invoice.subscription で直接 subscription ID を取得できました。v20 以降では:
const subscriptionId =
invoice.parent?.type === "subscription_details"
? (invoice.parent.subscription_details?.subscription as string | null)
: null;invoice.parent.subscription_details.subscription に移動しました。
なぜ Service Role Key を使うか
Webhook エンドポイントには「ログインしているユーザー」が存在しません。Stripe のサーバーからのリクエストです。Anon Key + RLS では任意の user_id の memberships を更新できないため、Service Role Key を使って RLS をバイパスします。
Supabase RLS で memberships を保護する → Supabase RLS の書き方入門
Stripe ダッシュボードでの Webhook 設定
エンドポイント URL の登録
Stripe ダッシュボード → 「開発者」→「Webhook」→「エンドポイントを追加」
本番 URL を登録します:
https://think-you-lab.vercel.app/api/webhooks/stripe
受信するイベントに以下の3つを追加します:
checkout.session.completedinvoice.payment_failedcustomer.subscription.deleted
STRIPE_WEBHOOK_SECRET を環境変数に追加する
エンドポイントを作成すると「署名シークレット」(whsec_ で始まる文字列)が発行されます。これを Vercel の環境変数 STRIPE_WEBHOOK_SECRET に追加して Redeploy します。
ローカル開発での Webhook テスト
# Stripe CLI のインストール(macOS)
brew install stripe/stripe-cli/stripe
# Stripe にログイン
stripe login
# ローカルのエンドポイントに転送する
stripe listen --forward-to localhost:3000/api/webhooks/stripestripe listen を実行すると、ローカル用の STRIPE_WEBHOOK_SECRET が表示されます。これを .env.local に設定してください。
# テストイベントを発火
stripe trigger checkout.session.completedよくある詰まりポイント
署名検証が 400 エラーになる
原因: request.json() を request.text() の前に呼んでしまっている
Request オブジェクトの body は一度しか読めません。必ず const body = await request.text() を最初に呼んでください。
Middleware が Webhook リクエストに干渉する
// proxy.ts(middleware)の matcher から除外することが必要
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)",
],middleware が Webhook リクエストを処理すると body が消費される可能性があります。
Stripe SDK バージョンと API フィールドの変化(v20 以降の注意点)
| フィールド | v19 以前 | v20 以降 |
|-----------|----------|----------|
| サブスク期間開始 | subscription.current_period_start | subscriptionItem.current_period_start |
| サブスク期間終了 | subscription.current_period_end | subscriptionItem.current_period_end |
| Invoice のサブスク ID | invoice.subscription | invoice.parent.subscription_details.subscription |
npm list stripe でバージョンを確認し、v20 以降を使っている場合はこの記事のコードを参照してください。
まとめ
-
success_urlで DB を更新してはいけない理由(改ざん・二重決済リスク) -
request.text()で raw body を取得する理由 -
constructEventによる署名検証の実装 -
Stripe SDK v20 での破壊的変更への対応
-
Service Role Key が必要な理由
-
Checkout セッション作成はこちら → Stripe Checkout を Next.js Server Action で実装する
-
Vercel への環境変数の設定方法はこちら → Vercel に Next.js をデプロイする手順
決済後に会員ステータスが更新されないバグを防ぐために、Supabase × Stripe Webhook 実装コード(エラーハンドリング込み)を LINE で無料配布中です。
→ Supabase × Stripe Webhook 実装コードを受け取る(LINE登録・無料)
LINE 公式アカウント
Stripe × Supabase 実装コード一式・migration SQLをLINEで無料配布中