Stripe の Dashboard に「最初の課金成功」通知が届いたとき、思わず声が出てしまいました。Claude Code と2週間格闘しながら実装したサブスクリプションシステムが、本番でようやく動き出した瞬間でした。
構築したのは、月額プランと永久ライセンスを両方扱えるSaaSの課金システムです。Stripe Checkout → Webhook → Cloudflare KV → Cookie という一連のフローを、Cloudflare Workers 環境でゼロから動かすまでに、なかなかのデバッグ時間を費やしました。
ここではその過程で学んだことをすべて書き出しています。公式ドキュメントには書かれていないハマりポイントや、Claude Code と一緒に設計を固めていくプロセスも含めて、できるだけ正直に記録しています。
課金フロー全体を設計してから実装を始める理由
Stripe の実装でよくある失敗が、「とりあえず Checkout を動かしてから考える」というアプローチです。私自身、最初はそうやって実装を始めてしまいました。
結果、Webhook の処理ロジックと権限管理の設計が後から追加した形になり、コードが複雑に絡み合ってしまいました。Claude Code に「このコードをリファクタリングしてほしい」と頼んだところ、「設計から見直した方が早いです」と返ってきたのが正直なところです。
実装を始める前に、以下の6ステップのフローを確認しておくことをお勧めします。
- フロントエンドの購入ボタン →
/api/checkout API
/api/checkout → Stripe Checkout セッション生成 → ユーザーをリダイレクト
- Stripe Checkout での支払い →
checkout.session.completed Webhook 発火
- Webhook ハンドラー → Cloudflare KV への権限情報書き込み
- 支払い完了後リダイレクト →
/api/verify-session でのCookie発行
- Cookie → コンテンツへのアクセス制御
この6ステップを先に整理しておくと、実装中に「今どこを作っているのか」が常に明確になります。Claude Code に「このフロー図を見て、実装の順番と依存関係を整理して」と投げると、どの部分を先に実装すべきかを具体的に提案してくれます。
Claude Code に設計レビューを頼む価値
実装を始める前の段階で、Claude Code との対話が特に効果を発揮します。「この課金フローで設計上のリスクはあるか?」と質問すると、べき等性の問題や Race Condition のリスクを指摘してくれます。
課金システムは一度動き始めると変更しにくい部分が多いため、設計段階でのレビューが後々の修正コストを大幅に下げます。
pricing.ts による課金プランの一元管理
課金プランの価格・Stripe の priceId・UI 表示テキストは、必ず1ファイルで管理することをお勧めします。各コンポーネントに直書きし始めると、価格変更のたびに全ファイルを修正することになります。
// src/config/pricing.ts
// 全 priceId を一元管理
// as const で型安全にしつつ、ヘルパー関数でアクセスする設計
export const STRIPE_PRICE_IDS = {
ja: {
tip: "price_xxxxxxxxxxxx", // ¥150
article: "price_yyyyyyyyyyyy", // ¥250(記事単体)
pro: "price_zzzzzzzzzzzz", // ¥580/月(月額)
premium: "price_wwwwwwwwwwww", // ¥2,480(永久ライセンス)
},
en: {
tip: "price_aaaaaaaaaaaa",
article: "price_bbbbbbbbbbbb",
pro: "price_cccccccccccc",
premium: "price_dddddddddddd",
},
} as const;
// as const 型の落とし穴を回避するヘルパー関数
// string 型の locale で直接インデックスアクセスはできないため
export const getPriceIds = (locale: string) => {
const localeKey = locale === "ja" ? "ja" : "en";
return STRIPE_PRICE_IDS[localeKey];
};
// SingleArticleCTA のテキストも同じファイルで管理
export const ARTICLE_LABELS = {
ja: {
heading: "この記事の全文を読む",
description: "詳細な実装コードと解説の続きをご覧ください",
button: "¥250 で購入",
separator: "または",
membershipText: "月額メンバーシップで全記事を読み放題",
},
en: {
heading: "Read the Full Article",
description: "Access the complete implementation code and detailed explanations",
button: "Buy for $1.75",
separator: "or",
membershipText: "Get unlimited access with a monthly membership",
},
};
as const の落とし穴
STRIPE_PRICE_IDS を as const で定義すると、string 型の locale 変数でインデックスアクセスができずエラーになります。
// ❌ これは TypeScript エラー(string で as const 型にアクセス不可)
const priceId = STRIPE_PRICE_IDS[locale].premium;
// ✅ ヘルパー関数を経由してアクセスする
const priceId = getPriceIds(locale).premium;
// ✅ サーバーコンポーネントで使う場合は、明示的な型注釈付きで再宣言
const PRICE_IDS: Record<string, { tip: string; article: string; pro: string; premium: string }> = STRIPE_PRICE_IDS;
const priceId = PRICE_IDS[locale]?.premium;
Claude Code に「as const で定義した型を locale 変数でインデックスアクセスしたい」と相談すると、上記のどちらのパターンが適切かを使用場所に応じて提案してくれます。
Stripe Checkout セッションの実装 — product_data と metadata の設計
最初に詰まりやすいのが、Checkout ページに商品画像や説明文が表示されない問題です。priceId だけを渡す実装では表示されません。price_data + product_data 方式が必要です。
// src/app/api/checkout/route.ts
import Stripe from "stripe";
import { getCloudflareContext } from "@opennextjs/cloudflare";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, {
apiVersion: "2024-11-20.acacia",
});
export async function POST(request: Request) {
const { locale, planType, articleSlug } = await request.json();
// plan_type を metadata に必ず設定する
// verify-session での処理分岐(tip/article/pro/premium)に使用するため省略不可
const metadata: Record<string, string> = {
plan_type: planType,
locale,
};
if (articleSlug) {
metadata.article_slug = articleSlug;
}
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: [
{
// price_id だけだと商品説明・画像が Checkout ページに表示されない(Stripe の仕様)
// price_data + product_data 方式が必須
price_data: {
currency: locale === "ja" ? "jpy" : "usd",
unit_amount: getUnitAmount(planType, locale), // ¥250 なら 250、$1.75 なら 175
product_data: {
name: getProductName(planType, locale),
description: getProductDescription(planType, locale),
// 画像がないと説明文も表示されにくい(Stripe の未文書化の挙動)
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/images/stripe-product.png`],
},
// Pro プランのみ recurring を追加(これがないと単発課金になる)
...(planType === "pro" && {
recurring: { interval: "month" },
}),
},
quantity: 1,
},
],
mode: planType === "pro" ? "subscription" : "payment",
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/${locale}/api/verify-session?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/${locale}/membership`,
metadata,
});
return Response.json({ url: session.url });
}
metadata.plan_type の重要性
plan_type を "tip" / "article" / "pro" / "premium" で区別しないと、verify-session での処理分岐ができなくなります。チップに Premium 権限を付与してしまうバグが起きやすい箇所です。
Stripe Checkout の metadata は文字列のみを受け付けるため、複雑なオブジェクトは JSON 文字列化してから渡してください。
ロケール対応と日英分離
日本語と英語で商品名・説明文・通貨を分ける場合、locale パラメータを Checkout 生成 API に渡す設計にすることをお勧めします。1つの Stripe 商品に日英両方の情報を混在させると、Checkout ページの表示が崩れることがあります。
Webhook 処理の実装 — Cloudflare Workers 特有の注意点
Webhook 処理で最も注意が必要なのが、Cloudflare Workers 環境での署名検証です。通常の Node.js 環境とは異なる実装が必要で、ここで詰まる人が非常に多い箇所です。
// src/app/api/webhook/route.ts
import Stripe from "stripe";
import { getCloudflareContext } from "@opennextjs/cloudflare";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, {
apiVersion: "2024-11-20.acacia",
});
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")\!;
let event: Stripe.Event;
try {
// Cloudflare Workers では constructEventAsync が必須
// constructEvent(同期版)は Workers 環境の SubtleCrypto API と非互換で動作しない
event = await stripe.webhooks.constructEventAsync(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET\!,
undefined,
Stripe.createSubtleCryptoProvider() // Workers の Web Crypto API を使用
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Webhook Error", { status: 400 });
}
// イベント種別に応じた処理
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.deleted":
await handleSubscriptionDelete(event.data.object as Stripe.Subscription);
break;
default:
// 処理しないイベントは無視(200 を返す)
break;
}
return new Response("OK", { status: 200 });
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const { plan_type } = session.metadata ?? {};
const email = session.customer_details?.email;
if (\!email) {
console.error("Checkout complete: no email in session");
return;
}
const { env } = await getCloudflareContext();
switch (plan_type) {
case "premium":
// 永久ライセンス — TTL を 100年に設定(実質期限なし)
await env.KV.put(
`site:claudelab:premium:${email}`,
JSON.stringify({ plan: "premium", grantedAt: Date.now() }),
{ expirationTtl: 60 * 60 * 24 * 365 * 100 }
);
break;
case "pro":
// 月額プラン — subscription.updated イベントで TTL を管理するため
// ここでは current_period_end まで有効な KV エントリを作成
const periodEnd = (session as any).subscription_details?.metadata?.period_end
?? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 31; // フォールバック: +31日
await env.KV.put(
`site:claudelab:pro:${email}`,
JSON.stringify({ plan: "pro", periodEnd }),
{ expirationTtl: periodEnd - Math.floor(Date.now() / 1000) + 86400 }
);
break;
case "article":
// 記事単体購入 — 購入済みスラッグリストに追加(べき等性: 重複追加を防ぐ)
const slug = session.metadata?.article_slug;
if (slug) {
const existing = await env.KV.get(
`site:claudelab:article:${email}`,
"json"
) as { slugs: string[] } | null;
const slugs = existing?.slugs ?? [];
if (\!slugs.includes(slug)) slugs.push(slug);
await env.KV.put(
`site:claudelab:article:${email}`,
JSON.stringify({ email, slugs }),
{ expirationTtl: 60 * 60 * 24 * 365 * 10 } // 10年
);
}
break;
case "tip":
// チップは権限付与なし — ログのみ
console.log(`Tip received from ${email} at ${new Date().toISOString()}`);
break;
}
}
べき等性の確保
Webhook は同じイベントが複数回届くことがあります(Stripe が配信失敗時に最大3回再送するため)。KV への書き込みは「同じ処理を2回実行しても同じ結果になる」設計が必須です。
記事単体購入では slugs.includes(slug) で重複追加を防いでいます。Premium 権限の付与も「既に存在すれば上書き」という冪等な操作のため問題ありません。
サブスクリプション更新でより厳密にする場合は、セッション ID を KV に記録しておき、同じセッション ID のイベントは処理をスキップする実装も有効です。
権限管理の設計 — KV + Cookie の2層フォールバック
コンテンツへのアクセス制御は、Cloudflare KV を一次情報源とし、Cookie をフォールバックとする2層設計をお勧めします。KV が一時的に障害を起こしても、Cookie があれば継続してアクセスできます。
// src/lib/premium.ts
export async function canViewPremium(request: Request): Promise<boolean> {
const cookieHeader = request.headers.get("cookie") ?? "";
const token = parseCookie(cookieHeader, "premium_token");
if (\!token) return false;
try {
const { email } = JSON.parse(atob(token));
const { env } = await getCloudflareContext();
// Tier 1: KV から直接確認(最も信頼性が高い)
const premiumData = await env.KV.get(`site:claudelab:premium:${email}`);
if (premiumData) return true;
// Pro プランも確認
const proData = await env.KV.get(`site:claudelab:pro:${email}`, "json") as { periodEnd?: number } | null;
if (proData?.periodEnd && proData.periodEnd > Math.floor(Date.now() / 1000)) {
return true;
}
return false;
} catch {
return false;
}
}
export async function getArticleAccess(slug: string, request: Request): Promise<boolean> {
const cookieHeader = request.headers.get("cookie") ?? "";
const purchases = parseCookie(cookieHeader, "article_purchases");
if (\!purchases) return false;
try {
const { email, slugs: cookieSlugs } = JSON.parse(atob(purchases)) as {
email: string;
slugs: string[];
};
const { env } = await getCloudflareContext();
// Tier 1: KV 確認(最新の購入記録)
const kvData = await env.KV.get(
`site:claudelab:article:${email}`,
"json"
) as { slugs: string[] } | null;
if (kvData?.slugs?.includes(slug)) return true;
// Tier 2: Cookie のスラッグリストで確認(KV 障害時のフォールバック)
if (cookieSlugs?.includes(slug)) return true;
// ⚠️ deny-by-default: 必ず false を返す(return true は絶対禁止)
// KV にも Cookie にも記録がなければアクセス拒否
return false;
} catch {
return false;
}
}
deny-by-default の重要性
getArticleAccess の末尾で return true にしてしまうと、KV 障害時や Cookie が壊れた場合に全プレミアム記事が無料で読まれてしまいます。必ず return false にしてください。
実際にやりがちな間違いです。「KV が取得できなかった場合は購入済みとみなす」という実装を一瞬考えましたが、それでは購入していないユーザーも読めてしまいます。不確かな状態では常に「拒否」を返すのが正しい設計です。
Cookie の安全な発行
// src/app/api/verify-session/route.ts 内での Cookie 発行
// article_purchases Cookie: 購入済みスラッグリストを蓄積
const existingPurchases = parseCookie(cookieHeader, "article_purchases");
let currentSlugs: string[] = [];
if (existingPurchases) {
try {
const parsed = JSON.parse(atob(existingPurchases));
currentSlugs = parsed.slugs ?? [];
} catch {}
}
if (slug && \!currentSlugs.includes(slug)) {
currentSlugs.push(slug);
}
const cookieValue = btoa(JSON.stringify({ email, slugs: currentSlugs }));
// セキュリティ設定を必ず付ける
const cookieOptions = [
`article_purchases=${cookieValue}`,
"Max-Age=315360000", // 10年
"Path=/",
"HttpOnly", // JavaScript からのアクセスを禁止
"Secure", // HTTPS のみ
"SameSite=Lax",
].join("; ");
サブスクリプション更新・解約フローの実装
月額プランの場合、Stripe は毎月自動で課金し customer.subscription.updated イベントを送信します。このイベントで KV の有効期限を更新することで、月額課金が継続する限りコンテンツにアクセスできる設計になります。
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
const email = await getEmailFromCustomer(subscription.customer as string);
if (\!email) return;
const { env } = await getCloudflareContext();
if (subscription.status === "active") {
// 次回更新日 + 1日の猶予期間まで TTL を延長
const periodEnd = subscription.current_period_end;
const ttl = periodEnd - Math.floor(Date.now() / 1000) + 86400; // +1日の猶予
await env.KV.put(
`site:claudelab:pro:${email}`,
JSON.stringify({ plan: "pro", periodEnd, updatedAt: Date.now() }),
{ expirationTtl: Math.max(ttl, 86400) } // 最低1日は保持
);
} else if (["past_due", "canceled", "unpaid"].includes(subscription.status)) {
// 支払い失敗・解約 — KV エントリを削除してアクセスを即座に無効化
await env.KV.delete(`site:claudelab:pro:${email}`);
console.log(`Subscription revoked for ${email}: status=${subscription.status}`);
}
}
async function handleSubscriptionDelete(subscription: Stripe.Subscription) {
const email = await getEmailFromCustomer(subscription.customer as string);
if (email) {
const { env } = await getCloudflareContext();
await env.KV.delete(`site:claudelab:pro:${email}`);
}
}
// Stripe Customer から email を取得するヘルパー
async function getEmailFromCustomer(customerId: string): Promise<string | null> {
try {
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) return null;
return (customer as Stripe.Customer).email;
} catch (err) {
console.error("Failed to retrieve customer:", err);
return null;
}
}
解約後の猶予期間
subscription.status === "canceled" になった時点で即座に KV を削除すると、月末に解約した場合でも当月分の利用期間が失われます。current_period_end を確認して、期間終了まではアクセスを維持する設計も検討に値します。ただし実装が複雑になるため、最初はシンプルに「解約 = 即時失効」で始めてもよいと思います。
本番でハマった5つの落とし穴
実装中に実際に詰まったポイントを記録しておきます。同じ轍を踏まないための参考にしてください。
① bfcache 問題 — Stripe から「戻る」ボタンでボタンが固まる
Stripe Checkout からブラウザの「戻る」ボタンで戻ると、ブラウザが bfcache(Back-Forward Cache)からページを復元します。この際、ローディング状態が残ったまま操作不能になります。
// 購入ボタンコンポーネントでの対処
import { useEffect, useState } from "react";
export function PurchaseButton({ planType }: { planType: string }) {
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
// bfcache からの復元時にローディング状態をリセット
const handlePageShow = (e: PageTransitionEvent) => {
if (e.persisted) {
setIsLoading(false);
}
};
window.addEventListener("pageshow", handlePageShow);
return () => window.removeEventListener("pageshow", handlePageShow);
}, []);
const handleCheckout = async () => {
setIsLoading(true);
try {
const res = await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ planType }),
headers: { "Content-Type": "application/json" },
});
const { url } = await res.json();
window.location.href = url; // ← Stripe Checkout へリダイレクト
} catch {
setIsLoading(false);
}
};
// ⚠️ <a> タグではなく <button> を使う(2クリック問題を防ぐ)
return (
<button onClick={handleCheckout} disabled={isLoading}>
{isLoading ? "処理中..." : "購入する"}
</button>
);
}
② product_data.images を忘れると説明文が表示されない
price_data.product_data.images を設定しないと、Stripe Checkout ページで商品説明文が表示されにくくなります。これは Stripe の仕様で、ドキュメントにあまり明記されていないため見落としやすいポイントです。
正方形のロゴ画像(推奨: 512×512px 以上)を /public/images/stripe-product.png に配置し、必ず設定してください。サイトごとに専用のブランド画像を用意するのがベストです。
③ Webhook タイムアウト対策
Cloudflare Workers の応答タイムアウトは30秒です。Webhook ハンドラー内でメール送信や重い処理を行う場合は、先に Stripe へ 200 OK を返してから、ctx.waitUntil() で非同期処理を続ける設計が必要です。
export async function POST(request: Request, { waitUntil }: { waitUntil: (promise: Promise<any>) => void }) {
// ... イベント検証 ...
// まず Stripe に 200 を返す(タイムアウト防止)
waitUntil(processWebhookAsync(event)); // バックグラウンドで処理継続
return new Response("OK", { status: 200 });
}
④ Webhook シークレットの環境別管理
Stripe の Webhook シークレット(whsec_...)はローカル開発環境と本番環境で異なります。stripe listen --forward-to localhost:3000/api/webhook で取得できるローカル用シークレットを誤って本番用として使うと、本番の Webhook が全て検証エラーになります。
環境変数名を STRIPE_WEBHOOK_SECRET_LOCAL と STRIPE_WEBHOOK_SECRET で分けて管理することをお勧めします。
⑤ Cookie の HttpOnly を忘れるセキュリティリスク
premium_token や article_purchases Cookie に HttpOnly フラグを付け忘れると、JavaScript からアクセス可能になり、XSS 攻撃でトークンが盗まれるリスクがあります。Cloudflare Workers からの Cookie 発行時に以下の4つのオプションを必ずセットで設定してください。
HttpOnly — JavaScript からのアクセスを禁止
Secure — HTTPS のみで送信
SameSite=Lax — CSRF 対策
Path=/ — サイト全体で有効
運用を始めてから気づいた、テストでは見えなかった一点
Dolice Labs で運営しているサイト群の有料メンバーシップを個人開発として運用していると、テスト環境では一度も再現しなかった挙動が、本番でだけ静かに起きていることに気づきます。私の場合、それは Webhook の「重複配信」ではなく「順序の入れ替わり」でした。
Stripe は checkout.session.completed と、サブスクリプション側の customer.subscription.created をほぼ同時に送ってきます。ネットワークの揺らぎ次第で、後者が先に届くことがあります。KV への書き込みを「セッション完了イベントが正」と決め打ちしていると、サブスクリプションイベントが先に着いたとき、権限レコードが一瞬だけ不完全な状態になります。
実際に観測したのは、決済直後のごく短い時間帯(おおむね数百ミリ秒)に、会員ページが「非会員」として描画されてしまう事象でした。リロードすれば直るため見逃しやすいのですが、初回決済という最も大事な瞬間に起きるため、印象としては決して良くありません。
対処はシンプルでした。KV のキーをイベント種別ごとに分けるのをやめ、status フィールドを持つ単一レコードへ merge 方式で書き込むようにしたのです。どちらのイベントが先に着いても、欠けているフィールドだけを埋める形にすれば、到着順に依存しなくなります。
// イベントの到着順に依存しない merge 書き込み
async function upsertMembership(kv: KVNamespace, email: string, patch: Record<string, unknown>) {
const key = `site:claudelab:member:${email}`;
const current = JSON.parse((await kv.get(key)) ?? "{}");
const merged = { ...current, ...patch, updatedAt: Date.now() };
await kv.put(key, JSON.stringify(merged));
return merged;
}
テストで再現しない事象こそ、本番のログを淡々と観察し続けることでしか見つけられない。運用を通じて私自身が静かに身につけた感覚です。
Stripe 実装と Claude Code の組み合わせを最大限に活かすために
Stripe 実装を Claude Code と進めて最も助かったのが、「なぜこうするのか」を説明しながらコードを書いてくれる点です。
べき等性の話を投げたとき、単にコードを生成するだけでなく「Stripe は同じ checkout.session.completed を最大3回送信することがあります。この実装だと2回目の処理でスラッグが重複追加されます」と具体的に指摘してくれました。
Claude Code を使った Stripe 実装で効果的だった使い方は3つです。
まず、フロー図を自然言語で記述して渡すこと。「Checkout → Webhook → KV → Cookie の順で権限を管理したい。各ステップで失敗した場合のフォールバックも設計してほしい」のような形で最初に全体像を渡すと、依存関係を考慮した実装提案が返ってきます。
次に、「この設計にセキュリティ上のリスクはあるか?」と定期的に質問すること。deny-by-default の原則を忘れていたとき、Claude Code が「KV から取得できない場合に true を返すと、障害時に全記事が無料で読まれます」と指摘してくれました。
最後に、テストシナリオの生成を依頼すること。「成功・失敗・Webhook 重複・解約後のアクセスの4シナリオでテストしてほしい」と頼むと、edge case を網羅したテストコードを生成してくれます。
課金システムの実装は、一度動き始めると変更が難しい部分が多くあります。設計段階で Claude Code と十分に対話することが、後からの修正コストを大幅に削減する最善策だと感じています。
本記事で紹介したセキュリティ設計の詳細については、Claude API プロダクション・セキュリティ完全ガイドも合わせてご参照ください。Cloudflare Workers でのセキュア実装パターンを網羅しています。
まず実際に動かすなら、pricing.ts の一元管理から始めることをお勧めします。価格・テキスト・priceId がバラバラのままコードを書き始めると、後から整理するのに相当な時間がかかります。土台となる pricing.ts を先に固めておけば、その後の実装がずいぶんとスムーズになります。