◉CLAUDE LABEN
●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました
記事一覧/API & SDK
⬡ API & SDK/2026-04-28上級

Claude API × Stripe で月額課金 SaaS を設計する — アーキテクチャから実装まで

Claude API を組み込んだ SaaS の月額課金設計を、Stripe Billing・Usage Metering・Webhook の実装コード付きで解説します。APIコスト管理からティア設計まで、個人開発者が実際に収益化するための設計パターンです。

claude-api83stripe7saasmonetization4billing6webhook3usage-meteringarchitecture7

Claude API を組み込んだ小さなツールが初めて動いた夜、私は画面に出た返答よりも、その直後に浮かんだ疑問のほうをよく覚えています。この 1 回の返答に、いったいいくらかかっているのだろう、と。

ユーザーが少しずつ増えるにつれて、その疑問は「どう課金するか」という形で戻ってきました。決済ボタンを置けば済む話ではありません。ユーザーごとの 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 でサブスクリプションを作成しただけでは足りません。実際には次の 3 つが必要になります。

  • 月末の自動請求が成功したかの確認
  • 請求失敗時のユーザーの自動ダウングレード
  • 月額の使用量制限のリセット

これらは全て 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 });
  }
  
  // 処理済みのイベントはスキップする
  const eventExists = await db.webhookEvent.findUnique({
    where: { stripeEventId: event.id },
  });
  
  if (eventExists) {
    return new Response('Event already processed', { status: 200 });
  }
  
  // 先に「処理済み」と記録しない。処理の途中で落ちた再送が握りつぶされるため、
  // ハンドラが最後まで成功してから記録する
  try {
    // イベント種別ごとの処理
    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;
      
        // 自社 DB 側のサブスクリプションを「有効」に
        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;
      }
    }
  
  } catch (err) {
    console.error('webhook handler failed', event.id, err);
    return new Response('Handler failed', { status: 500 }); // Stripe に再送させる
  }
  
  await db.webhookEvent.create({
    data: {
      stripeEventId: event.id,
      type: event.type,
      processedAt: new Date(),
    },
  });
  
  return new Response('OK', { status: 200 });
}

Webhook 実装で重要な3つのポイント

  1. 冪等性(Idempotency)

    • Stripe は同じイベントを複数回送ってくることがあります
    • stripeEventId を webhookEvent テーブルに記録して、重複処理を防ぎます
    • 「一度だけ届く」前提ではなく、「何度届いても結果が同じ」になる設計にします
  2. エラーハンドリング

    • Webhook の処理中に DB へのアクセスが失敗することがあります
    • 失敗したときは HTTP 500 を返し、Stripe に再送を依頼します
    • 成功したときだけ HTTP 200 を返します
  3. タイムアウト

    • Webhook の処理は 30 秒以内に完了させます
    • 重い処理は Queue(Bull など)に積んで非同期で実行します

この順番は、逆に書いてしまいがちな箇所です。「処理済み」の記録を先に書き、そのあとで DB を更新する形にすると、次のことが起きます。DB のメンテナンスと請求日が重なり、更新が失敗したところへ Stripe が再送してきたとします。記録だけが残っているため、再送は「処理済み」として 200 で返され、ユーザーは支払い済みなのにステータスが「失敗」のまま固まります。しかも、エラーログには何も残りません。気づくのは、問い合わせが 1 通届いてからになるでしょう。記録は結果のあとに、失敗したら 500 で返します。 この順番を、私は守るようにしています。なお、同じイベントが同時に 2 本届く場合に備えて、stripeEventId には一意制約も付けておくと安心です。

Next.js ミドルウェアでのレート制限・機能制限

API エンドポイントを呼ぶ際に、毎回「このユーザーは Pro か」「今月の使用量はあと何か」を確認する必要があります。

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
import { db } from '@/lib/db';
import { BILLING_TIERS } from '@/lib/billing/tiers';
 
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';
import { BILLING_TIERS } from '@/lib/billing/tiers';
 
const client = new Anthropic();
 
// ティア設定のエイリアスを、課金テーブルに載っているモデル ID へ対応づける
const MODEL_IDS = {
  haiku: 'claude-haiku-4-5-20251001',
  sonnet: 'claude-sonnet-5',
  opus: 'claude-opus-5',
} as const;
 
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 } = body;
  
  // モデルはクライアントの指定ではなく、ティア設定から決める
  // (指定をそのまま通すと、Free が上位モデルを呼べるうえ、単価表にないモデルで原価が記録できなくなる)
  const model = MODEL_IDS[tierConfig.model];
  
  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 のダッシュボード側も設定が必要です。

  1. 商品とプランの作成

    • Free / Pro / Enterprise のプランを Stripe ダッシュボードで作成します
    • Pro は「$9.99/月」、Enterprise は「カスタム」にします
  2. Usage Metering の有効化(Pro / Enterprise)

    • 「Billing」→「Billing Meter」で「api_usage」という meter を作成します
    • 「Price」でこの meter を選び、「使用量 1 単位 = $0.01」のように設定します
  3. Webhook の登録

    • 「Developers」→「Webhooks」で /api/stripe/webhooks エンドポイントを登録
    • invoice.payment_succeeded / invoice.payment_failed / customer.subscription.deleted を購読
  4. ウェブフックの送信テスト

    • 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 / 800325 / 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 パターンで採算を確かめることです。悲観のシナリオを一度も計算しないまま公開すると、赤字に気づくのは請求書が届いた日になってしまいます。

原価は記録から、価格は計算から、請求は結果のあとで。 課金まわりでは、この順番だけは崩さないようにしています。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ API & SDK2026-05-04
Claude API × Stripe で使用量ベース課金を最小構成で実装する
Claude API のトークン消費量を計測し、Stripe Meter Events で従量課金を実装する最小構成を解説。個人開発者が週末に実装できる Node.js コード例と、本番で詰まりやすい3つのポイントを共有します。
⬡ API & SDK2026-09-22
PDF の入力トークンは文字数では読めません — 送る前にページ数から上限で挟む
Claude API に PDF を送る前の入力トークンを、文字数ではなくページ数から上限で挟んで見積もる手順です。A4 9 ページの日本語文書で文字数見積もりの 6.6 倍になった実測と、解像度ティアとトークナイザの二重の増分、count_tokens が file_id を数えられない制約までをまとめます。
⬡ API & SDK2026-09-07
キャッシュ読み取りが 75% 安くなっても、請求の下がり方は構成しだいで 10 倍違います
Fable 5.1 と Mythos 5.1 でキャッシュ読み取りの倍率が 0.1 から 0.025 へ変わりました。自分のトークン内訳から削減幅を先に見積もる手順と、倍率を定数で持つコードの直し方をまとめます。