💳

Build Log #6:Stripe課金を最速で組む完全テンプレ

に公開

note 版:https://note.com/mintototo1/n/nc7cf608b62e4

Stripe を Next.js に組んだとき、地雷が5箇所あった。全部本番で踏んだ。
コードを5個貼る。コピペで動く。
webhook の raw body から始めないと、後でどうにもならない。

このシリーズ(Build Log)は俺が SaaS を実際に運営しながら踏んだ地雷を全部書く。前回(#5)は X 自動化で凍結した話。今回は課金まわりを全部出す。


数字パネル

  • Stripe 組み込みに費やした時間:最初のプロダクトで 8時間(今は30分)
  • 地雷を踏んだ回数:5回(うち本番1回)
  • webhook リトライで同じ処理が2回走ったバグ:本番で1回経験
  • 使った Stripe 機能:Checkout Session / Customer Portal / Webhook / Subscription

地雷1:webhook の raw body を読まないと署名検証が必ず落ちる

これが一番多いやつ。Next.js の API route はデフォルトで body を parse してから渡す。素直に req.body を使うと Stripe の署名検証が落ちる。Stripe は raw bytes で署名するので、parse 後の JSON から再 stringify しても byte 列が一致しない。

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-04-10' });

export async function POST(req: NextRequest) {
  const body = await req.text(); // ← raw text で読む。json() は絶対使わない
  const sig = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json({ error: 'Webhook Error' }, { status: 400 });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    await handleCheckoutComplete(session);
  }

  if (event.type === 'customer.subscription.deleted') {
    const sub = event.data.object as Stripe.Subscription;
    await handleSubscriptionCanceled(sub);
  }

  return NextResponse.json({ received: true });
}

req.text() で読む。これだけで解決する。でも知らないと「署名エラー」の意味がわからずに詰まる。ドキュメントには書いてあるが、実際に踏まないと理解できない。


地雷2:Checkout Session を作るときに customer を先に作らないとポータルが動かない

Stripe Customer Portal は customer ID があって初めて使える。Checkout 時に customer を渡さないと、支払い後に customer ID が特定できないので後からポータルが開けない。

// lib/stripe.ts
import Stripe from 'stripe';
import { supabase } from './supabase';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-04-10' });

export async function createCheckoutSession(userId: string, email: string) {
  const { data: sub } = await supabase
    .from('subscriptions')
    .select('stripe_customer_id')
    .eq('user_id', userId)
    .maybeSingle();

  let customerId = sub?.stripe_customer_id;

  if (!customerId) {
    const customer = await stripe.customers.create({
      email,
      metadata: { userId },
    });
    customerId = customer.id;

    await supabase.from('subscriptions').upsert({
      user_id: userId,
      stripe_customer_id: customerId,
      status: 'inactive',
    });
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId, // ← 必ず渡す
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  });

  return session.url;
}

先に customer を作って DB に保存する。Checkout に customer を渡す。この2段構えで後から Portal も開ける。


地雷3:webhook で同じイベントを2回処理する

Stripe は webhook を最大3回リトライする。自前の DB 更新が冪等でないと「課金済み」が2回 insert される。

// handleCheckoutComplete(idempotent 版)
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const { data: existing } = await supabase
    .from('stripe_events')
    .select('event_id')
    .eq('event_id', session.id)
    .maybeSingle();

  if (existing) {
    console.log('Already processed:', session.id);
    return;
  }

  await supabase.from('stripe_events').insert({ event_id: session.id });

  const subscriptionId = session.subscription as string;

  await supabase.from('subscriptions').upsert(
    {
      user_id: session.metadata?.userId,
      stripe_customer_id: session.customer as string,
      stripe_subscription_id: subscriptionId,
      status: 'active',
    },
    { onConflict: 'stripe_customer_id' }
  );
}

stripe_events テーブルに event_id を insert して重複チェックを入れる。upsert にしておくと余計に安全。本番でこれを忘れて同じ user の subscription が2行できた。


地雷4:Customer Portal の設定を Stripe ダッシュボードで先に済ませないと動かない

Customer Portal のセッションを API で作っても、Stripe ダッシュボードで Portal の設定(キャンセル可否 / プラン変更 / 請求書表示)を事前に済ませていないと、Portal が「設定がありません」エラーになる。

// app/api/stripe/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-04-10' });

