Stripe の Webhook テストを初めて Workers に向けて送った夜、ダッシュボードには赤い「400」が並び続けました。署名シークレットを三回貼り直し、改行の混入を疑い、最後には別のキーまで発行して、それでも結果は同じでした。原因は鍵ではなく、署名検証に使っていた関数のほうでした。
前編のGemini API有料サービス立ち上げロードマップの実装編にあたる記事です。Stripe 決済と Gemini API 呼び出しを Cloudflare Workers の上でつなぎ、月額課金の SaaS として動かすまでを、私が自分のサイトで使っている構成に沿って書き残します。
最初にお伝えしたいのは、この構成の肝がモデルの選び方ではなく、「誰が有料会員か」を一か所で決める設計にあるということです。Gemini の呼び出し自体は数十行で済みます。時間を取られるのは、Webhook、解約、キャッシュといった周辺のほうでした。
前提は Next.js (App Router) + Cloudflare Workers (OpenNext) + Stripe の Node SDK + @google/genai です。バージョンは日々動きますので、手元の package.json と各公式ドキュメントを見比べながら読んでいただければ幸いです。Webhook 周りの運用はStripe 課金 Webhook の本番運用ガイドにも別の角度から書いています。
全体アーキテクチャ
最終的な構成は次のとおりです。
- 認証: NextAuth.js(メール + OAuth)
- 決済: Stripe Checkout(月額サブスクと単発購入の両対応)
- アクセス制御: Stripe Webhook → Cloudflare KV(ユーザーごとの権限フラグ)
- API 実行層: Cloudflare Workers +
@google/genai - モデル切り替え: 通常は Flash、必要な場面だけ Pro
KV を権限の単一の情報源にしています。リクエストのたびに Stripe API へ問い合わせる構成は、レート制限の面でもレイテンシの面でも割に合いません。Stripe は「お金の記録」、KV は「いま入れるかどうかの札」と役割を分けておくと、障害の切り分けも楽になります。
ステップ1: Stripe のプロダクト設計
商品と価格は Stripe ダッシュボードで作り、発行された price_id を環境変数に入れます。通貨ごとに Price を分けておくと、Checkout に出る金額表示がロケールに合います。
- Pro 月額(JPY / USD): 月額サブスク
- 単発購入(JPY / USD): 24 時間だけ使える買い切り
金額はあえて記事に固定しません。月額をいくらにするかは、自分の請求書から 1 ユーザーあたりの月間コストを出して決めるものだと考えているからです。私も最初は「使い放題」の語感を先に決めて、あとから採算を確かめるという逆の順序で進めてしまい、価格の付け直しに手間取りました。
# wrangler.toml
[vars]
STRIPE_PRICE_PRO_JPY = "price_..."
STRIPE_PRICE_PRO_USD = "price_..."
STRIPE_PRICE_SINGLE_JPY = "price_..."
STRIPE_PRICE_SINGLE_USD = "price_..."
SITE_URL = "https://example.com"
# 秘密情報は wrangler secret put で別管理します
# - STRIPE_SECRET_KEY
# - STRIPE_WEBHOOK_SECRET
# - GEMINI_API_KEY
# - NEXTAUTH_SECRETステップ2: Checkout セッション生成 API
クライアントから届く plan と locale は、そのまま信用しません。許可する値を先に決めておき、外れたら 400 を返します。
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
const PLANS = ['pro', 'single'] as const;
const LOCALES = ['ja', 'en'] as const;
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const { plan, locale } = await req.json();
if (!PLANS.includes(plan) || !LOCALES.includes(locale)) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
httpClient: Stripe.createFetchHttpClient(), // Workers では fetch ベースの HTTP クライアントを使います
});
const suffix = locale === 'ja' ? 'JPY' : 'USD';
const priceId = process.env[`STRIPE_PRICE_${plan.toUpperCase()}_${suffix}`]!;
const isPro = plan === 'pro';
const checkout = await stripe.checkout.sessions.create({
mode: isPro ? 'subscription' : 'payment',
line_items: [{ price: priceId, quantity: 1 }],
customer_email: session.user.email,
success_url: `${process.env.SITE_URL}/${locale}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/${locale}/membership`,
metadata: { plan_type: plan, user_email: session.user.email },
});
return NextResponse.json({ url: checkout.url });
}metadata.plan_type は必ず付けてください。Webhook で受け取ったとき、サブスクと単発購入を見分ける手がかりがこれしか残らないからです。
OpenNext 経由で Workers に載せる場合、ルートに export const runtime = 'edge' を書いたままだとビルドで弾かれることがありました。私の環境では、この行を消して Node.js ランタイム(nodejs_compat)のまま動かすのが安定しました。お使いのバージョンの OpenNext のドキュメントで、いちど確かめておくと安心です。
ステップ3: Stripe Webhook の処理
冒頭の 400 の正体がここにあります。Node 向けのサンプルによく出てくる同期版の constructEvent は、Workers の暗号 API(SubtleCrypto は非同期です)と噛み合わず、署名が正しくても検証に失敗します。Workers では constructEventAsync を使います。
もうひとつ、最初に書いた版には冪等性の落とし穴がありました。「処理する前に、イベント ID を KV に記録する」という順序にしていたのです。途中で処理が落ちても Stripe は再送してくれるのに、再送されたイベントは「処理済み」として捨てられてしまいます。記録は、処理が終わってから。 いまはこの順序にしています。
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
const body = await req.text(); // 署名検証には加工前の生テキストが必要です
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
httpClient: Stripe.createFetchHttpClient(),
});
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body, sig!, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
const { env } = getCloudflareContext();
const kv = env.MEMBERSHIP_KV;
// 処理済みのイベントは読み飛ばします(記録は最後に行います)
const eventKey = `site:example:webhook:${event.id}`;
if (await kv.get(eventKey)) {
return NextResponse.json({ received: true, duplicate: true });
}
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
const email = s.customer_email ?? s.metadata?.user_email;
if (!email) break;
if (s.metadata?.plan_type === 'pro') {
const sub = await stripe.subscriptions.retrieve(s.subscription as string);
// 請求期間の終了日は、Stripe の API バージョンによって置き場所が違います
const periodEnd =
sub.items?.data?.[0]?.current_period_end ??
(sub as unknown as { current_period_end: number }).current_period_end;
const ttl = periodEnd - Math.floor(Date.now() / 1000) + 86400 * 3;
await kv.put(
`site:example:member:${email}`,
JSON.stringify({ plan: 'pro', current_period_end: periodEnd, subscription_id: sub.id }),
{ expirationTtl: Math.max(ttl, 60) } // KV の TTL は最短 60 秒です
);
} else if (s.metadata?.plan_type === 'single') {
await kv.put(`site:example:single:${email}`, '1', { expirationTtl: 86400 });
}
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(sub.customer as string);
if (!('deleted' in customer) && customer.email) {
await kv.delete(`site:example:member:${customer.email}`);
}
break;
}
}
await kv.put(eventKey, '1', { expirationTtl: 86400 * 7 });
return NextResponse.json({ received: true });
}TTL は「次回更新日 + 3 日」にしています。更新時の Webhook が何かの理由で届かなくても、KV の札が自然に失効して、会員の権限が永遠に残る事態は避けられます。逆に言えば、更新のたびに invoice.paid などを受けて TTL を延ばす処理も必要です。この記事のコードは最小構成ですので、更新イベントの扱いは本番前に足してください。
ステップ4: アクセス制御のヘルパー
// lib/premium.ts
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function canViewPremium(): Promise<boolean> {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return false;
try {
const { env } = getCloudflareContext();
const raw = await env.MEMBERSHIP_KV.get(`site:example:member:${session.user.email}`);
if (raw && JSON.parse(raw).plan === 'pro') return true;
return (await env.MEMBERSHIP_KV.get(`site:example:single:${session.user.email}`)) === '1';
} catch {
return false; // KV が読めないときは閉じます
}
}catch の中の return false は、どれだけ急いでいても変えないでください。ここを true にすると、KV に障害が出るたびに、全員が有料機能を使える状態になります。
権限の真実は KV に一か所だけ置き、迷ったときは閉じる側に倒します。 この線引きだけは、締め切りの前夜でも崩さないようにしています。
ステップ5: Gemini API の呼び出し
ここが Gemini 側の部分です。SDK は @google/genai を使います。旧 @google/generative-ai はすでに後継へ移っていますので、新規で組むなら最初から新しいほうを選ぶのが素直です。モデルは Flash と Pro の 2 段にして、どちらも名前を一か所の表にまとめます。モデル ID は入れ替わりが速いので、本番に出す前に公式のモデル一覧で現行の名前を確かめてください。
// lib/gemini.ts
import { GoogleGenAI } from '@google/genai';
export type ModelTier = 'flash' | 'pro';
const MODEL_NAMES: Record<ModelTier, string> = {
flash: 'gemini-3.2-flash',
pro: 'gemini-3.2-pro',
};
export async function generateWithGemini(
prompt: string,
tier: ModelTier = 'flash'
): Promise<string> {
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
const res = await ai.models.generateContent({
model: MODEL_NAMES[tier],
contents: prompt,
config: { maxOutputTokens: 2000, temperature: 0.7 },
});
return res.text ?? '';
}maxOutputTokens は必ず明示します。入力側の検証と合わせて、1 回の呼び出しの最大コストに天井を作っておくためです。
ステップ6: 利用上限つきの API エンドポイント
「使い放題」と書く場合でも、レート制限は別に必要です。使い放題は月額の話で、同時に何本でも投げられるという意味ではありません。
// app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { canViewPremium } from '@/lib/premium';
import { generateWithGemini, type ModelTier } from '@/lib/gemini';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
if (!(await canViewPremium())) {
return NextResponse.json({ error: 'subscription_required' }, { status: 402 });
}
const { prompt, tier = 'flash' } = await req.json();
if (typeof prompt !== 'string' || prompt.length > 8000) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
}
if (tier !== 'flash' && tier !== 'pro') {
return NextResponse.json({ error: 'bad_tier' }, { status: 400 });
}
// 1 分あたり 10 リクエストの目安
const { env } = getCloudflareContext();
const minuteKey = `rate:${session.user.email}:${Math.floor(Date.now() / 60000)}`;
const current = parseInt((await env.MEMBERSHIP_KV.get(minuteKey)) ?? '0', 10);
if (current >= 10) {
return NextResponse.json({ error: 'rate_limit', retryAfter: 60 }, { status: 429 });
}
await env.MEMBERSHIP_KV.put(minuteKey, String(current + 1), { expirationTtl: 120 });
const text = await generateWithGemini(prompt, tier as ModelTier);
return NextResponse.json({ text });
}正直に書いておくと、KV は「読んで、足して、書く」の間に他のリクエストが入り込めますし、書き込みの反映にも遅れがあります。この方式は厳密な計数ではなく、ループ呼び出しを止めるブレーキです。請求に直結する上限を守りたいときは、Durable Objects や Cloudflare のレート制限機能のように、原子的に数えられる仕組みへ移すのがよいと考えています。
ステップ7: 解約フローとエッジケース
本番に出してから詰まりやすい所を、三つ挙げます。
解約直後の駆け込み利用 — Customer Portal から解約された場合、期間末までは使えるのが普通です。customer.subscription.deleted が届くまで KV の札は残り、TTL も期間末に合わせてあるため、特別な処理を足さなくても期間内は入れます。「解約予約」を画面に出したい場合にだけ、customer.subscription.updated の cancel_at_period_end を読みます。
重複 Webhook — 同じイベントが複数回届くことがあります。ステップ3のとおり、event.id の記録で読み飛ばします。
Gemini 側のクォータ超過 — 超過すると HTTP 429(RESOURCE_EXHAUSTED)が返ります。SDK のエラーは status を持っていますので、文字列の一致ではなくステータスで判定するほうが確実です。
async function generateWithRetry(prompt: string, tier: ModelTier): Promise<string> {
try {
return await generateWithGemini(prompt, tier);
} catch (e) {
const status = (e as { status?: number }).status;
if (status === 429 || (status !== undefined && status >= 500)) {
await new Promise((r) => setTimeout(r, 1500)); // 短く待って 1 回だけ再試行します
return await generateWithGemini(prompt, tier);
}
throw e;
}
}Flash から Pro へ逃がす手もありますが、クォータはモデルごとに分かれていることもあれば、プロジェクト全体の上限に当たっていることもあります。後者では逃げ道になりません。まず待って再試行し、それでも駄目なら利用者に正直に「混み合っています」と返す、という順序が私の落ち着き先です。
ステップ8: Cloudflare のエッジキャッシュ対策
個人開発の SaaS でいちばん見落としやすい罠です。HTML をエッジでキャッシュすると、ログイン状態や課金状態に関係なく同じ HTML が配られます。課金したのにメンバーシップへの案内が消えない、という問い合わせは、たいていここが原因でした。
会員用の Cookie(私の環境では premium_token)を持つリクエストは、キャッシュを通さずオリジンへ回します。
addEventListener('fetch', (event) => {
const cookie = event.request.headers.get('cookie') || '';
if (cookie.includes('premium_token')) {
event.respondWith(fetch(event.request)); // 会員はキャッシュを迂回します
return;
}
event.respondWith(handleCachedRequest(event));
});本番投入前のチェックリスト
- Stripe Webhook のエンドポイント:本番 URL を指している状態
STRIPE_WEBHOOK_SECRETとGEMINI_API_KEY:本番用の値へ差し替え済み- KV のキー接頭辞:同じ KV を共有する他サービスと衝突しないこと
- Google Cloud 側の月間予算アラート:設定済み
- Stripe テストモード:「購入 → 解約 → 再購入」の 3 経路を通過
- 署名が不正な Webhook:curl で 400 が返ることを確認
- レート制限:実際に 429 が返ることを確認
- 会員 Cookie:キャッシュ迂回が効いていること
振り返って
組み終えてみると、Gemini 固有の判断は多くありませんでした。残ったのは、モデルを 2 段に分けて呼び出し側で選べるようにしたこと、出力トークンに天井を置いたこと、単価を自分の請求書から割り出して価格を決めたことの 3 点です。あとは Workers という実行環境の癖(非同期の署名検証、fetch ベースの HTTP クライアント、エッジキャッシュ)と、Webhook を再送前提で書く姿勢が、土台を支えてくれました。
まずはテストモードで、署名が不正なリクエストに 400 が返るところまで確かめてみてください。私もそこから一つずつ積み上げ直しました。冒頭の赤い 400 は、鍵のせいではなかったのだと、いまは笑って思い返せます。