Claude CodeでStripe Checkoutとカスタマーポータルを導入する手順
Stripe Checkoutの購入完了をwebhookで反映し、success_urlは画面遷移だけに使う設計を、Claude Codeで実装する手順です。カスタマーポータルの設定項目と制限も扱います。
Stripe Checkoutを導入するとき、最初に書きがちなのはsuccess_urlのページで会員権限を付ける処理です。動きますが、これが事故の入口になります。決済は成功したのに、ページが開く前に顧客の回線が切れれば、権限は付きません。
権限を付ける場所はwebhookです。success_urlは、買った人にお礼の画面を見せるためだけに使います。この記事は、その分担をClaude Codeに守らせながら、CheckoutとカスタマーポータルをSaaSに組み込む手順を示します。
権限付与をsuccess_urlに置かない理由
Stripeの公式ガイドは、フルフィルメント(購入内容を顧客に提供する処理。会員権限の付与や商品の発送)にwebhookが必要だと明記しています。顧客が決済後のランディングページを必ず開くとは限らないからです。サブスクや、入金が遅れて確定する決済手段では、Checkoutセッションの完了後に状態が変わるため、なおさらwebhookが前提になります。
ランディングページからもフルフィルメントを走らせる方法はあります。公式の推奨は、webhookと併用することです。顧客が画面を見ている間に素早く権限を付けるための補助で、保証はwebhook側が担います。
権限を付ける場所を移す
success_urlのページで付与
決済後のリダイレクトが届いたときだけ権限が付きます。回線切れやタブを閉じる操作で、払ったのに権限が付かない人が出ます。
webhookで付与
Stripeがサーバーへ直接イベントを送ります。顧客の画面に左右されず、失敗時はStripe側で再送されます。success_urlは表示専用です。
同じCheckoutセッションに対して、フルフィルメント関数は複数回、ときには同時に呼ばれます。関数は同じセッションIDで何度呼ばれても結果が変わらないように作ります。重複配信そのものの受け止め方はStripe webhookの冪等受信で扱っているので、ここではCheckout固有の部分に絞ります。
Claude Codeに渡す前の準備
課金のコードは、Claudeに任せる範囲を先に絞ります。.claude/settings.jsonで、本番キーと本番向けのコマンドを閉じておきます。
{
"permissions": {
"deny": [
"Read(./.env*)",
"Bash(stripe * --live*)"
],
"allow": [
"Bash(npm test *)",
"Bash(stripe trigger *)"
]
}
}権限ルールは、deny、ask、allowの順に評価され、最初に一致したものが採用されます。denyに入れたものは、より狭いallowでも通せません。一方で、Bashのルールは書かれたコマンドの形にしか一致しません。同じ操作を別の形で呼ばれると素通りするため、denyは安全境界ではなく、よくある形を止める柵だと考えてください。本番キーは、そもそも作業環境に置かないほうが確実です。
次に、Plan Modeで設計を出させます。claude --permission-mode planで起動するか、セッション中にShift+Tabで切り替えると、承認するまでファイルは編集されません。
claude --permission-mode plan最初の依頼は、実装ではなく設計の確認に絞ります。
Stripe Checkout(subscriptionモード)の導入計画を立てて。
- 権限付与はcheckout.session.completedとcheckout.session.async_payment_succeededのwebhookだけで行う
- success_urlは表示専用。ここで権限を変更しない
- 注文と自社ユーザーはclient_reference_idで結ぶ
- カスタマーポータルのセッションは、ログイン済みユーザー自身のcustomerでだけ作る
実装前に、変更するファイルとテストの一覧を出して。計画に「success_urlのハンドラでactiveにする」案が混じっていたら、承認せずに差し戻します。
Checkoutセッションを作る
注文と自社のユーザーを結ぶ鍵には、client_reference_idを使います。公式の説明では、顧客IDやカート番号など、自社システムと照合するための一意な文字列で、最大200文字です。
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: PRICE_ID, quantity: 1 }],
client_reference_id: String(user.id),
success_url: "https://example.com/after-checkout?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://example.com/pricing",
});
// ブラウザには session.url を返してリダイレクトさせるsuccess_urlに{CHECKOUT_SESSION_ID}を入れておくと、リダイレクト時にセッションIDへ置き換わります。このURLのページでやるのは、購入完了の表示と、権限が付くまでの待ち受けです。権限を書き換える処理は置きません。
webhookエンドポイントがあり、success_urlも設定している場合、Checkoutはサーバーがイベントに応答するのを最大10秒待ってから顧客をリダイレクトします。重い処理を同期で走らせると、顧客の待ち時間が延びます。まず200を返し、重い処理は後ろに回す構成が向いています。なお、この待機は組織アカウントに登録したwebhookエンドポイントでは働きません。
webhookで権限を付ける
公式のハンドラは、checkout.session.completedとcheckout.session.async_payment_succeededの2つを同じ関数に流します。入金が遅れて確定する決済手段では、完了時点でお金がまだ動いていません。遅れて成功したときにasync_payment_succeededが届きます。失敗はcheckout.session.async_payment_failedで受け、顧客に知らせる用途に使えます。
async function fulfillCheckout(sessionId: string) {
// 同じsessionIdで何度呼ばれても安全にする(処理済みなら何もしない)
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items"],
});
if (session.payment_status === "unpaid") return;
await db.transaction(async (tx) => {
const done = await tx.markFulfilled(session.id); // 一意制約で二重実行を弾く
if (!done) return;
await tx.grantAccess({
userId: Number(session.client_reference_id),
stripeCustomerId: session.customer as string,
});
});
}公式のサンプルが従う手順は、セッションをAPIで取り直し、payment_statusを見て、履行したあとに状態を記録する流れです。イベント本文を信じず取り直す点が要です。stripeCustomerIdをここで保存しておくと、あとでポータルのセッションを作るときに使えます。
Claudeに書かせるときは、テストの観点を先に指定します。
- 同じセッションIDで2回呼んでも、権限付与が1回だけであること
payment_statusがunpaidのとき、何も変わらないことsuccess_urlのハンドラが、権限テーブルを書き換えないこと
3つ目は、Claudeが親切心で「画面表示のついでに権限も更新」を足してしまうのを防ぐための確認です。テストはwebhook経由とページ経由の両方を叩き、ページ経由では権限が変わらないことを検証します。
stripe listenで流れを確かめる
ローカルでは、Stripe CLIがイベントをサーバーへ転送します。
stripe listen --forward-to localhost:4242/webhook起動するとwhsec_で始まる署名シークレットが表示されます。ハンドラの署名検証にこの値を使います。stripe listenは人のターミナルで起動したままにし、Claudeにはstripe triggerとテストの実行だけを任せるのが、先ほどのpermissionsとも噛み合います。
公式のテスト手順は、テストカード4242 4242 4242 4242、将来の有効期限、任意の3桁のセキュリティコードで支払い、次の2点を確認する流れです。stripe listenの画面にcheckout.session.completedが転送されること。サーバーログに、フルフィルメント関数の出力が出ること。ローカルでStripe CLIを使うときは、Checkoutが待たずにsuccess_urlへ即リダイレクトする点も、本番との違いです。開発サーバーの公開まで含めた手順はStripe CLIのwebhook開発にまとめています。
カスタマーポータルで解約とプラン変更を任せる
購入後の管理画面を自作しなくても、Stripeがホストするカスタマーポータルで、顧客は支払い方法の更新、請求書の確認、サブスクの変更とキャンセルを自分でできます。導入は2段階です。ダッシュボードでポータルの設定を決め、アプリ側でセッションを作って顧客を送り出します。
ダッシュボードで選ぶ設定
公式の設定項目のうち、導入時に判断が分かれるものを抜き出します。
| 設定 | 既定 | 選ぶときの見どころ |
|---|---|---|
| プランの変更 | 既定オフ | 選ぶときの見どころ良い・より良い・最高の価格帯を用意しているときに向く |
| 数量の更新 | 既定オフ | 選ぶときの見どころユーザー数課金のときに向く |
| 比例配分の適用 | 既定オフ | 選ぶときの見どころプランや数量の変更を許すとき、残り期間を精算するか |
| ダウングレードの管理 | 既定今すぐ更新 | 選ぶときの見どころオンにすると、変更が請求期間の終了時に予約される |
| サブスクのキャンセル | 既定オン | 選ぶときの見どころ期間終了までは使い続けられる |
| キャンセルの理由 | 既定オン | 選ぶときの見どころ解約理由を集めて分析に使える |
| 顧客維持クーポン | 既定オフ | 選ぶときの見どころ解約の前にクーポンを提示する |
最初に決めるのは、プラン変更を許すかどうかです。許すなら、権限の側でプランごとの機能差を持つ必要があります。許さないなら、解約とカード更新だけを開けた最小構成で始められます。
制限事項を先に確認する
ポータルには、導入前に知っておきたい制限があります。
- 複数商品、従量課金、トライアル、請求書送付型の回収、未対応の決済手段のサブスクは、キャンセルはできますがプラン変更はできません
- サブスクスケジュールで更新が予定されているサブスクは、顧客が更新もキャンセルもできません
- 既定では、
trialingのサブスクを顧客が変更すると無料トライアルが終了し、即時決済の請求書が作られます。トライアルを保つには、ポータル設定のfeatures.subscription_update.trial_update_behaviorをcontinue_trialにします - プラン変更の選択肢にできる製品は最大10です
- 同じ
productとrecurring.intervalに複数のPriceは置けません。学割のような別価格は、別のProductとして作ります - iframeの中にポータルは表示できません
プラン変更を開ける構成で、トライアル中のユーザーがいるなら、trial_update_behaviorは必ず見ます。既定のままだと、画面を触っただけでトライアルが終わり、請求が発生します。
セッションを作って顧客を送る
ポータルのセッションを作るには、顧客のIDとreturn_urlが必要です。ダッシュボードで既定の戻り先を設定していればreturn_urlは省けます。公式の説明どおり、セッションを作る前に、自サイトで顧客を認証します。
app.post("/billing/portal", requireLogin, async (req, res) => {
const customerId = await db.stripeCustomerIdOf(req.user.id); // 自分のものだけ
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "https://example.com/account",
});
res.redirect(303, portal.url);
});顧客IDをリクエストのパラメーターから受け取る実装は、他人のポータルを開ける穴になります。IDは必ずセッションのユーザーからDBで引きます。CLAUDE.mdに明記しておくと、Claudeが「便利だから」と引数にしてしまうのを防げます。
ポータルのURLは短期間で失効します。新しいセッションは5分で期限切れになり、使い始めたあとは最後の操作から1時間で切れます。画面にURLを保存して使い回さず、ボタンを押すたびにセッションを作ります。
ポータルでの変更をwebhookで受け取る
ポータルで起きた変更は、画面の戻り先ではなくwebhookで知ります。ここがCheckoutと同じ原則です。請求先情報や既定の決済手段の更新はcustomer.updatedで届きます。解約時に集めた理由は、ダッシュボードのサブスク詳細、Stripe Sigma、subscription.updatedのwebhookで確認できます。
サブスクの状態を自社DBへ写す設計(past_dueの猶予、取り直しの方針)はサブスク同期の実装と同じです。ポータルを導入すると、解約とプラン変更の入口が増えるだけで、状態の受け皿は変わりません。ポータルを足すときは、その記事の状態遷移表に、期間末の解約予約を行として加えてください。
CLAUDE.mdに残す規約
次回以降のセッションでも同じ分担を守らせるため、短い規約をCLAUDE.mdに残します。
## Stripe Checkout / カスタマーポータル
- 権限の付与・剥奪はwebhook(checkout.session.completed / async_payment_succeeded ほか)だけで行う
- success_url / return_url のハンドラは表示専用。権限テーブルを書き換えない
- 注文とユーザーの紐付けは client_reference_id。フルフィルメントはsession IDで冪等に
- ポータルのセッションは、ログイン中ユーザー自身のcustomerでのみ作る。customer IDをリクエストから受け取らない
- 本番キー、--live、.env には触らないつまずきやすい点
success_urlで権限を付けて動作確認が通る: ローカルでは流れが良く、回線切れの再現はしないと分かりません。ページを開かないまま決済を終えるテストを足しますasync_payment_succeededを購読していない: 入金が遅れて確定する決済手段だけ、権限が付かないまま残ります- ポータルのURLを保存して再利用する: 短期間で失効するため、リンク切れになります
- ポータルを試さずに本番へ出す: 公式は、本番で有効にする前に必ずテストするよう求めています。テストデータの表示を切り替えて、本番側の設定も見直します
- プラン変更を開けたまま複数商品のサブスクを売る: 該当する顧客はキャンセルしかできません
まとめ
Checkoutの導入は、購入完了を知る経路を1本に決める作業です。webhookで権限を付け、success_urlとreturn_urlは画面を戻すだけにします。この分担を、Plan Modeの設計確認、permissions、CLAUDE.mdの規約の3段でClaudeに守らせると、後から画面側へ処理が紛れ込みにくくなります。
ポータルは、設定を絞るほど事故が減ります。最初はカード更新と解約だけを開け、プラン変更はトライアル中の挙動と複数商品の制限を確かめてから足す順番が、手戻りの少ない進め方です。Claude Codeには設計と実装を任せ、キーとライブ環境は人が持つ分担で進めます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- docs.stripe.com/checkout/fulfillment
- docs.stripe.com/customer-management
- docs.stripe.com/customer-management/configure-portal
- docs.stripe.com/customer-management/integrate-customer-portal
- docs.stripe.com/customer-management/cancellation-page
- code.claude.com/docs/en/permissions
- code.claude.com/docs/en/common-workflows