メインコンテンツへスキップ
Think You Lab
ブログログイン無料で始める
トップ/ブログ/how-to
StripeSupabaseWebhookNext.js決済

Supabase + Stripe Webhook で会員ステータスを自動更新する — Next.js App Router 実装

Stripe Webhook を Next.js App Router で実装する手順を解説。THINK YOU LAB 本番稼働中のコードを全公開し、request.text() が必要な理由・constructEvent による署名検証・Stripe SDK v20 での破壊的変更(current_period_start/end の移動)への対応まで説明します。

2026-04-15·約16分

LINE 公式アカウント

AI副業スタートガイドをLINEで無料配布中

目次
  • Stripe Webhook とは何か — なぜ必要か
  • `success_url` で DB を更新してはいけない理由
  • Webhook の仕組み
  • 受け取るべきイベント一覧
  • Next.js App Router での Webhook エンドポイント実装
  • Webhook エンドポイントの全コード
  • `request.text()` で raw body を取得する理由
  • `constructEvent` で署名を検証する
  • イベントごとの DB 更新処理
  • `checkout.session.completed` — memberships を `active` に upsert する
  • `invoice.payment_failed` — `past_due` に更新する
  • なぜ Service Role Key を使うか
  • Stripe ダッシュボードでの Webhook 設定
  • エンドポイント URL の登録
  • `STRIPE_WEBHOOK_SECRET` を環境変数に追加する
  • ローカル開発での Webhook テスト
  • よくある詰まりポイント
  • 署名検証が 400 エラーになる
  • Middleware が Webhook リクエストに干渉する
  • Stripe SDK バージョンと API フィールドの変化(v20 以降の注意点)
  • まとめ

目次

  • Stripe Webhook とは何か — なぜ必要か
  • `success_url` で DB を更新してはいけない理由
  • Webhook の仕組み
  • 受け取るべきイベント一覧
  • Next.js App Router での Webhook エンドポイント実装
  • Webhook エンドポイントの全コード
  • `request.text()` で raw body を取得する理由
  • `constructEvent` で署名を検証する
  • イベントごとの DB 更新処理
  • `checkout.session.completed` — memberships を `active` に upsert する
  • `invoice.payment_failed` — `past_due` に更新する
  • なぜ Service Role Key を使うか
  • Stripe ダッシュボードでの Webhook 設定
  • エンドポイント URL の登録
  • `STRIPE_WEBHOOK_SECRET` を環境変数に追加する
  • ローカル開発での Webhook テスト
  • よくある詰まりポイント
  • 署名検証が 400 エラーになる
  • Middleware が Webhook リクエストに干渉する
  • Stripe SDK バージョンと API フィールドの変化(v20 以降の注意点)
  • まとめ

この記事に書いている実装は、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.completed
  • invoice.payment_failed
  • customer.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/stripe

stripe 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で無料配布中

R

Rikuto (LAB)

非エンジニアが Claude Code × n8n × Supabase で副業システムを作り続ける実験記。 失敗も含めたリアルな一次情報を発信しています。

THINK YOU LAB 運営

関連記事

  • Stripe Checkout を Next.js Server Action で実装する — Supabase Auth 連携つき

    2026-04-15

  • Next.js App Router の middleware で Supabase 認証ガードを作る — 実装コード全公開

    2026-04-15

  • Supabase Auth で magic link ログインを実装する — Next.js App Router 対応

    2026-04-15

← ブログ一覧へ
X でシェアLINE でシェア
← 前の記事Supabase Auth で magic link ログインを実装する — Next.js App Router 対応
次の記事 →Stripe Checkout を Next.js Server Action で実装する — Supabase Auth 連携つき

目次

  • Stripe Webhook とは何か — なぜ必要か
  • `success_url` で DB を更新してはいけない理由
  • Webhook の仕組み
  • 受け取るべきイベント一覧
  • Next.js App Router での Webhook エンドポイント実装
  • Webhook エンドポイントの全コード
  • `request.text()` で raw body を取得する理由
  • `constructEvent` で署名を検証する
  • イベントごとの DB 更新処理
  • `checkout.session.completed` — memberships を `active` に upsert する
  • `invoice.payment_failed` — `past_due` に更新する
  • なぜ Service Role Key を使うか
  • Stripe ダッシュボードでの Webhook 設定
  • エンドポイント URL の登録
  • `STRIPE_WEBHOOK_SECRET` を環境変数に追加する
  • ローカル開発での Webhook テスト
  • よくある詰まりポイント
  • 署名検証が 400 エラーになる
  • Middleware が Webhook リクエストに干渉する
  • Stripe SDK バージョンと API フィールドの変化(v20 以降の注意点)
  • まとめ
Think You Lab
このブログについて料金プランプライバシーポリシーお問い合わせ特定商取引法ログイン

© 2026 Think You Lab