Claude API を組み込んだサービスで従量課金を始めると、最初の数週間は順調に見えます。問題が顔を出すのは月末です。Stripe が確定した請求額と、自分のダッシュボードに出していた「今月の使用量」が、わずかに、しかし確実にズレている。数円のこともあれば、ヘビーユーザーで数百円のこともあります。
このズレは、コードのバグというより、分散した2つの台帳(Stripe 側の meter と自前 DB の使用量カウンタ)を別々に書き込んでいることから生まれる構造的なものです。今回は、その構造を前提にした上で、ズレを「ゼロにする」のではなく「検知して説明できる状態に保つ」ための運用を整理します。設定手順の解説ではなく、本番で課金を1年ほど回して効いた設計の話です。
まず前提:createUsageRecord はもう使わない
少し前まで、Stripe の従量課金は Subscription Item に対して subscriptionItems.createUsageRecord() を送る方式でした。今もマイグレーション期間として動きますが、新規実装では Billing Meters を使うのが現行の作法です。違いは小さく見えて運用上は大きく、meter event は「どの顧客の」「どの指標を」「いくつ」増やすかを、Subscription Item ID を意識せずに送れます。
// lib/claude-metering.ts
import Anthropic from "@anthropic-ai/sdk" ;
import Stripe from "stripe" ;
const anthropic = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
// 旧方式: stripe.subscriptionItems.createUsageRecord(itemId, {...}) ← 新規では使わない
// 新方式: meter に対して event を送る。顧客IDで紐づくので Subscription Item を引かなくてよい
async function sendMeterEvent ( params : {
stripeCustomerId : string ;
credits : number ;
identifier : string ; // 冪等キー(後述)
}) {
await stripe.billing.meterEvents. create ({
event_name: "claude_credits" ,
identifier: params.identifier,
payload: {
stripe_customer_id: params.stripeCustomerId,
value: String (params.credits),
},
});
}
event_name は Stripe ダッシュボードで作成した meter の名前と一致させます。meter 側で「value を月内で合算する(sum)」と設定しておけば、送った event がそのまま月間使用量として積み上がります。
トークンを直接送らず、「クレジット」を1枚かませる
ここが運用を楽にする最大のポイントです。Claude のトークン単価は入力と出力で違い、モデルでも違います。2026年6月時点で Sonnet 系と Opus 4.8、さらに上位の Fable 5 では桁が変わります。これを meter にトークン数のまま送ると、価格改定のたびに「過去の event の意味」が変わってしまい、請求ロジックが壊れます。
そこで、トークンを自社単位の クレジット に正規化してから送ります。モデルごとの単価差はクレジット換算係数に吸収させ、Stripe には常に「クレジット数」だけを渡す。こうしておくと、新モデルが出ても係数表に1行足すだけで済みます。
// モデルごとの「1クレジットあたりに丸める係数」を一箇所に集約する
// 値は2026年6月時点の概算。実際の単価は公式の料金ページで必ず確認すること
const MODEL_RATES : Record < string , { inPer1k : number ; outPer1k : number }> = {
"claude-sonnet-4-6" : { inPer1k: 0.3 , outPer1k: 1.5 },
"claude-opus-4-8" : { inPer1k: 1.5 , outPer1k: 7.5 },
"claude-fable-5" : { inPer1k: 3.0 , outPer1k: 15.0 },
};
// 1クレジット = サービス内部の最小課金単位。ここでは「概算0.1円相当」に寄せて整数化する
const YEN_PER_CREDIT = 0.1 ;
function tokensToCredits ( model : string , inTok : number , outTok : number ) : number {
const r = MODEL_RATES [model] ?? MODEL_RATES [ "claude-sonnet-4-6" ];
const yen = (inTok / 1000 ) * r.inPer1k + (outTok / 1000 ) * r.outPer1k;
// 切り上げ。端数を事業者側が負担しないが、1リクエスト最低1クレジットは保証する
return Math. max ( 1 , Math. ceil (yen / YEN_PER_CREDIT ));
}
なぜ整数に切り上げるのか。meter event の value は小数も扱えますが、自前 DB と突合するときに浮動小数の丸め差が生まれると、後述の reconciliation で「本当にズレているのか、丸め誤差なのか」が判別できなくなります。整数クレジットに寄せておくと、ズレが出たら必ず「件数の食い違い」として現れるので、原因を追いやすくなります。
冪等キーで、二重計上と取りこぼしを同じ仕組みで防ぐ
従量課金の事故は、ほぼ2方向しかありません。同じ使用を2回送る(過大請求)か、送ったつもりが届いていない(過少請求=持ち出し)か。Billing Meters の identifier は、この両方を1つの設計で抑えられます。同じ identifier の event は Stripe 側で重複として無視されるため、リトライを安全に何度でも撃てるようになります。
肝は「identifier を、リクエストの自然キーから決定的に作る」ことです。ランダム UUID にすると、リトライのたびに別 event になり二重計上します。私は「内部リクエストID」を identifier にしています。
import { createHash } from "crypto" ;
// 内部のリクエストIDから決定的に冪等キーを作る。リトライしても同じ値になる
function meterIdentifier ( requestId : string ) : string {
return "req_" + createHash ( "sha256" ). update (requestId). digest ( "hex" ). slice ( 0 , 32 );
}
// 使用量の記録は「自前台帳への書き込み」と「Stripeへの送信」を分離する
export async function recordUsage ( req : {
requestId : string ;
userId : string ;
stripeCustomerId : string ;
model : string ;
inTok : number ;
outTok : number ;
}) {
const credits = tokensToCredits (req.model, req.inTok, req.outTok);
// 1) まず自前台帳に確定で書く(これが一次情報。Stripeは二次的な集計先)
await ledger. insert ({
requestId: req.requestId,
userId: req.userId,
credits,
model: req.model,
reportedToStripe: false ,
createdAt: new Date (),
});
// 2) Stripe へ送る。失敗しても台帳には残っているので後で再送できる
try {
await sendMeterEvent ({
stripeCustomerId: req.stripeCustomerId,
credits,
identifier: meterIdentifier (req.requestId),
});
await ledger. markReported (req.requestId);
} catch (err) {
// 送信失敗は握りつぶさず、未送信フラグのまま残す。リトライワーカーが拾う
console. error ( "[meter send failed]" , req.requestId, err);
}
}
ここで意図的に自前台帳を一次情報 にしている点が重要です。Stripe を信頼の源にすると、送信失敗の瞬間にデータが消えます。台帳に先に書いておけば、Stripe への送信は「いつでも再実行できる派生処理」に格下げできます。identifier が冪等なので、再送が二重計上になる心配もありません。
ストリーミングでは、トークンが確定する瞬間を待つ
本番で一番ハマる落とし穴がここです。stream: true でレスポンスを流している間、トークン数は確定していません。途中で送信したくなりますが、接続が切れた場合に過少報告になります。私の環境では、ストリーム途中送信をやめて完了後に一括記録へ切り替えただけで、過少報告由来のズレが月あたり約3%から1%未満まで下がりました。この落とし穴への対処はシンプルです。
正解はシンプルで、ストリーム完了イベントで初めて usage を確定して記録する こと。Anthropic SDK のストリームは最終的に usage を含むメッセージにまとまるので、そこを待ちます。
const stream = anthropic.messages. stream ({
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
messages: [{ role: "user" , content: prompt }],
});
for await ( const event of stream) {
// ここではUI表示だけ。課金記録はまだしない
}
// ストリーム完了後に最終メッセージから usage を取り出して初めて記録する
const final = await stream. finalMessage ();
await recordUsage ({
requestId,
userId,
stripeCustomerId,
model: final.model,
inTok: final.usage.input_tokens,
outTok: final.usage.output_tokens,
});
リアルタイムで使用量バーを出したい場合は、UI 表示用に DB のカウンタを楽観的に増やしておき、Stripe への確定送信はストリーム完了後に回します。表示と課金を別系統にすることで、見た目の即時性と請求の正確さを両立できます。
月内に突合する — 月末に気づくと遅い
ここからが本題です。自前台帳と Stripe の meter は別々に積み上がるので、定期的に突合(reconciliation)してズレを早期に捕まえる 仕組みを必ず入れます。月末に初めて気づくと、請求は確定済みで打ち手が限られます。私は1日1回、顧客ごとに「自前台帳の当月合計」と「Stripe meter の当月集計」を比べています。
// 日次バッチ: 顧客ごとに自前台帳とStripe集計を突合する
async function reconcileCustomer ( stripeCustomerId : string , periodStart : number , periodEnd : number ) {
// 自前台帳の当月クレジット合計
const ledgerTotal = await ledger. sumCredits (stripeCustomerId, periodStart, periodEnd);
// Stripe meter の当月集計(meter ごとの event summary を取得)
const summaries = await stripe.billing.meters. listEventSummaries ( METER_ID , {
customer: stripeCustomerId,
start_time: periodStart,
end_time: periodEnd,
});
const stripeTotal = summaries.data. reduce (( s , x ) => s + x.aggregated_value, 0 );
const diff = ledgerTotal - stripeTotal;
if (diff !== 0 ) {
// ズレあり。符号で原因の方向がわかる
// diff > 0 : 台帳にあるのにStripeに届いていない(送信失敗の積み残し)
// diff < 0 : Stripeに多く積まれている(二重送信 or identifier設計ミス)
await alertReconcileDrift ({ stripeCustomerId, ledgerTotal, stripeTotal, diff });
}
return diff;
}
差分の符号 が、そのまま原因の切り分けになります。台帳が多ければ送信の取りこぼし(未送信フラグを拾って再送すれば回復)、Stripe が多ければ二重計上(identifier の決定性が崩れている)。この符号ルールを運用ドキュメントに1行書いておくだけで、深夜のアラートに落ち着いて対応できます。
突合で diff > 0 を見つけたら、未送信の台帳行を identifier 付きで再送します。冪等なので、既に届いていた分があっても二重にはなりません。これが「台帳を一次情報にする」設計の効きどころです。
突合で diff > 0 を見つけたときの復旧手順は、運用ドキュメントに次の3ステップで固定しておくことを推奨します。
当月分の未送信フラグが立った台帳行を抽出する(reportedToStripe = false)
各行を決定的な identifier 付きで meter event として再送する(冪等なので二重計上にならない)
再送に成功した行へ送信済みフラグを立て、翌日の突合で diff が縮むことを確認する
私はこの3ステップを推奨します。手作業の判断を挟まず、符号と未送信フラグだけで機械的に回せるからです。
使いすぎの安全弁は、Stripe ではなく自分側に置く
従量課金をユーザーに受け入れてもらうには「青天井ではない」という安心が要ります。上限判定は Stripe の集計を待たず、自前台帳のリアルタイムカウンタ で行います。Stripe の meter 集計には反映ラグがあるので、上限の門番には向きません。
判定したいこと 参照すべき場所 理由
今このリクエストを通すか(上限門番) 自前台帳のリアルタイムカウンタ Stripe集計にはラグがあり、即時判定に使うと超過を許す
今月いくら請求するか(確定金額) Stripe meter の集計 請求の正本はStripe。台帳は突合用の控え
ズレていないかの監査 両者の差分(日次) 符号で原因方向が割れる
役割を分けるのがコツです。「門番=自前台帳」「請求=Stripe」「監査=両者の差分」。この三層を混ぜると、ラグや丸めの問題が全部1箇所に集まって切り分け不能になります。
運用してみて、静かに効いている設計判断
私自身、個人開発で Dolice Labs の複数サイトにまたがって Stripe のメンバーシップを運用していますが、課金まわりで一番神経を使うのは、派手な障害よりも「気づかないうちに少しずつズレる」類の事象でした。台帳を一次情報にして日次で突合を回す体制にしてから、月末の請求確定で慌てることがなくなりました。ズレが出ても、符号を見て未送信を再送すれば収束する、という見通しが立つだけで、運用の心理的な負荷がかなり下がります。私の場合は、この構成にしてから月末の突合差分がほぼ常に1%未満で収まっています。
従量課金は「正しく実装する」だけでは足りず、「ズレることを前提に、ズレを説明できる状態を保つ」ところまで設計して初めて運用に乗ります。次の一歩としては、reconciliation の diff をメトリクスとして時系列で記録し、特定ユーザーで恒常的にズレていないかを見られるようにすると、identifier 設計の穴を早期に見つけられます。
実装で同じ突合の悩みに当たっている方の、設計判断の足場になれば幸いです。