stripe-billing について
stripe-billingは、Stripeのサブスクリプションと課金を実装する開発者向けスキルです。決済イベント、権限、アプリデータを連携部分で整合させる方法を扱います。
主な機能
- 01Webhookの処理と重複排除
- 02チェックアウトとサブスクリプションのライフサイクル
- 03権限付与と更新のルール
- 04価格とプロダクトの整合性チェック
- 05テストとローカル開発のガイド
活用シーン
- 01サブスクリプションのチェックアウトと検証の実装
- 02Webhookの重複による権限付与の防止
- 03更新、プラン変更、解約への対応
- 04Stripeとアプリコードの価格の照合
MCP 対応
Stripe公式のMCPサーバー、CLI、エージェントスキルを先に使うことを推奨し、それらが扱わない連携上の課題に焦点を当てています。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ ssheleg/ sheleg-dev --skill stripe-billingインストール後、Claude Code で/stripe-billingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
fixtures/assert-money-invariants.mjscharge-refunded-partial.jsoncharge-refunded-remainder.jsoncheckout-session-async-payment-failed.jsoncheckout-session-completed-async-unpaid.jsoncheckout-session-completed-paid.jsoncustomer-discount-created-retention.jsoncustomer-subscription-updated-cancel-classic.jsoncustomer-subscription-updated-cancel-flexible.jsoncustomer-subscription-updated-discount-consumed.jsoninvoice-paid-subscription-cycle-next-period.jsoninvoice-paid-subscription-cycle-redelivery.jsoninvoice-paid-subscription-cycle.jsoninvoice-paid-subscription-update-proration.jsonmanifest.jsonREADME.mdreference-handler.mjsreferences/cancellation-and-retention.mdprice-integrity.mdprovider-concentration.mdstripe-agent-toolchain.mdsubscription-lifecycle.mdtesting-and-local-dev.mdwebhook-events.mdscripts/assert-stripe-env.mjs詳細
Stripe billing
Stripe が資金を管理します。権利情報はデータベースで管理します。重大な課金不具合はどれも、2つのシステムにある事実が一致しなくなったことで発生します。価格、数量、期間、返金額などです。
Stripe API 自体がインテグレーションの失敗原因ではありません。失敗は境界部分で起きます。コールバックが二重に届いた場合、0.40 ドルの按分請求書で1か月分のサービス利用権を付与してしまった場合、アップグレードでカードへの課金後にレコードの書き込みに失敗した場合などです。
このスキルが扱うのは、その境界部分です。使用するStripeのプリミティブについては、公式のstripe-best-practicesスキルが基準となり、意見が異なる場合はそちらを優先します。検索にはstripe-docsを使用します。例はTypeScriptですが、ルールは言語に依存しません。
必要に応じて読み込む詳細情報:
| 読むもの | タイミング |
|---|---|
references/stripe-agent-toolchain.md | 何もない状態から始める場合や、APIの形式を推測する前。CLI、MCP、スキルインデックス、キーの取り扱い |
references/webhook-events.md | ハンドラーを作成またはレビューする場合。イベントカタログ、ペイロードの形式、順序、障害時の動作 |
references/subscription-lifecycle.md | チェックアウト、検証、更新、シート、プラン変更、トライアル、権利の取り消しを実装する場合 |
references/cancellation-and-retention.md | 解約や再開を実装する場合、または解約手続きでクーポンや一時停止を提示する場合 |
references/price-integrity.md | 価格が複数のファイルに記載されている場合、または表示価格がStripeの価格と一致することを証明する必要がある場合 |
references/testing-and-local-dev.md | ローカルのWebhookやモック、およびすでに作成済みのテスト一式であるfixtures/を使う場合 |
references/provider-concentration.md | 収益が増加した場合、第2市場を開拓する場合、非自発的な解約を分離する場合、または決済アカウントに制限がかかった場合 |
まずStripe独自のツールを使う
Stripeは独自のMCP サーバー、CLI、エージェントスキルを提供しています。APIは毎月変更されるため、まずはこれらを利用してください。このスキルが扱うのは、それらではカバーされない境界部分です。どのシグナルが支払いを示すのか、リダイレクトによって何が証明されるのか、資金が二重に計上されるのはどこか、といったことです。コマンドについてはreferences/stripe-agent-toolchain.mdを参照してください。
2つの台帳
browser your server Stripe
│ choose plan ──────────►│
│ │ create checkout session ──►│
│◄──────── url ───────────│◄──── session + hosted page ─│
│ pays on Stripe's page ─┼────────────────────────────►│
│◄─ redirect (proves nothing) ──────────────────────── │
│ │◄──── webhook (at-least-once)│
│ │ write entitlement │
│ │◄──── webhook (again, later) │
└── nightly: reconcile ─────►│
金額、税金、請求書、督促、支払い方法はStripeの管理下にあります。誰が何を使えるか、シート数はいくつか、どれだけのクレジットを付与したかは自分の管理下にあります。両者を結び付けるのはIDだけです。それ以外の情報は使いません。
各事実の保存場所は1か所だけにします。 Stripeから金額をコピーしてコードに記載すると、乖離に対する責任を負うことになります。チェックアウトから送られるのは価格IDで、金額を保持しているのはStripeであるため、乖離は気付かれないまま発生します。金額が間違っていてもリクエストが失敗することはなく、顧客に誤った金額が表示されるだけです。詳細はreferences/price-integrity.mdを参照してください。
クライアント
// Copy the linked assert-stripe-env.mjs beside this client (see price-integrity.md).
import { assertStripeEnv } from "./assert-stripe-env.mjs";
let client: Stripe | null = null;
export function getStripe(): Stripe {
if (!client) {
const key = process.env.STRIPE_SECRET_KEY;
assertStripeEnv(process.env.STRIPE_PRODUCT_MODE, key);
// Pin checked 2026-08-30 — 2026-08-26.dahlia was already newer. Confirm
// against Stripe's changelog before pinning; this line goes stale monthly.
client = new Stripe(key, { apiVersion: "2026-07-29.dahlia", maxNetworkRetries: 2 });
}
return client;
}
- モジュールレベルではなく、遅延初期化します。 インポート時に
new Stripe(...)を実行すると、キーなしでモジュールをインポートするすべてのビルド手順がクラッシュします。 apiVersionを固定します。 固定しないと、レスポンスの形式は自分の予定ではなくStripeのスケジュールで変わります。アップグレードは作業項目(stripe:upgrade-stripe)であり、デプロイ当日の不意打ちではありません。- 手書きのループではなく、
maxNetworkRetriesを使います。 SDKはリクエストごとに冪等性キーを生成します。これが、書き込みの再試行を安全に行う唯一の方法です。subscriptions.createをループで囲むと、1つの処理意図に対してサブスクリプションが2つ作成されます。意図的に再試行する場合は、独自の安定した{ idempotencyKey }を渡してください。
商品、価格、2つのモード
チェックアウトでは価格を指定しますが、コードでは商品を扱うようにします。価格は改定時に置き換わりますが、商品は変わりません。
- 顧客が選択できるプランごとに商品を1つ用意します。 1つの商品に複数の価格を設定するのは、同じプランのバリエーション(月額と年額、通貨ごと)に限ります。階層ごとに商品を分けないと、すべての請求書明細に同じ名前が表示され、コードの残りの部分が依存する商品 ID → プランのマッピングが壊れます。
- 価格 ID は設定に固定し、
prices.list({ active: true })はフォールバックとして使います。 価格改定後は1つの商品に有効な価格が2つ存在しますが、その順序は保証されていません。 - 検証用の許可リストには、両方のモードの ID が必要です。 データベースにはテスト環境と本番環境の両方で書き込まれた行があります。現在のモードだけを使って「この商品は販売中か」を確認する Webhook は、実際の履歴を拒否してしまいます。
resource_missingはほぼ常にモードの不一致を意味します。 これを捕捉してモードとキーのプレフィックスを明示してください。そのままでは「商品が削除された」と読めてしまいます。
顧客の取得または作成は競合します
2つのタブ、2つのリクエスト、2人の顧客が作成され、2人目が1人目の請求対象となるサブスクリプションを気づかれないまま所有することになります。顧客を作成してから、条件付き更新で ID を確保します。更新対象の行がなければ競合に負けたということなので、孤立した顧客を削除し、勝者の ID を読み取ります。作成する前に保存済みの ID を取得し、resource_missing または deleted: true は「新しく作成する」と扱ってください。これはキーのローテーション後に通常起こる状態です。コードは
references/subscription-lifecycle.md にあります。
チェックアウトセッション
const metadata = { userId, productId, quantity: String(qty) };
await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: qty }],
// Never pass payment_method_types — Stripe picks eligible methods from
// Dashboard settings; hardcoding ['card'] locks out methods that convert.
metadata, // reaches checkout.session.completed
subscription_data: { metadata }, // reaches EVERY later subscription event
success_url: `${origin}/after?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/plans`,
});
- メタデータは2か所に書き込みます。 セッションのメタデータはサブスクリプションには引き継がれません。更新時の請求書とすべての
customer.subscription.*イベントにはサブスクリプションが含まれます。1年後にはセッションはなくなり、手元に残る唯一のuserIdはsubscription_data.metadataにあるものです。 - 呼び出し元が指定した戻り先 URL は、自分のオリジンに対して検証します。 リクエスト本文の
successUrlをそのまま渡すエンドポイントは、決済フローを装ったオープンリダイレクトです。 - 重複は決済が発生する前に防ぎます。 同じ商品への有効なサブスクリプションがあれば、既存の ID とともに 409 を返します。1つのシートに対して2つ目の有効なサブスクリプションが作成されると、返金の話になります。
{CHECKOUT_SESSION_ID} は Stripe によって置換されます。自分でレンダリングするものではありません。2026-03-25.dahlia+ では、integration_identifier ラベルを使うと Dashboard でフローを比較できます(2026-08-30 に Stripe の変更履歴を確認)。
3つの判断事項は stripe-best-practices にあります。従量課金(新規のものには Metronome を使用します。Billing Meters は低レベルのプリミティブです)、税金(登録がなければ automatic_tax は何も徴収しません。有効化してもコンプライアンス対応にはなりません)、そして資金を第三者に送る場合の Connect です。
Webhook が決済です
export async function POST(request: Request) {
const body = await request.text(); // RAW — not parsed JSON
const signature = request.headers.get("stripe-signature");
if (!signature) return json({ error: "missing signature" }, 400);
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return json({ error: "invalid signature" }, 400); // no detail — it is an oracle
}
const claim = await claimEvent(event.id); // INSERT on a primary key
if (claim === "completed") return json({ received: true, duplicate: true });
if (claim === "in_flight") return json({ error: "in flight" }, 500); // retry later
try {
await handle(event);
} catch {
await releaseEventClaim(event.id); // let the retry back in
return json({ error: "handler error" }, 500);
}
return json({ received: true });
}
- 生の本文を使います。 解析済みの本文を再シリアライズするとバイト列が変わり、署名の検証に失敗します。このルートでは自動解析を無効にしてください。
- このパスだけを、完全一致で CSRF とセッション認証の対象外にします。
/api/billingに対するプレフィックス一致では、決済そのものであるチェックアウトまで対象外になります。 - 処理前に確保します。確保は受領の記録であり、処理の完了ではありません。
SELECTの後にINSERTすると競合が起きます。40 ms 間隔で届いた2つの配信が、どちらもクレジットを付与する可能性があります。付与処理のトランザクションがcompletedにするまでは、行の状態をprocessingのままにします。状態、期限切れ、引き継ぎ、解放については、references/webhook-events.mdを参照してください。 - 正確に応答します。 処理済みまたは重複なら 200、不正な署名なら 400、再試行が必要なら 5xx、処理しない種類なら 200 を返します。再試行を止めるために失敗時に 200 を返してはいけません。決済を黙って破棄することになります。
- ハンドラー内の順序: 失敗する可能性のある外部呼び出しを先に行い、その後、1つのトランザクションで権限付与、重複排除マーカー、完了状態、アウトボックス行を処理します。その後、サイドエフェクトをアウトボックスから取り出して処理し、それぞれ独自のコンシューマーキーを使います。
イベントごとの詳細は、references/webhook-events.md を参照してください。
リダイレクトがブラウザー経由であることを証明します
カード決済が完了すると Stripe はリダイレクトします。Webhook が届くタイミングは一定ではありません。その間、ユーザーは成功ページを表示していますが、データベースには何も反映されていません。
成功ページから呼び出す 検証エンドポイント を用意します。セッションを取得し、metadata.userId が呼び出し元と一致すること、status === "complete" であること、payment_status !== "unpaid" であることを確認してから、Webhook が実行する書き込みを まったく同じように 行い、一意制約に処理を委ねます。競合に負けた側は重複キーエラーを捕捉し、成功として応答します。
所有者の確認はセキュリティ上、非常に重要です。これがなければ、cs_… ID を知った認証済みユーザーなら誰でも、他人の購入を自分のものとして申告できてしまいます。これはローカル開発を機能させる唯一の方法でもありますが、あくまで安全網であり、主経路にしてはいけません。タブを閉じたユーザーにも、支払い済みの商品を必ず提供する必要があります。
更新:billing_reason で判断します
invoice.paid は複数の異なる状況で発生します。これらすべてで商品を付与することが、このドキュメントで最も高くつく間違いです。
billing_reason | 内容 | 付与するか |
|---|---|---|
subscription_create | 初回請求書 | いいえ — Checkout で処理済みです |
subscription_cycle | 更新 | はい |
subscription_update | 期間途中の按分 | いいえ |
manual、subscription_threshold | あなた、またはメーターが発行した請求書 | 明示的に判断します |
数量を変更すると、subscription_update がすぐに発生します。この経路で付与すると、シートを4回追加して削除したユーザーに、按分請求書4件分として4か月分の商品を付与することになります。
サブスクリプション項目から期間を読み取ります —
sub.items.data[0].current_period_start / current_period_end。最近の API バージョンで場所が変更されました。古いトップレベルのフィールドを読むコードは undefined を取得し、エラーもなくエポック日付を保存します。
付与処理と同じトランザクション内で確認するマーカー(lastGrantedPeriodStart、または請求書 ID をキーにした監査行)を使って付与をガードします。これにより、Webhook と照合ジョブが同じ期間を重複して付与するのを防げます。
billing_mode — 作成時に行う一方向の選択
Stripe は、すべてのサブスクリプションを2種類の請求モードのいずれかで作成します。選択は subscriptions.create(または Checkout セッションの subscription_data)で行い、元に戻せません。Stripe は新規サブスクリプションには flexible を推奨しています。
次のセクションで説明する計算方法が変わるため、この項目をここで取り上げます。flexible モードでは、クレジットの按分計算に当初引き落とされた金額が使われるため、classic では1件だったクレジット按分が、1回の変更で複数件発生することがあります。按分明細の [0] を取得するコードは、取り消し処理で気づかれないまま壊れます。ここで誤った金額が発生すると、実際の損失につながります。また、予約済みのキャンセルをどのフィールドに記録するかも、この設定で決まります。モードの選択は、stripe-best-practices が担うプロダクト上の判断です。
シートと按分
proration_behavior:always_invoiceはすぐに請求し、create_prorationsは次の請求書まで延期し、noneは何も調整しません。取り消しにはnoneが適切です。補償的な取り消しを含む呼び出しはreferences/subscription-lifecycle.mdに記載されています。- アップグレードでは
payment_behavior: "error_if_incomplete"を指定します。 これがないと、カードが拒否されてもサブスクリプションはアップグレード済みとなり、未払いのまま、データベースではアップグレードが反映されます。StripeCardErrorを捕捉し、402 を返してください。 - 書き込み順序: まず Stripe、次にデータベースです。データベースへの書き込みに失敗した場合は、
proration_behavior: "none"で Stripe を取り消し、取り消しの失敗を必ず目立つ形で記録します。これは人が修正しなければならない唯一の状態です。逆の順序では、Stripe が販売していないシートに対して請求することになります。 - 数量にはサーバー側で上限を設け、最低値を1にします。最後のシートを削除する場合はキャンセルとなるため、その経路で処理します。現在の利用数を下回る数量への削減は、ビジネス上の判断です。解放する必要があるものを示して409を返し、どれを選ぶかはユーザーに委ねます。
キャンセルと、それを思いとどまらせる特典
- デフォルトは期間終了時です — ユーザーは支払い済みの内容を引き続き利用でき、
statusはactiveのままです。flexiblebilling_modeでは、ポータルからキャンセルするとcancel_atが設定され、cancel_at_period_endは false のままです。「キャンセル予定かどうか」は両方のフィールドから判断し、ブール値だけで判断しないでください。 - 解約処理は
customer.subscription.deletedで行ってください。API 呼び出しのすぐ横では行わないでください。そうすれば、UI、ポータル、督促のいずれにも同じ処理経路を使えます。 invoice.payment_failedではpast_dueに設定して通知してください。キャンセルはしないでください。再試行と最終状態は Stripe の督促処理に任せます。- 特典を提示する対象の条件は、独自に管理してください。 Stripe は「この顧客はすでに割引を受けたか」に答えられません。
duration=onceの割引は確定時にsubscription.discountsに残るため、適用対象かどうかは独自に記録してください。
両方のコードは次のとおりです。
references/cancellation-and-retention.md。
返金額は累積値で届きます
charge.amount_refunded は今回の返金額ではなく、これまでの返金総額です。2 回の一部返金では、4000、次に 9000 が届きます。これを増分として読み取ると、90 ドルの請求に対して 130 ドルを払い戻すことになります。保存済みの合計額との差分を計算し、比較交換で書き込んでください。これにより、同時に届いたイベントの片方は処理されず、二重に払い戻すことを防げます。コードは
references/subscription-lifecycle.mdにあります。
返金の対象は、一回限りの支払い(payment_intent で検索)か、サブスクリプションの請求書(購入レコードはありません。charge.invoice → invoice → subscription とたどります)のどちらかです。両方に対応してください。対応しないと、サブスクリプションの返金が顧客に商品を持ち続けさせることになります。
照合
Webhook はベストエフォートであり、障害は仮定の話ではありません。status: "all" を指定してサブスクリプションを一覧取得し、存在しないものを作成して、ステータス、期間、数量、価格に差異があれば更新するジョブを実行してください。各反復処理ではトランザクションを開始し、外部 API を呼び出す場合もあるため、逐次実行してください。付与処理では、Webhook と同じ冪等性マーカーを再利用してください。そうしないと、夜間ジョブが毎晩のプレゼントになってしまいます。
重要なガードは、Stripe に存在しないサブスクリプションのローカルレコードをキャンセル済みにすることです。ただし、もともと Stripe のものではないレコードは除外してください。無料提供、手動管理、その他のプロバイダーのプランには合成 ID が使われており、それらをキャンセルすると自ら障害を引き起こすことになります。コードと、この処理を兼ねる「今すぐ同期」ボタンについては、
references/subscription-lifecycle.mdを参照してください。
金額は最小通貨単位で扱います。境界で一度だけ変換し、整数のセント単位で比較してください。浮動小数点数では Math.abs(20.83 - 20.84) > 0.01 が true になります。
1 つのプロバイダーへの依存
カード決済を 1 つのプロバイダーに依存すると、収益の単一障害点になります。この判断は、このスキルではなく事業側が行うものです。コードが備えるべきなのは、決済処理をインターフェースの背後に置き、顧客 ID を自社で管理し、Stripe オブジェクト ID だけを支払いアカウントのキーにしないための接点です。その根拠、移行の形態、コストについては
references/provider-concentration.mdを参照してください。
ローカル開発とテストマトリックス
いずれも references/testing-and-local-dev.md に記載されています。stripe listen --forward-to、CLI のトリガー動詞、テストカードと拒否コード、更新や日割り計算をテストするための時計操作などです。始める前に知っておくべきことが 1 つあります。stripe listen が表示する署名シークレットはダッシュボードのものとは異なります。間違ったシークレットを使うと、キーの問題のように見える形で検証に失敗します。
劣化
- Claude Code ではない場合(Cursor、Codex、skills CLI、API コンテナ):このパックに含まれる
PreToolUseの決済ゲートは存在しません。返金、支払い、異議申し立ての終了、または有効なsk_live_…キーを 代わりに拒否するものはありません。以下のルールはすべて引き続き有効ですが、今度は前提条件ではなく助言となるため、 承認の手順は人が行うことになります。決済に関する操作を行う前に、どの環境を使っているか伝えてください。 - プラグインではなくコピーしてインストールした場合:同じ制約があり、対処法も同じです。単なる
~/.claude/skills/のコピーには方針は含まれますが、hooks/money-gate.jsは含まれません。README にはカテゴリと承認方法が 記載されていますが、フックがなければ、その承認を実際に誰かが言葉にする必要があります。 - Stripe CLI がない、ネットワークがない、またはテストキーのみの場合:Webhook の署名検証と ローカルでの再生ループにはバイナリが必要です。何が不足しているか一度伝えたうえで、残りを進め、 検証できなかった内容を記録してください。検証できたかのように示してはいけません。
リリース前に
これは二つ目のチェックリストではありません。以下の各ルールにはすでに上のセクションがあり、 一つのルールが二か所にあると、どちらかで内容がずれていきます。失敗するとエラーページではなく 金銭的な問題につながる、次の四つを確認してください。
- Webhook が支払いです。 リダイレクトを根拠に権限を付与してはいけません(§ Webhook が 支払いです、§ リダイレクトが証明するのはブラウザーの動作です)。
- 未加工の本文に対して署名を検証してください。 本文を解析すると検証に失敗しますが、その理由は キーの問題のように見えます。
- すべてのハンドラーで
event.idに基づく冪等性を確保してください。 Stripe は再試行します。 再試行によって二重に権限を付与すると、返金の対応が必要になります。 - 定期的に照合してください。 Webhook が届かなければ、支払い済みの顧客がアクセスできないままになり、 そのことを示すログもありません(§ 照合)。
セキュリティチェックリストの全文は references/stripe-agent-toolchain.md にあります。以前はそこに記載されていた
落とし穴はすべて、各ルールの該当箇所に記載されています。それが、内容を正確に保てる唯一の場所です。
トリガー
ここに案内されるその他のフレーズ:「invoice.paid」、「proration」、「refund」、「retention coupon」、 「キャンセル時の割引」、「サブスクリプションの請求」、「支払い」。
0.16.2 で説明文からこちらに移動したフレーズ:「Stripe チェックアウト」、「サブスクリプションの請求」、 「サブスクリプションをキャンセル」、「サブスクリプションによる支払い」、「Stripe の Webhook」。