Claude API で何かを作った。動いた。ユーザーも増えた。でも、これをどう課金すればいいのか……。
僕自身も個人開発の中でこの問題に直面してきました。API の利用は課金・請求と切り離せません。単なる決済ボタンではなく、実際にユーザーごとの API 利用量をトラッキングし、そのコストを回収できる設計が必要です。
Claude API のコスト構造を理解する
Stripe で料金を決める前に、Claude API の実際のコストを把握する必要があります。
Claude の価格設定は単純です。入力トークンと出力トークンで単価が異なり、モデルの世代とサイズで変わります。2026年8月時点の現行世代は以下の通りです。
| モデル | 入力 | キャッシュ読み出し | 出力 |
|---|---|---|---|
| Claude Haiku 4.5 | $1/M | $0.10/M | $5/M |
| Claude Sonnet 5 | $2/M | $0.20/M | $10/M |
| Claude Opus 5 | $5/M | $0.50/M | $25/M |
(M = 100万トークン。最新の値は Claude Platform の価格ページ でご確認ください)
課金システムを書く前に、この表を自分のコードに写経しないでください。単価は改定されます。実際、Sonnet 5 の $2/$10 は当初「2026年8月31日までの導入価格」として案内され、9月1日から $3/$15 へ戻る予定でした。その引き上げは行われず、$2/$10 が標準価格として据え置かれています。もし値上げ日を見越してハードコードしていたら、9月から3割ほど過大に原価計上していたことになります。
単価はコードではなく設定として持つ。これが課金システムで最初に決めるべき境界です。
ここで重要なのは、入力と出力のコスト比です。Opus なら出力は入力の5倍高い。つまり、長めの応答を大量に返すアプリケーションでは、単に「リクエスト数」を課金単位にすると大赤字になります。
実例
Chat とコード生成を組み合わせた SaaS を想定します。
- ユーザーが「この API 使い方教えて」と 100 トークンの質問を送信
- Claude が 500 トークンの詳細な回答を返す
- コスト(Sonnet 5) = (100 × $2 + 500 × $10) / 1,000,000 ≈ $0.0052
月 1,000 リクエストなら約 $5.2 です。ユーザーから「月額 $5」を取っていたとしても、API 原価だけで受け取った額を使い切ります。決済手数料もサーバー代も、そこから先は全部持ち出しです。
これが、多くの個人開発者が AI SaaS で失敗する原因の一つです。
損益分岐点の計算式
SaaS の各ティアで、「月額 X 円」に対して「月額 Y 円のコストで採算が取れるか」を事前に計算しておきます。
// 月額課金と API コストの関係を計算
const calculateBreakEven = (
monthlyPrice,
avgInputTokens,
avgOutputTokens,
estimatedRequestsPerMonth,
model = 'sonnet'
) => {
// 2026-08 時点の現行世代。値は環境変数か設定テーブルから読むのが望ましい
const pricing = {
haiku: { input: 1, output: 5 }, // Claude Haiku 4.5
sonnet: { input: 2, output: 10 }, // Claude Sonnet 5
opus: { input: 5, output: 25 } // Claude Opus 5
};
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.0085',
// totalMonthlyCost: '4.25',
// monthlyProfit: '5.74',
// profitMargin: '57.5'
// }この計算で大事なのは、使用パターンの想定が外れた時の余裕度です。「利益率 57.5%」と出ると、そこそこ安全な気がしてきます。けれど崩れるときは静かに崩れます。
- ユーザーが予想より多くリクエストを送る
- より長い応答が必要なリクエストが増える
- Opus へのアップグレード要望が増える
- モデル世代を上げた結果、同じ文章でもトークン数が増える(後述します)
最後の一つは、コードもユーザーの使い方も何ひとつ変えていないのに利益率だけが下がる、という性質の悪い変化です。この記事の後半で、その実測的な影響と対処を扱います。
個人開発では最初、利益率 50% 以上を目安に設定し、データが集まった後に最適化するのが実用的です。私は「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,
});
};
class UnknownModelError extends Error {}
const calculateTokenCost = (
model: string,
inputTokens: number,
outputTokens: number
): number => {
const rates: Record<string, { input: number; output: number }> = {
'claude-haiku-4-5-20251001': { input: 1, output: 5 },
'claude-sonnet-5': { input: 2, output: 10 },
'claude-opus-5': { input: 5, output: 25 },
};
const rate = rates[model];
// 既定値へ落とさない。未知のモデルは必ず気づける形で失敗させる
if (!rate) throw new UnknownModelError(`No billing rate for model: ${model}`);
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-haiku-4-5-20251001' : 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
請求から漏れるトークン — キャッシュ分は別枠で返ってくる
自前のダッシュボードが「今月 $62」と表示していた月に、Console の請求は $78 でした。$16 の差が説明できず、集計クエリを疑い、タイムゾーンを疑い、最後に API のレスポンスを生で眺めて理由が分かりました。
プロンプトキャッシュを有効にすると、usage はキャッシュ分を別のフィールドで返してきます。
{
"usage": {
"input_tokens": 105,
"cache_creation_input_tokens": 7345,
"cache_read_input_tokens": 7123,
"output_tokens": 6039
}
}input_tokens は 105 しかありません。実際に課金されている 7,000 トークン超は cache_creation_input_tokens と cache_read_input_tokens に入っています。input_tokens と output_tokens だけを合計する計上コードは、キャッシュを入れた瞬間に構造的に過小申告へ変わります。
単価は入力単価に対する倍率で決まります。
| 種別 | 入力単価に対する倍率 | Sonnet 5 の場合 |
|---|---|---|
| 5分キャッシュ書き込み | 1.25x | $2.50/M |
| 1時間キャッシュ書き込み | 2x | $4/M |
| キャッシュ読み出し | 0.1x | $0.20/M |
書き込みは割高、読み出しは 10分の1。5分キャッシュなら1回読まれた時点で元が取れ、1時間キャッシュなら2回目の読み出しで元が取れる計算になります。倍率の根拠は プロンプトキャッシュの価格 に記載があります。
先ほどの recordTokenUsage を、キャッシュを含めた形へ直します。
type Usage = {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
const calculateTokenCostFull = (
model: string,
usage: Usage,
cacheTtl: '5m' | '1h' = '5m'
): number => {
const rate = rates[model];
if (!rate) throw new UnknownModelError(`No billing rate for model: ${model}`);
const writeMultiplier = cacheTtl === '1h' ? 2 : 1.25;
const cost =
usage.input_tokens * rate.input +
(usage.cache_creation_input_tokens ?? 0) * rate.input * writeMultiplier +
(usage.cache_read_input_tokens ?? 0) * rate.input * 0.1 +
usage.output_tokens * rate.output;
return cost / 1_000_000;
};?? 0 を使っているのは、キャッシュを使っていないリクエストではフィールド自体が返らないことがあるためです。undefined を掛け算に入れると NaN が伝播し、その月の集計が丸ごと壊れます。私自身、これで一度、月次レポートを丸ごと作り直す羽目になりました。
もう一点。キャッシュを入れると原価は確かに下がります。ただ、下がった分を計上していなければ、値下げやティア拡張の余地が自分から見えなくなるだけです。安くなったことを帳簿で確認できて初めて、その分をユーザーへ返すかどうかを判断できます。
同じ文章でもトークン数が変わる — 世代更新でティア設計が狂う
課金設計でいちばん見落としやすいのは、「1トークン」の中身が世代によって変わることです。
Claude 4.7 以降のモデルは新しいトークナイザを採用していて、同じ文章からおよそ30%多くトークンが出ます(Sonnet 4.6 以前は従来のトークナイザです)。正確な増加量は文章の内容によって変わります。この点は 価格ページの注記 に明記されています。
何が起きるかというと、こうです。
- ユーザーの使い方は先月と何ひとつ変わっていない
- こちらのコードも変えていない
- モデルを新世代に切り替えただけ
- なのに「月10万トークン」の無料枠が、体感で7万トークン分の働きしかしなくなる
ユーザーからは「急に制限が厳しくなった」と見えます。こちらには値上げした自覚がありません。この噛み合わなさが、解約理由として最も説明しにくい部類のものです。
原価側も動きます。先ほどの Pro プラン(月額 $9.99 / 平均入力 250・出力 800 トークン / 月500リクエスト)を、30%増しのトークン数で引き直してみます。
| 項目 | 従来トークナイザ | 新トークナイザ(+30%想定) |
|---|---|---|
| 平均入力 / 出力 | 250 / 800 | 325 / 1,040 |
| 1リクエスト原価 | $0.0085 | $0.01105 |
| 月間原価 | $4.25 | $5.53 |
| 利益率 | 57.5% | 44.6% |
13ポイントの低下です。単価表は一文字も変わっていないのに、です。
対処は3つあります。
① 課金と割当の単位をトークンから外す。 ユーザーに見せる枠は「月○○リクエスト」、内部で持つ上限は「月○○ドル」にします。トークンは世代で伸縮する物差しなので、契約の単位に据えるには向いていません。
// 割当はドル建てで持ち、トークンは記録専用にする
export const TIER_BUDGETS = {
free: { requests: 100, hardCostCapUSD: 0.5 },
pro: { requests: 5000, hardCostCapUSD: 4.5 }, // 月額 $9.99 に対する原価上限
} as const;
export async function assertWithinBudget(db: Database, userId: string, tier: keyof typeof TIER_BUDGETS) {
const spent = await getMonthlyCostUSD(db, userId);
const cap = TIER_BUDGETS[tier].hardCostCapUSD;
if (spent >= cap) {
throw new BudgetExceededError(`Monthly cost cap reached: $${spent.toFixed(2)} / $${cap}`);
}
}② どうしてもトークン枠で見せたい場合は、モデルごとの換算係数を持つ。 表示上の「消費トークン」を旧世代基準へ正規化してから引き当てれば、ユーザー体験は世代更新をまたいでも連続します。
③ 移行前に自分の代表プロンプトで実測する。 30%というのは平均的な目安であって、自分のアプリの文章に当てはまる保証はありません。トークンカウント用のエンドポイントで、実際に使っているシステムプロンプトとよくある入力を新旧のモデルに投げ、増加率を自分の数字として持ってから切り替えます。私の場合、日本語主体のプロンプトでは目安より少し大きく出ました。
移行日を決める前に、この3つのうち少なくとも①だけは入れておくことをお勧めします。ドル建ての上限が1本あれば、トークナイザが変わっても請求は跳ねません。
実運用での学び
個人開発で実際に SaaS を運用して得た知見をいくつか共有します。
支払い失敗への対処
最初は「支払い失敗 → メール通知 → 手で対応」としていましたが、自動化が重要です。失敗したユーザーを自動でダウングレードしておくと、「使えなくなった」というサポート問合せが減ります。
使用量の「バースト」対応
正常なユーザーは「毎日 10 リクエスト」くらいの一定ペースですが、たまに「今月 1,000 リクエスト送ってくる」ユーザーが出ます。これは悪意ではなく、「大量データの処理」や「自動ツール化」が原因です。
ティア設計に「Burst limit」(15分単位での制限)を入れると、サーバー負荷と請求額の両方で安定します。
通知の重要性
ユーザーが「使用量がもうすぐ上限に達する」ことに気づいていないことが多いです。月間利用量の 70% に達した時点でメール+ダッシュボード通知を送ると、アップグレードへの自然な誘導になります。
結び:設計から運用までの全体像
Claude API を使った SaaS 課金は、単に「ボタンを Stripe に接続する」のではなく、API の実コスト → 採算が取れるティア設計 → ユーザーごとの使用量トラッキング → 自動請求と冪等な Webhook → リアルタイム監視 という一連のシステムです。
実装が複雑に見えるかもしれませんが、各パートは独立していて、1つずつ組み立てることができます。
もし今、あなたが「Claude API で SaaS を作りたいけど、どう課金すればいいか分からない」という段階なら、まずは Free / Pro の 2層構造から始めることをお勧めします。リアルなユーザーデータが集まれば、必要な調整が見えてきます。
月間の API コスト計算表を作成し、3パターン(楽観 / 実際 / 悲観)のシナリオで採算を確認してから実装に入ること。これが、赤字になるティア設計を避ける最速の方法です。