Claude CodeでStripeの従量課金を実装 — メーターイベントの重複排除
Stripe Billing Metersへ使用量を送る処理を、identifierの固定、タイムスタンプ範囲の検査、請求プレビューとの突き合わせまで、Claude Codeに書かせて検証する手順です。
使用量をStripeへ送る処理で一番壊れやすいのは、再送です。ネットワークが切れて同じ使用量をもう一度送ると、顧客は二重に請求されます。かといって再送しなければ、取りこぼしが出ます。
この記事の結論は3つです。使用量は自前のDBに先に書く。そのレコードのIDをそのままメーターイベントのidentifierにする。月末に、自前の合計とStripeの請求プレビューを突き合わせる。以下では、この3点をClaude Codeに実装させ、重複や遅延を再現するテストで確かめるまでを書きます。
先に前提を一つ。Stripeのドキュメントは、新規の従量課金にはMetronomeを推奨し、Billing Meters(Meters API)は既存の実装向けと位置づけています。この記事は、すでにBilling Metersで請求している、またはMetersで始めると決めた実装が対象です。
新規ならMetersかMetronomeか
分かれ目は、料金体系の複雑さと取り込み量です。Stripeのドキュメントは、次のような場合に既存のMeters実装を維持する場合を除いてMetronomeを使うよう案内しています。
- 従量課金を新規に実装する
- 既存の定額制サブスクに従量制の料金を足す
- 前払いクレジットやクレジットの消費が必要
- エンタープライズ契約、コミット、ランプスケジュールが必要
- 使用量をリアルタイムに見せたい
- 大量のイベントを取り込む
Billing Metersは請求書の作成時にしか使用量が更新されません。画面に「今月の使用量」を出したい場合は、自前の集計を別に持つことになります。この記事の「自前DBに先に書く」構成は、その集計の土台にもなります。
すでにBilling Metersで請求している場合、Stripeは移行を求めていません。基本的な従量課金にはConnect、Checkout、Adaptive Pricing、Workflowsとの互換性が高いという利点も書かれています。
使用量は自前のDBに先に書く
Stripeへの送信を、使用量が発生した処理の中で直接呼ぶ実装は避けます。送信に失敗したとき、「使用量は発生したが送れていない」状態を表す場所がなくなるからです。
先に自前のテーブルへ書き、送信は別の処理が拾う構成にします。
CREATE TABLE usage_events (
id uuid PRIMARY KEY, -- これがStripeのidentifierになる
customer_id text NOT NULL, -- Stripeのcus_...
tokens integer NOT NULL,
occurred_at timestamptz NOT NULL, -- 使用が発生した時刻
sent_at timestamptz, -- Stripeが受理した時刻
status text NOT NULL DEFAULT 'pending' -- pending / sent / expired
);重要なのはidとoccurred_atの2列です。idは送信のたびに変わってはいけません。occurred_atは送信時刻ではなく、使用が起きた時刻にします。この2つを間違えると、次の節の重複排除も、その次の集計も崩れます。
identifierで重複排除する
メーターイベントの作成APIには、任意のidentifierパラメーターがあります。指定しなければStripeが自動生成します。つまり、指定しない実装では、再送は常に別のイベントになります。
仕様上のポイントは次のとおりです。
- UUIDのような一意の値を推奨している
- 一意性が強制されるのは、少なくとも24時間のローリング期間の中だけ
- 想定しているのは、短時間に起きる誤った再試行などの重複防止
後ろの2点が、設計に効きます。24時間を超えて再送すると、同じidentifierでも重複を弾いてもらえる保証がありません。障害が長引いたときの再送では、自前DBのstatusで「送信済みか」を判断する必要があります。Stripe側の重複排除は、自前の管理を置き換えるものではなく、短時間の再試行を守る保険だと考えるのが安全です。
送信コードは次の形になります。SDKに頼らず、REST APIをそのまま呼ぶ形にしています。
// src/billing/send-meter-event.ts
const ENDPOINT = "https://api.stripe.com/v1/billing/meter_events";
const MAX_AGE_SEC = 35 * 24 * 60 * 60;
type UsageEvent = {
id: string;
customerId: string;
tokens: number;
occurredAt: Date;
};
export async function sendMeterEvent(e: UsageEvent): Promise<"sent" | "expired"> {
const ts = Math.floor(e.occurredAt.getTime() / 1000);
const now = Math.floor(Date.now() / 1000);
// 35日より古いイベントはStripeが受け付ける範囲外
if (now - ts > MAX_AGE_SEC) return "expired";
const body = new URLSearchParams({
event_name: "hypernian_tokens",
identifier: e.id, // 再送でも必ず同じ値
timestamp: String(Math.min(ts, now)), // 未来の時刻は送らない
"payload[stripe_customer_id]": e.customerId,
"payload[value]": String(e.tokens),
});
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: "Basic " + btoa(process.env.STRIPE_SECRET_KEY + ":"),
},
body,
});
if (res.status === 429) throw new Error("rate_limited"); // 呼び出し側で指数バックオフ
if (!res.ok) throw new Error(`stripe ${res.status}`);
return "sent";
}event_nameとpayloadのキー名は、メーターの設定と一致させます。ここではStripeのガイドの例に合わせ、hypernian_tokens、stripe_customer_id、valueを使っています。メーターを作るAPI呼び出しは次のとおりです。
curl https://api.stripe.com/v1/billing/meters \
-u "$STRIPE_TEST_KEY:" \
-d "display_name=Hypernian tokens" \
-d event_name=hypernian_tokens \
-d "default_aggregation[formula]=sum" \
-d "customer_mapping[event_payload_key]=stripe_customer_id" \
-d "customer_mapping[type]=by_id" \
-d "value_settings[event_payload_key]=value"メーターは設定後に、表示名を除いて変更できません。集計式やイベント名を間違えると、作り直しになります。本番のメーターを作る前に、テストモードで集計式まで確認してください。
タイムスタンプと集計ウィンドウ
timestampは省略すると現在時刻になります。指定する場合は、35暦日前から、5分後までの範囲に収めます。5分の猶予は、サーバーとStripeのクロックのずれを吸収するためのものです。
ここで落とし穴になるのが、受理と請求は別だという点です。35日以内なら受け付けられますが、請求に反映されるとは限りません。サブスクのサービス提供期間の使用量は、請求が確定する前に報告する必要があります。確定後に受け付けられてメーターサマリーに入っても、確定済みの請求は更新されず、次回の請求にも足されないことがあります。
集計の仕方は、メーター側の2つの設定で決まります。
| 設定 | 選択肢 | 実装への影響 |
|---|---|---|
| 集計式 | 選択肢合計 / 件数 / 最終 | 実装への影響合計はvalueを足す。件数はイベント数を数える。最終は最新の値だけを使う |
| イベントの取り込み | 選択肢未加工 / 事前集計 | 実装への影響未加工は同じタイムスタンプのイベントも上書きしない。事前集計は、毎時または毎日の枠(UTC)の中で最後に届いたイベントが前のものを上書きする |
トークン数を足し上げる今回の例は「合計」と「未加工」の組み合わせです。この組み合わせでは、identifierが違えば、同じタイムスタンプのイベントも別々に加算されます。だからこそ、再送のたびにidentifierを変える実装は、そのまま二重請求になります。
事前集計を選ぶと、挙動が変わります。1時間ごとに合計を送り直す設計なら、同じ枠への再送は上書きになり、二重計上されません。ただしその設計では、枠の境界をUTCで揃える責任が送信側に移ります。ローカルタイムゾーンで時間を切って送ると、枠がずれます。
Claude Codeに書かせる
ここまでの仕様を、実装の前にClaude Codeへ渡します。Claude Codeのドキュメントは、変更を確認してから反映したいときのためにプランモードを案内しています。claude --permission-mode planで起動するか、セッション中にShift+Tabで切り替えます。承認するまで、ファイルは編集されません。
claude --permission-mode plan最初の指示は、実装でなく設計の確認にします。
usage_eventsテーブルとStripeのメーターイベント送信を実装したい。
次の制約を満たす設計案を出して。コードはまだ書かない。
- identifierはusage_events.idをそのまま使い、再送で変えない
- timestampは使用が発生した時刻(occurred_at)。送信時刻にしない
- 35日より古い行は送らずstatus=expiredにする
- 429は指数バックオフ。それ以外の失敗でもidentifierは変えない
- Stripeのシークレットキーはテストモードのものだけを使う設計案を読んで、identifierとtimestampの扱いが指示どおりかを確認してから承認します。ここで外れていたら、実装に入る前に直せます。
守らせたい規約はCLAUDE.mdにも短く置きます。長いCLAUDE.mdは、毎回のセッションで読み込まれるぶん効きが落ちます。Claude Codeのベストプラクティスも、コードから読み取れないことだけを書くよう勧めています。
## Stripe従量課金
- メーターイベントのidentifierは必ずusage_events.id。生成し直さない
- timestampはoccurred_at。Date.now()を使わない
- テストはStripeを叩かず、fetchをモックに差し替えるさらに、シークレットキーを.envから読ませたくない場合は、Readの拒否ルールで止められます。Claude Codeの権限ドキュメントにはRead(./.env)の書式が載っています。
{
"permissions": {
"deny": ["Read(./.env)", "Read(./secrets/**)"]
}
}実キーを渡さず、テストはモックで回す前提にすると、Claude Codeが誤って本番のメーターに使用量を送る経路そのものがなくなります。stripe listenの秘密鍵の扱いはStripe CLIのwebhook開発の記事にまとめています。
重複と期限切れを再現するテスト
実装が終わったら、Stripeの仕様に合う失敗をテストで作らせます。公式のワークフロー集では、テストを書かせるときに「どの挙動を確かめたいか」を具体的に伝えること、境界値やエラー条件の洗い出しを頼むことが勧められています。今回確かめたいのは次の3つです。
sendMeterEventのテストを追加して。fetchはモックにする。
1. 1回目の送信が失敗し、同じ行を再送したとき、2回のリクエストで
identifierが同一であること
2. occurredAtが36日前の行はfetchを呼ばずにexpiredを返すこと
3. occurredAtが10分後の行でも、送るtimestampが現在時刻を超えないこと
テストを実行して、失敗したら実装を直して。1番目が、二重請求を防ぐ中心のテストです。identifierを送信のたびに生成する実装に退行すると、このテストが落ちます。
テストの実行は、フックで自動化すると手戻りが減ります。ファイルを書き換えるたびに課金テストが走り、失敗が次の修正の入力になります。設定の書き方はフックでテストを自動実行する記事にあります。webhookの受信側の重複対策はStripe webhookの冪等受信の記事で扱っています。
請求プレビューと自前の合計を突き合わせる
送れたかどうかは、Stripeの返答では分かりません。送信が受理されても、集計は非同期に行われます。確かめる先は2つあります。
1つ目は、メーターイベントサマリーです。顧客、メーター、時間枠を指定して、集計済みの使用量を取れます。
curl -G https://api.stripe.com/v1/billing/meters/$METER_ID/event_summaries \
-u "$STRIPE_TEST_KEY:" \
-d customer=$CUSTOMER_ID \
-d start_time=1717249380 \
-d end_time=17172494402つ目は、プレビュー請求書です。サブスクリプションを指定すると、次回の請求書の見込みが返ります。
curl https://api.stripe.com/v1/invoices/create_preview \
-u "$STRIPE_TEST_KEY:" \
-d subscription=$SUBSCRIPTION_ID突き合わせの手順は次のとおりです。
月末の突き合わせ手順
- 1
自前の合計を出す
usage_eventsのうち、対象期間のoccurred_atを持つsent行のtokensを顧客ごとに合計します。 - 2
サマリーと比べる
同じ期間のイベントサマリーの値と比べます。差が出たら、
pendingのまま残っている行がないかを先に調べます。 - 3
プレビュー請求書の金額と比べる
価格設定から期待金額を計算し、プレビュー請求書の金額と照らします。
価格の例を使うと、計算の形が分かります。Stripeのガイドの例は、100トークンあたり0.04 USDです。価格の作成時はunit_amount=4、transform_quantity[divide_by]=100、transform_quantity[round]=upを指定します。期間の合計が3,000トークンなら、30パッケージで1.20 USDになるはずです。この計算は価格設定から組んだ例で、最終的な請求額の判定はプレビュー請求書の値に従ってください。
差が出る原因の一つが、非同期処理です。Stripeは、直近に受信したイベントが次回の請求書に即時には反映されない場合があると説明しています。送信した直後に比べるのではなく、少し置いてから比べる運用にします。
もう一つが、確定の猶予期間です。請求にはデフォルトで1時間の確定猶予期間があり、その間は前の請求期間の使用量を報告し続けられます。設定で最長72時間まで延ばせます。ただし、猶予期間中の報告が請求書に入るのは、サブスクサイクルの請求と、サブスクスケジュールのフェーズ移行の請求だけです。しきい値到達時の請求には、請求が作られた時点までの使用量しか入りません。月末の送信バッチは、この猶予期間より前に終わるようにスケジュールを組みます。
誤りの訂正とエラーの検知
誤って送った使用量は、メーターイベント調整で取り消せます。取り消しにidentifierが要るので、自前DBにIDを持っていることがここでも効きます。
curl https://api.stripe.com/v1/billing/meter_event_adjustments \
-u "$STRIPE_TEST_KEY:" \
-d type=cancel \
-d event_name=hypernian_tokens \
-d "cancel[identifier]=$USAGE_EVENT_ID"取り消せるのは、過去24時間以内に送ったイベントだけです。確定済みの請求書に入った使用量をキャンセルしても、Stripeはその請求書を更新せず、修正請求書も出しません。24時間を過ぎた誤りは、数量をマイナスで記録して打ち消す方法があります。サイクル全体の使用量がマイナスになった場合、Stripeは請求書の項目の使用量を0として報告します。
送信が受理された後に起きるエラーは、APIの応答に出ません。Stripeはメーターイベントを非同期に処理し、無効なイベントを見つけるとv1.billing.meter.error_report_triggeredかv1.billing.meter.no_meter_foundのイベントを作ります。どちらも薄いペイロード(thin)形式です。エラーコードにはmeter_event_customer_not_found、timestamp_too_far_in_past、timestamp_in_future、meter_event_invalid_valueなどがあります。
つまり、送信が成功しても、status = sentは「Stripeが処理できた」を意味しません。この2つのイベントを受ける送信先を作り、エラーを自前DBに書き戻す処理まで入れて、はじめて取りこぼしを検知できます。
よくあるつまずき
再送のたびにidentifierを生成してしまう
AIが書いた送信関数が、関数の内部でcrypto.randomUUID()を呼んでいる形です。1回の呼び出しの中では正しく見えますが、再送すると別のIDになります。CLAUDE.mdの規約と、先ほどの1番目のテストが、この退行を止めます。
タイムスタンプに送信時刻を使う
滞留していた行を後でまとめて送ると、使用が発生した月とは別の月に計上されます。occurred_atを使い、35日の範囲外は送らずにexpiredにする運用にします。
単位を間違える
ガイドの価格では、unit_amount=4は4セント、つまり0.04 USDを指します。USDで1ドル単位と読み違えると、桁が合いません。突き合わせで金額がずれたときは、まずunit_amountとtransform_quantityの読み方を疑います。
高頻度の送信でレート制限に当たる
本番のMeter Eventエンドポイントには、Stripeアカウントごとに1秒あたり1,000回という個別の制限があります。さらに、メーターと顧客の組ごとに同時に1回の呼び出しに制限されます。個々の操作ごとにイベントを送るより、複数の操作を累積して1つの集計イベントを定期的に送る設計のほうが、制限に収まりやすくなります。サンドボックスでは、この呼び出しがグローバルなレート制限に数えられる点も違います。
まとめ
メーターイベントの二重計上は、identifierを固定し、自前のDBで送信済みかどうかを持つことでほぼ防げます。残るのは、タイムスタンプの範囲、確定の猶予期間、非同期のエラーという、送信の外側で起きる問題です。Claude Codeに任せる場合は、これらをCLAUDE.mdの規約とテストに落とし、プランモードで設計を先に読むところから始めると、手戻りが小さくなります。
サブスクの状態管理と組み合わせる場合は、Stripeのサブスク同期の実装も併せて読んでください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。