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_KEY を sk_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_... をそれぞれの環境に設定する。
全体のフロー(改めて)
- user が「課金する」ボタンを押す
-
/api/stripe/checkoutで customer 作成 or 取得 → session 作成 → Stripe の決済ページに redirect - 支払い完了 → Stripe が
/api/stripe/webhookに POST -
checkout.session.completedイベントで DB 更新(idempotent に) - user が解約したいとき →
/api/stripe/portalで portal session 作成 → redirect - 解約完了 → Stripe が
customer.subscription.deletedイベントで webhook - 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項目)
- webhook は
req.text()で raw body を読む。req.json()は絶対使わない - Checkout 時に customer を先に作って DB に保存してから session に渡す
-
stripe_eventsテーブルで event_id の重複チェックを必ず入れる - DB 更新は upsert にする(insert だと重複でクラッシュする)
- Customer Portal は Stripe ダッシュボードで設定を先に済ませてからコードを書く
- Portal の
return_urlを渡す(渡さないと「戻る」が壊れる) - Vercel env は Preview / Production で分けて管理
- 本番 webhook エンドポイントは Stripe Production から別途登録する
-
customer.subscription.deletedイベントも必ず処理して DB に反映する -
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