Claude CodeでStripeサブスクリプションを実装する実務ガイド
Claude CodeでStripeサブスクを実装。Checkout、Webhook、権限管理、解約対応まで実例で解説。
サブスク実装は「決済ボタン」ではなく収益の運用設計
Stripeでサブスクリプションを始めるだけなら、Checkout Sessionを作ってユーザーをStripeにリダイレクトすれば動きます。しかしSaaSや情報商材の収益導線では、それだけでは足りません。支払い失敗、プラン変更、解約予約、請求書、領収書、税、顧客ポータル、Webhookの再送、権限の剥奪まで決めておかないと、売上より先に問い合わせが増えます。
この記事では、Claude Codeを「コードを書くAI」としてではなく、実装の抜け漏れを減らすエージェントの足場として使います。ここでいう足場とは、要件、公式ドキュメント、DB設計、テストコマンド、レビュー観点をまとめ、Claude Codeが迷わない状態にすることです。
対象は、月額課金のミニSaaS、会員制教材、テンプレート販売、導入コンサルの申込導線です。Stripeは請求の正本、アプリ側DBは「このユーザーが今どの機能を使えるか」を判断するための同期先、と分けて考えます。
flowchart LR
A["Pricing page"] --> B["Checkout Session<br/>mode=subscription"]
B --> C["Stripe subscription"]
C --> D["Webhook endpoint"]
D --> E["billing_subscriptions"]
D --> F["entitlements"]
F --> G["App feature gate"]
G --> H["Product, course, SaaS dashboard"]
H --> I["Customer Portal"]
I --> C
料金設計を先に決める
Claude Codeに最初から「Stripeを実装して」と頼むと、コードは出ますが、収益設計が曖昧なまま進みます。先に次の表を決めてから実装します。
| プラン | 想定読者 | 月額例 | 年額例 | 付与する権限 |
|---|---|---|---|---|
| Free | 記事を試す読者 | 0円 | 0円 | 無料記事、サンプル |
| Pro | 個人開発者、教材購入者 | 1,980円 | 19,800円 | 全記事、テンプレート、限定ダウンロード |
| Studio | チーム、法人、継続支援 | 9,800円 | 98,000円 | Pro権限、チーム席、レビュー依頼、研修資料 |
ユースケースは3つ以上で考えると実装の穴が見えます。1つ目はSaaSのProプランです。毎月の利用料でダッシュボード、分析、エクスポートを解放します。2つ目は情報商材ファネルです。無料記事からメール登録、テンプレート、月額会員、個別相談へ進めます。3つ目は研修・コンサル導線です。法人向けにチーム席、請求書、キャンセル理由、支払い失敗時の連絡が重要になります。
Claude Codeへの依頼は公式リンクつきにする
Stripeは機能変更が多いので、Claude Codeには公式ドキュメントを読ませる前提で依頼します。プロンプトの例です。
Stripe Billingで月額サブスクリプションを実装してください。
必ず次の公式ドキュメントを前提にしてください。
- Checkout subscription mode: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhook signatures and local testing: https://docs.stripe.com/webhooks
- Subscription statuses: https://docs.stripe.com/api/subscriptions/object
要件:
- Next.js App Router + TypeScript + Postgres
- Stripe Checkoutはmode="subscription"
- Stripe Customer Portalで支払い方法変更、請求書確認、解約を扱う
- Webhookは署名検証し、イベントIDで重複処理を防ぐ
- アプリ側にentitlementsテーブルを持ち、機能開放をDBで判断する
- active/trialingは開放、past_dueは3日猶予、unpaid/canceled/pausedは停止
- ローカルでstripe listenを使って検証できるREADME断片も出す
entitlementは「権限」や「利用権」のことです。たとえばtemplates:downloadが有効ならテンプレートをダウンロードできる、team:seatsが有効ならチーム席を使える、という判定に使います。dunningは支払い失敗後の回収対応です。カードの失敗、3Dセキュア未完了、請求書未払いなどに対して、メールやポータル誘導で復旧を促します。
DBはStripe状態とアプリ権限を分ける
次のSQLはPostgres向けの最小構成です。billing_subscriptionsはStripeの状態を保存し、entitlementsはアプリが見る権限だけを保存します。Webhookは重複配信されることがあるため、webhook_eventsでイベントIDも記録します。
create table if not exists app_users (
id text primary key,
email text not null unique,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create table if not exists billing_subscriptions (
user_id text primary key references app_users(id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
plan_key text not null,
status text not null check (
status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
),
access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
grace_until timestamptz,
updated_at timestamptz not null default now()
);
create table if not exists entitlements (
user_id text not null references app_users(id) on delete cascade,
feature_key text not null,
active boolean not null default true,
expires_at timestamptz,
updated_at timestamptz not null default now(),
primary key (user_id, feature_key)
);
create table if not exists webhook_events (
event_id text primary key,
event_type text not null,
status text not null default 'processing',
attempts integer not null default 1,
processed_at timestamptz,
last_error text,
created_at timestamptz not null default now()
);
CheckoutとPortalの実装
依存関係はこの程度です。認証は実プロジェクトのAuth.js、Clerk、Supabase Authなどに置き換えてください。
npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!, {
max: 5,
idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
export const PLANS = {
free: {
priceId: null,
features: ["article:preview"],
},
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library"],
},
studio: {
priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
},
} as const;
export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;
export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
return value === "pro" || value === "studio";
}
export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
const customerId = await findOrCreateCustomer(userId);
const plan = PLANS[planKey];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: plan.priceId, quantity: 1 }],
client_reference_id: userId,
allow_promotion_codes: true,
automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${APP_URL}/pricing`,
subscription_data: {
metadata: { userId, planKey },
},
metadata: { userId, planKey },
});
if (!session.url) throw new Error("Stripe did not return a Checkout URL");
return session.url;
}
export async function createPortalSession(userId: string) {
const customerId = await getCustomerId(userId);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${APP_URL}/settings/billing`,
});
return session.url;
}
async function findOrCreateCustomer(userId: string) {
const users = await sql`
select id, email, stripe_customer_id
from app_users
where id = ${userId}
`;
const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
if (!user) throw new Error("User not found");
if (user.stripe_customer_id) return user.stripe_customer_id;
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId },
});
await sql`
update app_users
set stripe_customer_id = ${customer.id}
where id = ${userId}
`;
return customer.id;
}
async function getCustomerId(userId: string) {
const rows = await sql`
select stripe_customer_id
from app_users
where id = ${userId}
`;
const customerId = rows[0]?.stripe_customer_id as string | undefined;
if (!customerId) throw new Error("Stripe customer is not linked");
return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) {
return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
}
const { planKey } = await request.json();
if (!isPaidPlanKey(planKey)) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
const url = await createCheckoutSession(userId, planKey);
return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) {
return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
}
const url = await createPortalSession(userId);
return NextResponse.json({ url });
}
Webhookで権限を同期する
成功画面のリダイレクトはUXのためのものです。機能を開放する根拠はWebhookか、Stripe APIで取得した最新状態にします。Stripeはイベント順序を保証しないため、イベント本文だけで完結させず、必要に応じてSubscriptionを再取得します。
// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";
type AccessState = "pending" | "granted" | "grace" | "revoked";
export async function syncSubscriptionFromStripe(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["items.data.price"],
});
const item = subscription.items.data[0];
const priceId = item?.price.id;
const planKey = planKeyFromPrice(priceId);
const userId = subscription.metadata.userId;
if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);
const accessState = accessStateFor(subscription.status);
const currentPeriodEnd = item?.current_period_end
? new Date(item.current_period_end * 1000)
: null;
const graceUntil = accessState === "grace"
? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
: null;
await sql.begin(async (tx) => {
await tx`
insert into billing_subscriptions (
user_id, stripe_customer_id, stripe_subscription_id, plan_key,
status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
)
values (
${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
${subscription.status}, ${accessState}, ${currentPeriodEnd},
${subscription.cancel_at_period_end}, ${graceUntil}, now()
)
on conflict (user_id) do update set
stripe_customer_id = excluded.stripe_customer_id,
stripe_subscription_id = excluded.stripe_subscription_id,
plan_key = excluded.plan_key,
status = excluded.status,
access_state = excluded.access_state,
current_period_end = excluded.current_period_end,
cancel_at_period_end = excluded.cancel_at_period_end,
grace_until = excluded.grace_until,
updated_at = now()
`;
const features = accessState === "granted" || accessState === "grace"
? PLANS[planKey].features
: PLANS.free.features;
await tx`delete from entitlements where user_id = ${userId}`;
for (const feature of features) {
await tx`
insert into entitlements (user_id, feature_key, active, expires_at, updated_at)
values (${userId}, ${feature}, true, ${graceUntil}, now())
`;
}
});
console.info("stripe.subscription.synced", {
userId,
subscriptionId: subscription.id,
status: subscription.status,
accessState,
planKey,
});
}
export async function hasEntitlement(userId: string, featureKey: string) {
const rows = await sql`
select 1
from entitlements
where user_id = ${userId}
and feature_key = ${featureKey}
and active = true
and (expires_at is null or expires_at > now())
limit 1
`;
return rows.length > 0;
}
function planKeyFromPrice(priceId: string | undefined): PlanKey {
const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
if (!entry) return "free";
return entry[0] as PlanKey;
}
function accessStateFor(status: Stripe.Subscription.Status): AccessState {
if (status === "active" || status === "trialing") return "granted";
if (status === "past_due") return "grace";
if (status === "incomplete") return "pending";
return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";
export async function POST(request: NextRequest) {
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (error) {
console.error("stripe.webhook.signature_failed", error);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const reserved = await reserveEvent(event);
if (!reserved) return NextResponse.json({ received: true, duplicate: true });
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = expandableId(session.subscription);
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
break;
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionFromStripe(subscription.id);
break;
}
case "invoice.paid":
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = subscriptionIdFromInvoice(invoice);
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
break;
}
default:
console.info("stripe.webhook.ignored", { id: event.id, type: event.type });
}
await markEventProcessed(event.id);
return NextResponse.json({ received: true });
} catch (error) {
await markEventFailed(event.id, error);
console.error("stripe.webhook.failed", { id: event.id, type: event.type, error });
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 });
}
}
function expandableId(value: string | { id: string } | null): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
function subscriptionIdFromInvoice(invoice: Stripe.Invoice) {
const subscription = invoice.parent?.subscription_details?.subscription;
return expandableId(subscription ?? null);
}
async function reserveEvent(event: Stripe.Event) {
const rows = await sql`
insert into webhook_events (event_id, event_type, status)
values (${event.id}, ${event.type}, 'processing')
on conflict (event_id) do update set
attempts = webhook_events.attempts + 1,
status = 'processing',
last_error = null
where webhook_events.status <> 'processed'
returning event_id
`;
return rows.length > 0;
}
async function markEventProcessed(eventId: string) {
await sql`
update webhook_events
set status = 'processed', processed_at = now(), last_error = null
where event_id = ${eventId}
`;
}
async function markEventFailed(eventId: string, error: unknown) {
await sql`
update webhook_events
set status = 'failed', last_error = ${error instanceof Error ? error.message : String(error)}
where event_id = ${eventId}
`;
}
ローカルWebhookテスト
ローカルではStripe CLIを使います。stripe listenの出力にあるwhsec_...をSTRIPE_WEBHOOK_SECRETへ入れます。
stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe
Checkoutをブラウザで最後まで通すのが最も実運用に近いテストです。補助的にイベントを発火するなら、次のように使います。
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
ただし、トリガーで作られるテストイベントは、あなたの実際のPrice IDやユーザーIDと一致しない場合があります。最終確認では、テストモードの商品、Price、Checkout、Portal、Webhookを一連で動かしてください。
よくある失敗
1つ目は、成功URLに戻っただけで有料機能を開放することです。ユーザーが戻らない、Webhookが遅れる、支払いに追加認証が必要になる、というケースがあります。成功画面では「処理中」を表示し、WebhookまたはStripe APIの最新状態で開放します。
2つ目は、Checkout SessionのmetadataだけにuserIdを入れることです。SubscriptionやInvoice側で使うならsubscription_data.metadataにも入れておくほうが同期しやすくなります。
3つ目は、past_dueとunpaidを同じ扱いにすることです。past_dueは回収中の状態で、短い猶予を置く判断もあります。unpaidやcanceledでは、通常は有料権限を止めます。猶予期間は事業判断なので、3日、7日、即停止のどれにするかを規約とメール文面に合わせます。
4つ目は、Customer Portalを作ったのにDashboard側の設定をしていないことです。Portalでプラン変更や解約を許可するには、Stripe Dashboardで対象商品や機能を設定します。Portalに任せる範囲と、自社UIで制御する範囲を分けてください。
5つ目は、税と通貨を最後に考えることです。日本円だけなら単純に見えても、海外販売、法人請求、消費税、返金、年額割引が入ると仕様が変わります。Stripe Taxを使う場合も、対象地域や商品分類は人間が確認します。
観測可能性と収益導線
stripe.subscription.syncedのようなログを残すと、問い合わせ対応が速くなります。最低限、Stripe customer ID、subscription ID、plan、status、access_state、event_idを追えるようにします。売上を伸ばす導線では、checkout_started、checkout_completed、portal_opened、payment_failed_notified、entitlement_revokedも分析対象です。計測設計はClaude Codeで分析実装も参考になります。
ClaudeCodeLabで実際にこの流れを試したとき、最も効いたのはコード生成よりも状態表の固定でした。activeなら開放、past_dueなら猶予、unpaidなら停止、という表を先に決めると、Claude Codeへの指示、レビュー、テスト、サポート文面がそろいます。
決済全体の導入はClaude Codeで決済システムを統合する方法、ログインや権限境界はClaude Codeで認証機能を実装するも合わせて読んでください。自社のSaaS、教材、会員サイトに合わせてStripe Billing、権限テーブル、Claude Codeレビュー手順を整えたい場合は、Claude Code研修・導入相談や教材・テンプレート一覧から相談できます。
公式情報は必ず最新を確認してください。特にStripe Checkout Sessions API、Customer Portal、Subscription webhooks、Webhooks、Subscription objectは実装前に再確認する価値があります。Claude Code自体のセットアップはClaude Code公式ドキュメントを参照してください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。