export async function POST(req: NextRequest) {
  const user = await getUser(req);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { data: sub } = await supabase
    .from('subscriptions')
    .select('stripe_customer_id')
    .eq('user_id', user.id)
    .maybeSingle();

  if (!sub?.stripe_customer_id) {
    return NextResponse.json({ error: 'No active subscription' }, { status: 400 });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: sub.stripe_customer_id,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });

  return NextResponse.json({ url: session.url });
}

事前に Stripe ダッシュボード → Billing → Customer portal で設定を保存。「キャンセル可能にする」「プラン変更を許可する」などをここで決める。コードより先に設定。


地雷5:本番 vs テストの env を間違える

STRIPE_SECRET_KEYsk_test_... のまま本番にデプロイするケースがある。テストモードでは実際の課金が走らないので気づかない。

Vercel の環境変数は Preview / Development / Production で分けて管理する。

# Production 用(sk_live_... を使う)
vercel env add STRIPE_SECRET_KEY production

# Preview 用(sk_test_... を使う)
vercel env add STRIPE_SECRET_KEY preview

# Webhook Secret も環境ごとに別
vercel env add STRIPE_WEBHOOK_SECRET production
vercel env add STRIPE_WEBHOOK_SECRET preview

本番 webhook エンドポイントは Stripe ダッシュボードの Production の Webhook から別途登録する。テスト用とは別エンドポイントになる。登録時に発行される whsec_... をそれぞれの環境に設定する。


全体のフロー(改めて)

  1. user が「課金する」ボタンを押す
  2. /api/stripe/checkout で customer 作成 or 取得 → session 作成 → Stripe の決済ページに redirect
  3. 支払い完了 → Stripe が /api/stripe/webhook に POST
  4. checkout.session.completed イベントで DB 更新(idempotent に)
  5. user が解約したいとき → /api/stripe/portal で portal session 作成 → redirect
  6. 解約完了 → Stripe が customer.subscription.deleted イベントで webhook
  7. DB の subscription status を canceled に更新

DB スキーマ(Supabase / PostgreSQL の最小構成)

-- subscriptions テーブル
create table subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  stripe_customer_id text unique not null,
  stripe_subscription_id text,
  status text not null default 'inactive',
  -- inactive / active / canceled / past_due / unpaid
  current_period_end timestamptz,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- idempotency 管理
create table stripe_events (
  event_id text primary key,
  processed_at timestamptz default now()
);

-- RLS
alter table subscriptions enable row level security;
create policy "user can see own subscription"
  on subscriptions for select
  using (auth.uid() = user_id);

-- updated_at 自動更新
create or replace function update_updated_at()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;

create trigger subscriptions_updated_at
  before update on subscriptions
  for each row execute function update_updated_at();

current_period_end を保存しておくと、解約後も期間内はアクセスを許可する「猶予期間」が実装できる。status = 'canceled' and current_period_end > now() ならまだ有効、という判定。


確定ルール(Stripe 実装 10項目)

  1. webhook は req.text() で raw body を読む。req.json() は絶対使わない
  2. Checkout 時に customer を先に作って DB に保存してから session に渡す
  3. stripe_events テーブルで event_id の重複チェックを必ず入れる
  4. DB 更新は upsert にする(insert だと重複でクラッシュする)
  5. Customer Portal は Stripe ダッシュボードで設定を先に済ませてからコードを書く
  6. Portal の return_url を渡す(渡さないと「戻る」が壊れる)
  7. Vercel env は Preview / Production で分けて管理
  8. 本番 webhook エンドポイントは Stripe Production から別途登録する
  9. customer.subscription.deleted イベントも必ず処理して DB に反映する
  10. current_period_end を保存して解約後の猶予期間を制御する

次回(#7)は「Supabase + Vercel + Next.js の地雷15個」。Stripe を組み終わった後に必ず踏む RLS と env の話。


俺が運営してるプロダクト

🎬 VideoTracker — 不動産業者向け動画自動生成 SaaS
動画1本¥596。問合せ倍率の想定値はシミュレーションで2.8倍(実測は検証中)。
https://komugi-ai.jp/realestate

🤖 Mint Agent — Slack で @AI に話しかけて業務代行(近日リリース)
議事録投稿・メール返信・データ集計が Slack 内で完結
→ ベータ Waitlist:https://agent.komugi-ai.jp

業務効率化・SaaS 開発相談 → X DM @mintnekoneko0
過去記事まとめ:https://note.com/mintototo1

Discussion