Claude CodeでStripe CLIのwebhookを開発 — listenと秘密鍵の守り方
stripe listenとstripe triggerでwebhookをローカル検証しながら、sk_キーとwhsec_をClaude Codeに読ませないdeny設定と役割分担を、設定例つきで示します。
Stripeのwebhook受信コードは、Claude Codeに書かせやすい題材です。ただし開発中に触れる秘密が3種類あります。sk_で始まるAPIキー、stripe listenが表示するwhsec_、Stripe CLIのログイン情報です。
結論から言うと、役割はこう分けると安全に回ります。stripe listenは人が別ターミナルで動かし、Claude Codeには受信コードの実装とstripe triggerによるイベント送出を任せます。秘密を置く.env系のファイルはpermissions.denyで読ませません。この記事は、その設定と検証ループを順に示します。
ローカル検証で使うStripe CLIの3コマンド
Stripe CLIのlistenは、Stripeからのwebhookイベントを自分のマシンで受け取るコマンドです。Dashboardにエンドポイントを登録する必要はありません。--forward-toでローカルのアプリのURLへ転送すると、そのとき署名シークレット(whsec_)が返ります。
| コマンド | 役割 | 副作用 |
|---|---|---|
stripe listen | 役割イベントを受信し、--forward-toで転送する | 副作用なし(既定は受信のみ) |
stripe trigger <event> | 役割テスト用のイベントを発生させる | 副作用Stripe APIにリクエストが飛び、必要なAPIオブジェクトが作られる |
stripe login | 役割ブラウザ経由でCLIをアカウントに接続する | 副作用認証情報がOSの安全な保管庫に保存される |
listenの転送先には--forward-to(-f)を、受信するイベントの絞り込みには--events(-e)を使います。--liveを付けない限り、リクエストはサンドボックスに対して実行されます。
triggerは、payment_intent.succeededのようなイベント名を渡します。このときpayment_intent.createdなど関連するイベントも一緒に発生します。サポートされるイベントの一覧はstripe trigger --helpで見られます。
守る対象は3つ:sk_キー・whsec_・CLIの認証情報
何をClaude Codeから遠ざけるかを、先に表にします。
| 秘密 | どこに現れるか | 漏れたときの影響 |
|---|---|---|
シークレットキーsk_... | どこに現れるか.env、STRIPE_API_KEY、stripe configの設定 | 漏れたときの影響すべてのStripe APIに無制限の権限を持つ |
署名シークレットwhsec_... | どこに現れるかstripe listenの出力、STRIPE_WEBHOOK_SECRET | 漏れたときの影響webhookの署名検証を偽装される |
| CLIの認証情報 | どこに現れるかstripe loginで保存されたセッション | 漏れたときの影響CLIが接続しているアカウントの操作 |
シークレットキーは権限を絞れません。Stripeの案内では、新しい用途でシークレットキーを使うことは推奨されておらず、制限付きAPIキー(rk_...)を使うよう案内されています。制限付きキーなら、実装に必要な権限だけを割り当てられるので、漏れたときの被害を小さくできます。
CLIの認証には、ブラウザのstripe loginが推奨されています。認証情報はOSの安全な保管庫に保存され、セッションは自動で更新されます。APIキーで認証する場合はstripe login --interactive、stripe config、--api-keyフラグ、環境変数の4つが選べます。
ここで注意したいのが環境変数です。STRIPE_API_KEYを設定すると、ほかのどの設定値よりも優先されます。シェルでこの変数をexportしたままclaudeを起動すると、Claude Codeが動かすコマンドにもキーが渡ります。キーはシェルに常駐させず、stripe loginの保管庫に任せるのが素直です。
settings.jsonのenvにキーを書く方法もありますが、設定ファイルに平文で残ります。プロジェクトの設定ファイルはコミット対象になりやすいため、Stripeのキーには向きません。
役割分担:listenは人が、triggerはClaudeが
stripe listenは起動したままになるコマンドで、起動時にwhsec_を画面へ出します。Claude Codeに実行させると、その出力は会話の履歴に入ります。そこで、次のように分けます。
Stripe webhook開発の役割分担
- 1
人:別ターミナルでlistenを起動する
stripe listen --forward-to localhost:4242/webhookを実行し、表示されたwhsec_を自分で.env.localに書き込みます。 - 2
Claude:受信コードを実装する
署名検証つきのwebhookハンドラを書かせます。シークレットは
process.env.STRIPE_WEBHOOK_SECRETから読むだけにします。 - 3
Claude:triggerでイベントを送る
stripe trigger payment_intent.succeededを実行し、サーバーのログからイベントIDと処理結果だけを読んで直します。 - 4
人:本番のエンドポイントは自分で登録する
Dashboardでのエンドポイント登録と本番キーの設定は、Claudeに渡しません。
whsec_はリスン中の画面に残りますが、listenのシークレットは再起動しても変わりません。一度.env.localに書けば、画面を見直す必要はありません。
settings.jsonに入れるdenyとallow
次の設定を、コードを書かせる前にプロジェクトの.claude/settings.jsonへ入れます。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(stripe listen *)",
"Bash(stripe login *)",
"Bash(stripe logout *)",
"Bash(stripe config *)",
"Bash(stripe * --live)",
"Bash(stripe * --live *)",
"Bash(stripe * --api-key *)"
],
"allow": [
"Bash(stripe trigger *)"
]
}
}Read(./.env)の拒否は、読み取りだけでなくEditツールとWriteツールにも効きます。.env.localのような派生ファイルはRead(./.env.*)で拾います。
Bashのルールは、末尾に*を置いてスペースを空けると、引数なしのコマンドにもマッチします。Bash(stripe listen *)はstripe listen単体も止めます。--print-secretを付けた実行も同じルールで止まります。
ただし、末尾の*で引数なしにも一致するのは、それがルール内の唯一のワイルドカードのときだけです。stripe * --live *のように途中にもワイルドカードがあるルールは、stripe trigger payment_intent.succeeded --liveのように--liveが末尾に来る形に一致しません。そのため、末尾に置かれる形をBash(stripe * --live)で別に止めています。--api-keyは後ろに値が続くので、1本のルールで足ります。
ルールは拒否が先に評価され、そのあとに確認、許可の順です。拒否に一致した時点で結果が決まるため、allowのstripe trigger *とdenyのstripe * --liveが重なっても、拒否側が勝ちます。
triggerはStripe APIへ通信します。サンドボックスを有効にしている場合は、初回にドメインの承認を求められます。承認後にその範囲を広げすぎないよう、許可するホストはapi.stripe.comなど必要な範囲に絞ります。
denyが止めないもの
Bashのルールは、Claudeが書いたコマンド文字列に対して照合されます。次のような別の呼び出し方は止まりません。
/usr/local/bin/stripe listenのようにパスを付けた実行sh -c 'stripe listen'のようにシェルを挟んだ実行grep -r whsec .のようにファイル名を指定せず中身を読むコマンド.envを自分で開くNodeスクリプトなど、サブプロセスによる読み取り
Readのdenyは、組み込みのファイルツールと、catやheadのようにファイルを名指しするコマンドに効きます。サブプロセスによる間接的な読み取りまでは止まりません。OSの水準で止めるなら、サンドボックスを有効にします。設定の考え方はsandbox-runtime(srt)の記事にまとめています。
CLAUDE.mdに書く規約とwebhookハンドラの形
denyは「読ませない」ための設定です。「どう書かせるか」はCLAUDE.mdに規約として残します。
## Stripe webhook
- シークレットは `process.env.STRIPE_WEBHOOK_SECRET` から読む。コードに直書きしない
- 署名検証には未加工のリクエスト本文を使う。`express.json()` をこのルートに適用しない
- ログに出すのはイベントIDとtypeだけ。ペイロード全体を出力しない
- `.env*` は読まない。値が必要なときは変数名だけ使い、人に設定を頼む
- 検証は `stripe trigger` で行う。`--live` は使わない2行目が大事です。Stripeの署名検証は、未加工のリクエスト本文、Stripe-Signatureヘッダー、エンドポイントシークレットの3つを要求します。JSONとして解釈したあとの本文では一致しません。Stripeのクイックスタートでも、Expressではexpress.raw({type: 'application/json'})で本文を受け取り、stripe.webhooks.constructEventで検証しています。
この規約に沿って書かせると、ハンドラは例えば次のような形になります(Stripeのクイックスタートに沿った例です)。
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_API_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
endpointSecret
);
} catch (err) {
return res.sendStatus(400);
}
console.log(event.id, event.type);
res.sendStatus(200);
});
app.listen(4242);ログに出しているのはevent.idとevent.typeだけです。ペイロードにはメールアドレスなど顧客の情報が入りうるため、Claudeがログを読む前提なら、最初から出さない設計にします。
検証ループ:triggerで送り、ログで直す
実装ができたら、人が別ターミナルでlistenを動かし、Claudeがtriggerを打ちます。
# 人:別ターミナル(転送先は実装したルートに合わせる)
stripe listen --forward-to localhost:4242/webhook \
--events payment_intent.succeeded
# Claude:Bashから実行
stripe trigger payment_intent.succeededtriggerが成功すると、Stripeのドキュメントの例ではTrigger succeeded! Check dashboard for event details.と表示されます。サーバー側のログにpayment_intent.succeededのイベントIDが出て、listen側に転送結果が並べば疎通は成功です。
--eventsで受信するイベントを絞ると、確認したい流れだけがログに残ります。省略すると、既定ですべてのスナップショットイベントを受け取ります。
Claudeに直させるのは、ログが示す差分です。次のように指示すると、ループが回しやすくなります。
stripe trigger checkout.session.completedを実行して、サーバーのログからイベントIDと処理結果を確認してください。400が返るなら、署名検証に渡している本文が未加工かどうかを見直してください。.envは読まないでください。
triggerは実際にAPIオブジェクトを作るので、繰り返すとサンドボックスの顧客や請求が増えます。気になる場合は、--overrideや--addでパラメータを指定して再現条件を絞ります。
よくあるつまずき
- 400が返り続ける:本文がJSONとして解釈された後に検証へ渡されています。Expressなら、このルートだけ
express.rawにします。 - 署名が合わない:
listenが返すwhsec_と、Dashboardのエンドポイントの署名シークレットは別物です。ローカルではlistenが表示した値を使います。 - Claudeが
stripe listenを実行しようとして拒否される:意図どおりの動作です。人のターミナルで起動してください。 stripe loginが通らない:v1.50.0より新しいCLIでは、AdministratorかIAM Adminが事前にCLIアクセスを有効にする必要があります。設定場所はDashboardの「MCP and CLI access」です。- シェルでAPIキーを
exportしていた:STRIPE_API_KEYは他の設定より優先されます。claudeを起動したシェルから外してください。
Stripe向けのエージェント設定との違い
Stripe CLIには、AIコーディングエージェント向けのstripe agent setupがあります。Claude Code、Codex、Cursorを検出し、Stripeのプラグイン、スキル、MCPサーバーを入れるコマンドです。--client claude-codeで対象を絞れます。
これはStripe側が用意した道具をClaudeに持たせる方法で、この記事の方式とは目的が違います。ここで扱ったのは、手元のCLIとwebhook受信コードの開発を、秘密を渡さずに進める設計です。Claude側のコネクタでStripeのデータを扱う手順はClaude Stripe連携の設定方法、経理の使い方はStripeの経理業務の記事にあります。
同じwebhook受信でも、トークンの置き方は連携先で変わります。Botトークンをdenyで守る例はTelegram Botの記事に、CLIの実行権限を絞る例はCloudflare Workersとwranglerの記事にあります。
まとめ
Stripeのwebhook開発では、listenを人が、triggerと実装をClaudeが担当すると、whsec_とsk_が会話に入りません。.envのdenyと--liveのdenyを先に入れ、シェルにSTRIPE_API_KEYを残さないことが前提です。denyはコマンド文字列への照合なので、本番キーに近い作業はサンドボックスと制限付きキーで二重に囲みます。