Claude API で何かを作った。動いた。ユーザーも増えた。でも、これをどう課金すればいいのか……。
僕自身も個人開発の中でこの問題に直面してきました。API の利用は課金・請求と切り離せません。単なる決済ボタンではなく、実際にユーザーごとの API 利用量をトラッキングし、そのコストを回収できる設計が必要です。
Claude API のコスト構造を理解する
Stripe で料金を決める前に、Claude API の実際のコストを把握する必要があります。
Claude の価格設定は単純です。入力トークンと出力トークンで単価が異なり、モデル(Opus / Sonnet / Haiku)で変わります。2026年時点での概算は以下の通りです。
| モデル | 入力 | 出力 |
|---|---|---|
| Haiku | $0.80/M | $4/M |
| Sonnet | $3/M | $15/M |
| Opus | $15/M | $75/M |
(M = 100万トークン、数値は概算)
ここで重要なのは、入力と出力のコスト比です。Opus なら出力は入力の5倍高い。つまり、長めの応答を大量に返すアプリケーションでは、単に「リクエスト数」を課金単位にすると大赤字になります。
実例
Chat とコード生成を組み合わせた SaaS を想定します。
- ユーザーが「この API 使い方教えて」と 100 トークンの質問を送信
- Claude が 500 トークンの詳細な回答を返す
- コスト(Sonnet) = (100 × $3 + 500 × $15) / 1,000,000 ≈ $0.0081
月 1,000 リクエストなら約 $8 です。一方、ユーザーから「月額 $5」を取っていたら、半年で赤字確定です。
これが、多くの個人開発者が AI SaaS で失敗する原因の一つです。
損益分岐点の計算式
SaaS の各ティアで、「月額 X 円」に対して「月額 Y 円のコストで採算が取れるか」を事前に計算しておきます。
// 月額課金と API コストの関係を計算
const calculateBreakEven = (
monthlyPrice,
avgInputTokens,
avgOutputTokens,
estimatedRequestsPerMonth,
model = 'sonnet'
) => {
const pricing = {
haiku: { input: 0.8, output: 4 },
sonnet: { input: 3, output: 15 },
opus: { input: 15, output: 75 }
};
const rate = pricing[model];
const costPerRequest = (
(avgInputTokens * rate.input + avgOutputTokens * rate.output) / 1_000_000
);
const totalMonthlyCost = costPerRequest * estimatedRequestsPerMonth;
const profit = monthlyPrice - totalMonthlyCost;
const margin = (profit / monthlyPrice) * 100;
return {
costPerRequest: costPerRequest.toFixed(4),
totalMonthlyCost: totalMonthlyCost.toFixed(2),
monthlyProfit: profit.toFixed(2),
profitMargin: margin.toFixed(1)
};
};
// Pro プランの採算性をチェック
const breakEven = calculateBreakEven(
9.99, // 月額 $9.99
250, // 平均入力トークン
800, // 平均出力トークン
500, // 月間リクエスト数予想
'sonnet'
);
console.log(breakEven);
// {
// costPerRequest: '0.0129',
// totalMonthlyCost: '6.45',
// monthlyProfit: '3.54',
// profitMargin: '35.4'
// }この計算で大事なのは、使用パターンの想定が外れた時の余裕度です。「利益率 35%」と見えますが、実際には:
- ユーザーが予想より多くリクエストを送る
- より長い応答が必要なリクエストが増える
- Opus へのアップグレード要望が増える
……といった時に、すぐに赤字に転じます。個人開発では最初、利益率 50% 以上を目安に設定し、データが集まった後に最適化するのが実用的です。
ティア設計の実装パターン
Free / Pro / Enterprise の3層構造が、個人開発の SaaS では最適です。
// lib/billing/tiers.ts
export const BILLING_TIERS = {
free: {
name: 'Free',
monthlyPrice: 0,
requestLimit: 10, // 月間リクエスト数
maxOutputTokens: 500, // 1リクエストあたりの最大出力
model: 'haiku', // 使用モデル制限
supportEmail: false,
},
pro: {
name: 'Pro',
monthlyPrice: 9.99,
requestLimit: 500,
maxOutputTokens: 4000,
model: 'sonnet',
supportEmail: true,
},
enterprise: {
name: 'Enterprise',
monthlyPrice: null, // カスタム料金
requestLimit: Infinity,
maxOutputTokens: Infinity,
model: 'opus',
supportEmail: true,
features: ['priority-support', 'api-access', 'sso']
}
} as const;
export type BillingTier = keyof typeof BILLING_TIERS;
export const getTierConfig = (tier: BillingTier) => BILLING_TIERS[tier];ここで工夫するべき点は、Free ティアの設計です。
- 「無料は完全に制限」ではなく、「月10リクエストなら Haiku で十分賄える」という現実的な制限
- Haiku は Sonnet の 1/5 のコスト。Free ユーザーが月10リクエスト(Haiku)なら、月額コストは約 $0.05 ですみます
- 十分な体験ができるので、実際に Pro へのコンバージョンが期待できる
逆に「月1リクエストだけ無料」という Free ティアは、試す価値を感じてもらえません。
Claude API トークン消費のリアルタイムトラッキング
月額課金と使用量ベース課金を組み合わせるには、ユーザーごとの API 消費を正確に記録する必要があります。
Stripe では「Usage Records」という機能で、毎月の消費量を後から報告できます。ただし、これは「月末に集計して報告」するやり方。リアルタイムで表示したい場合は、自分たちで追跡テーブルを持つのが実装の基本です。
// lib/db/usage.ts
import { Database } from '@your-db/client';
export interface UsageRecord {
userId: string;
timestamp: Date;
inputTokens: number;
outputTokens: number;
model: 'haiku' | 'sonnet' | 'opus';
requestId: string;
cost: number; // USD
}
// Anthropic SDK から token_usage を抽出して記録
export const recordTokenUsage = async (
db: Database,
userId: string,
response: Message,
model: string
) => {
const inputTokens = response.usage.input_tokens;
const outputTokens = response.usage.output_tokens;
// トークン数からドルコストを計算
const costUSD = calculateTokenCost(model, inputTokens, outputTokens);
await db.usage.create({
userId,
timestamp: new Date(),
inputTokens,
outputTokens,
model,
requestId: response.id,
cost: costUSD,
});
};
const calculateTokenCost = (
model: string,
inputTokens: number,
outputTokens: number
): number => {
const rates: Record<string, { input: number; output: number }> = {
'claude-3-5-haiku-latest': { input: 0.8, output: 4 },
'claude-3-5-sonnet-latest': { input: 3, output: 15 },
'claude-opus-4-1': { input: 15, output: 75 },
};
const rate = rates[model] || rates['claude-3-5-sonnet-latest'];
return (inputTokens * rate.input + outputTokens * rate.output) / 1_000_000;
};
// ユーザーの月間集計を取得
export const getMonthlySummary = async (
db: Database,
userId: string,
year: number,
month: number
) => {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 1);
const records = await db.usage.findMany({
where: {
userId,
timestamp: {
gte: startDate,
lt: endDate,
},
},
});
return {
totalRequests: records.length,
totalInputTokens: records.reduce((sum, r) => sum + r.inputTokens, 0),
totalOutputTokens: records.reduce((sum, r) => sum + r.outputTokens, 0),
totalCost: records.reduce((sum, r) => sum + r.cost, 0),
models: [...new Set(records.map(r => r.model))],
};
};このテーブルに対して、Stripe Usage Metering へ「月1回、月末に集計値を送信」というバッチ処理を組みます。
// jobs/sync-usage-to-stripe.ts
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export const syncUsageToStripe = async () => {
// 全ユーザーの当月分を集計
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
// 有効なサブスクリプションを持つユーザーを取得
const subscriptions = await db.subscription.findMany({
where: {
status: 'active',
tier: { in: ['pro', 'enterprise'] }, // Free は課金対象外
},
include: { user: true },
});
for (const sub of subscriptions) {
const usage = await db.usage.aggregate({
where: {
userId: sub.userId,
timestamp: { gte: startOfMonth, lt: endOfMonth },
},
_sum: { cost: true },
});
const costInCents = Math.round((usage._sum.cost || 0) * 100);
// Stripe に使用量を報告
await stripe.billing.meterEvents.create({
event_name: 'api_usage',
payload: {
value: costInCents,
stripe_customer_id: sub.stripeCustomerId,
},
});
}
};実装のポイント
- 小数点の取り扱い: Stripe では金額は「セント単位の整数」で扱います。浮動小数点の丸め誤差に注意
- タイムゾーン: 「月末集計」は UTC を基準にするか、ユーザーのタイムゾーンを基準にするか、事前に決める(後から変更は混乱のもと)
- リトライ: バッチ処理が途中で失敗した時は、失敗したユーザーだけ再実行できる仕組みが必須
Stripe Webhook による請求・ダウングレード管理
Stripe でサブスクリプションを作成しただけでは不十分です。実際には:
- 月末に自動請求が成功したか確認したい
- 請求失敗時に自動でユーザーをダウングレードしたい
- 月額の使用量制限をリセットしたい
これらは全て Webhook で実装します。
// app/api/stripe/webhooks/route.ts
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { NextRequest } from 'next/server';
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
// Webhook の署名検証
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err: any) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// idempotency を確保するため、既処理イベントはスキップ
const eventExists = await db.webhookEvent.findUnique({
where: { stripeEventId: event.id },
});
if (eventExists) {
return new Response('Event already processed', { status: 200 });
}
// イベントを記録(リプレイ攻撃対策)
await db.webhookEvent.create({
data: {
stripeEventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
// イベント種別ごとの処理
switch (event.type) {
case 'invoice.payment_succeeded': {
const invoice = event.data.object as any;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
const customerId = subscription.customer as string;
// 本社サイドのサブスクリプションを「有効」に
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { status: 'active', failedPaymentCount: 0 },
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as any;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
const customerId = subscription.customer as string;
const sub = await db.subscription.findUnique({
where: { stripeCustomerId: customerId },
});
const failureCount = (sub?.failedPaymentCount || 0) + 1;
// 3回連続失敗したらダウングレード
if (failureCount >= 3) {
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { tier: 'free', status: 'downgraded' },
});
// ユーザーにメール送信(通知)
// await sendEmail({...})
} else {
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { failedPaymentCount: failureCount },
});
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as any;
const customerId = subscription.customer as string;
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { status: 'cancelled', tier: 'free' },
});
break;
}
case 'billing_portal.session.created': {
// 顧客がポータルで設定を変更
// ここではログ記録のみ(特に処理は不要)
break;
}
}
return new Response('OK', { status: 200 });
}Webhook 実装で重要な3つのポイント
-
冪等性(Idempotency)
- Stripe が同じイベントを複数回送信する可能性がある
stripeEventIdをwebhookEventテーブルに記録して、重複処理を防ぐ- 最初から「一度だけ処理される」という保証がない設計が正解
-
エラーハンドリング
- Webhook の処理中に DB へのアクセスが失敗することがある
- 失敗した場合は HTTP 500 を返して Stripe に「再試行してくれ」と伝える
- 成功時は HTTP 200 を返す
-
タイムアウト
- Webhook の処理は 30 秒以内に完了させる
- 重い処理は Queue(Bull など)に push して非同期実行する
Next.js ミドルウェアでのレート制限・機能制限
API エンドポイントを呼ぶ際に、毎回「このユーザーは Pro か」「今月の使用量はあと何か」を確認する必要があります。
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
import { db } from '@/lib/db';
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(request: NextRequest) {
// /api/chat/* へのリクエストに対してのみ
if (!request.nextUrl.pathname.startsWith('/api/chat')) {
return NextResponse.next();
}
// JWT トークンから user ID を取得
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new NextResponse('Unauthorized', { status: 401 });
}
const token = authHeader.slice(7);
let payload;
try {
const verified = await jwtVerify(token, secret);
payload = verified.payload;
} catch {
return new NextResponse('Invalid token', { status: 401 });
}
const userId = payload.sub as string;
// ユーザーの登録情報と当月使用量を取得
const user = await db.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
if (!user?.subscription) {
return new NextResponse('No subscription', { status: 403 });
}
// 当月の使用量を集計
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const monthlyUsage = await db.usage.aggregate({
where: {
userId,
timestamp: { gte: startOfMonth },
},
_count: true,
});
const tier = BILLING_TIERS[user.subscription.tier];
const requestCount = monthlyUsage._count;
// リクエスト数制限をチェック
if (requestCount >= tier.requestLimit) {
return new NextResponse(
JSON.stringify({
error: 'Monthly request limit exceeded',
limit: tier.requestLimit,
used: requestCount,
}),
{ status: 429 }
);
}
// リクエスト情報をレスポンスヘッダに追加(ルートハンドラで参照可能)
const response = NextResponse.next();
response.headers.set('x-user-id', userId);
response.headers.set('x-tier', user.subscription.tier);
response.headers.set('x-requests-remaining', String(tier.requestLimit - requestCount));
return response;
}
export const config = {
matcher: ['/api/chat/:path*'],
};ルートハンドラ側では、このヘッダ情報を使って Claude API の呼び出しを制御します。
// app/api/chat/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import { recordTokenUsage, getMonthlySummary } from '@/lib/billing/usage';
import { db } from '@/lib/db';
const client = new Anthropic();
const BILLING_TIERS = { /* ... */ };
export async function POST(request: NextRequest) {
const userId = request.headers.get('x-user-id')!;
const tier = request.headers.get('x-tier')! as any;
const tierConfig = BILLING_TIERS[tier];
const body = await request.json();
const { messages, model: userRequestedModel } = body;
// ユーザーが Pro 以上でなければ Haiku を強制
const model = tier === 'free' ? 'claude-3-5-haiku-latest' : userRequestedModel;
try {
// Claude API に問い合わせ
const response = await client.messages.create({
model,
max_tokens: tierConfig.maxOutputTokens,
messages,
});
// 使用量を DB に記録
await recordTokenUsage(db, userId, response, model);
// クライアントに返す
return NextResponse.json({
content: response.content,
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
},
});
} catch (error) {
// API エラーのハンドリング
if (error instanceof Anthropic.RateLimitError) {
return NextResponse.json(
{ error: 'Claude API rate limited. Try again later.' },
{ status: 429 }
);
}
throw error;
}
}本番環境でのコスト監視とアラート
API SaaS の最大の敵は「予想外のコスト爆発」です。バグ一つで、ボットが大量リクエストを送ってきたら……月間 $10,000 の請求になることもあります。
// jobs/monitor-api-costs.ts
import { db } from '@/lib/db';
import { sendAlert } from '@/lib/email';
export const monitorAPICosts = async () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// 当月の集計
const monthlyCost = await db.usage.aggregate({
where: { timestamp: { gte: startOfMonth } },
_sum: { cost: true },
});
const totalCost = monthlyCost._sum.cost || 0;
const budget = 1000; // 月間予算 $1000
// 予算の 80% に達したらアラート
if (totalCost > budget * 0.8) {
await sendAlert({
to: 'admin@yourapp.com',
subject: `Cost Alert: ${(totalCost / budget * 100).toFixed(0)}% of monthly budget used`,
body: `Current API cost: $${totalCost.toFixed(2)} / $${budget}`,
});
}
// 1日単位での異常検知
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const yesterdayCost = await db.usage.aggregate({
where: {
timestamp: {
gte: yesterday,
lt: new Date(yesterday.getTime() + 24 * 60 * 60 * 1000),
},
},
_sum: { cost: true },
});
// 1日のコストが平均の 3 倍なら異常
const dailyAverage = totalCost / now.getDate(); // 平均日次コスト
const yesterdayTotal = yesterdayCost._sum.cost || 0;
if (yesterdayTotal > dailyAverage * 3) {
await sendAlert({
to: 'admin@yourapp.com',
subject: `Anomaly: Yesterday's API cost $${yesterdayTotal.toFixed(2)} (avg: $${dailyAverage.toFixed(2)})`,
body: `Investigate potential bot activity or budget issues.`,
});
}
};このモニタリングを cron ジョブ(またはCloudflare Cron Triggers)で毎日実行します。
Stripe 側の設定と結線
ここまで説明したのは、アプリケーション側の実装です。Stripe のダッシュボード側も設定が必要です。
-
商品とプランの作成
- Free / Pro / Enterprise のプランを Stripe ダッシュボードで作成
- Pro は「$9.99/月」、Enterprise は「カスタム」に設定
-
Usage Metering の有効化(Pro / Enterprise)
- 「Billing」→「Billing Meter」で「api_usage」という meter を作成
- 「Price」でこの meter を選択して、「使用量 1 単位 = $0.01」のように設定
-
Webhook の登録
- 「Developers」→「Webhooks」で
/api/stripe/webhooksエンドポイントを登録 invoice.payment_succeeded/invoice.payment_failed/customer.subscription.deletedを購読
- 「Developers」→「Webhooks」で
-
ウェブフックの送信テスト
- Stripe CLI を使ってローカルテスト
stripe listen --forward-to localhost:3000/api/stripe/webhooks stripe trigger invoice.payment_succeeded
実運用での学び
個人開発で実際に SaaS を運用して得た知見をいくつか共有します。
支払い失敗への対処
最初は「支払い失敗 → メール通知 → 手で対応」としていましたが、自動化が重要です。失敗したユーザーを自動でダウングレードしておくと、「使えなくなった」というサポート問合せが減ります。
使用量の「バースト」対応
正常なユーザーは「毎日 10 リクエスト」くらいの一定ペースですが、たまに「今月 1,000 リクエスト送ってくる」ユーザーが出ます。これは悪意ではなく、「大量データの処理」や「自動ツール化」が原因です。
ティア設計に「Burst limit」(15分単位での制限)を入れると、サーバー負荷と請求額の両方で安定します。
通知の重要性
ユーザーが「使用量がもうすぐ上限に達する」ことに気づいていないことが多いです。月間利用量の 70% に達した時点でメール+ダッシュボード通知を送ると、アップグレードへの自然な誘導になります。
結び:設計から運用までの全体像
Claude API を使った SaaS 課金は、単に「ボタンを Stripe に接続する」のではなく、API の実コスト → 採算が取れるティア設計 → ユーザーごとの使用量トラッキング → 自動請求と冪等な Webhook → リアルタイム監視 という一連のシステムです。
実装が複雑に見えるかもしれませんが、各パートは独立していて、1つずつ組み立てることができます。
もし今、あなたが「Claude API で SaaS を作りたいけど、どう課金すればいいか分からない」という段階なら、まずは Free / Pro の 2層構造から始めることをお勧めします。リアルなユーザーデータが集まれば、必要な調整が見えてきます。
月間の API コスト計算表を作成し、3パターン(楽観 / 実際 / 悲観)のシナリオで採算を確認してから実装に入ること。これが、赤字になるティア設計を避ける最速の方法です。