Claude Media
Claude CodeでStripe CLIのwebhookを開発 — listenと秘密鍵の守り方

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. 1

    人:別ターミナルでlistenを起動する

    stripe listen --forward-to localhost:4242/webhookを実行し、表示されたwhsec_を自分で.env.localに書き込みます。

  2. 2

    Claude:受信コードを実装する

    署名検証つきのwebhookハンドラを書かせます。シークレットはprocess.env.STRIPE_WEBHOOK_SECRETから読むだけにします。

  3. 3

    Claude:triggerでイベントを送る

    stripe trigger payment_intent.succeededを実行し、サーバーのログからイベントIDと処理結果だけを読んで直します。

  4. 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.succeeded

triggerが成功すると、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はコマンド文字列への照合なので、本番キーに近い作業はサンドボックスと制限付きキーで二重に囲みます。

この記事を共有:XはてブLinkedIn