Free

Claude に 2 種類の決済を統合させる:Stripe + x402

全く違う 2 つのプロトコル(Stripe ホスト型 + x402 オンチェーンウォレット)の決済統合を Claude に書かせる。3 つの静かな失敗を踏みつつ、両方の決済レールを 1 アプリで動かす構成を組む。


最近 how2claude の Pro プランに Stripe(クレカ/法定通貨)と x402(EVM オンチェーン USDC)の両方を組み込んだ。Claude に全く違う 2 つのプロトコルの決済統合を書かせる——一方はホスト型 Checkout + webhook、もう一方は HTTP 402 + ブラウザウォレット——丸一晩のセッションがかかった。3 つの静かな失敗を踏みつつ、両方の決済レールを 1 アプリで動かす構成を組み終えた。

これは「Stripe をどう統合するか」のチュートリアルじゃない——そんな文書はいくらでもある。注目点は:2 つのプロトコルがどう並び立つか、Claude はどこで派手にコケやすいか、どの瞬間に自分でじっと見ている必要があるか。


2 つの決済パラダイム

Stripe x402
トリガー button_to → checkout.stripe.com にリダイレクト POST /x402/subscribe → HTTP 402 を返す
ユーザー操作 Stripe ホストページでカード入力 ブラウザウォレットで署名
結果配信 webhook (checkout.session.completed) リクエストを X-PAYMENT header 付きで再試行、gem が同期決済
永続化したいデータ payment_intent_id + amount_total tx_hash + payer + amount
プロトコル複雑度 SDK で全部 viem + x402-fetch のプロトコルハンドシェイクが必要

根本的に違う:Stripe はユーザーを自社ページに送り出し、戻ってきたときの webhook を確認すれば足りる。x402 は最初から最後まで自分のドメイン上で動き、HTTP 拡張レイヤでそのままプロトコルハンドシェイクを完結させる。

この差が、後段のあらゆる構成判断を決める。

コントローラを薄く:record メソッドをモデル層に押し出す

最初コントローラはフィールドマッピングで膨らんでいた:

# ❌ 初期版
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... 十数行のフィールドマッピング
  )
end

両方の決済とも Purchase + Subscription を永続化するが、フィールドはまるで違う。コントローラの中にマッピングを置くと、両決済で同じロジックを写すはめになる。

移行(9f3e239)でモデルに押し出した:

class Purchase < ApplicationRecord
  validates :provider, presence: true, inclusion: { in: %w[stripe x402] }

  def self.record_x402!(article:, user:, payment:, settlement:)
    create!(
      article: article,
      user: user,
      provider: "x402",
      wallet_address: payment[:payer],
      amount_cents: article.price_cents,
      tx_hash: settlement.transaction,
      purchased_at: Time.current
    )
  end

  def self.record_stripe!(session:, user:)
    create!(
      article_id: session.metadata.article_id,
      user: user,
      provider: "stripe",
      amount_cents: session.amount_total,
      stripe_payment_intent_id: session.payment_intent,
      purchased_at: Time.current
    )
  end
end

合計 4 メソッド:Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!。コントローラは 1 行になる:

Purchase.record_x402!(article:, user:, payment:, settlement:)

Claude はこの種の作業が得意:黙々とフィールドを 1 つずつマッピングし、テストを書き、validates :provider, inclusion: { in: %w[stripe x402] } を足す。人間は「とりあえず動かす」を優先しがちで、フィールドマッピングがコントローラ間に散ったまま戻ってこない。

ペース:手書きで通してから gem に乗せ替える

b2f0333 で初めて Claude に x402 統合を書かせたとき、3 つのクラスを手書きで作った:

  • X402::PaymentHandler — 402 requirements を構築、PAYMENT-SIGNATURE header をデコード
  • X402::FacilitatorClientx402.org/facilitator/verify + /settle をラップ
  • app/controllers/concerns/content_gate.rb — 402 header を検出して PAYMENT-REQUIRED を返す

449 行、動く、テストも通る。

6 時間後(9f3e239)、これを丸ごと x402-rails gem(v1 プロトコル、非 optimistic モード)に置き換えさせた。3 つのクラスを削除、コントローラは x402_paywall(amount:) DSL を使い、request.env["x402.payment"]request.env["x402.settlement_result"] から読む。

ペースには意味がある:手書きでプロトコルを理解させてから、gem で楽になる。最初から gem を入れると、Claude は gem のドキュメントに合わせて書くが、402 header の中身も /settle の中身もあなたは分からない。何か壊れたとき(必ず何かは壊れる)、デバッグの足場がない。

このパターンは新プロトコル/新サービス全般で使える:Claude に一度手書きさせ、テストを通してから gem に乗せ替える。前後の diff があなたの学習材料。

チェーンは Rails.env で実行時に切る、デプロイ時の手切り替えはやらない

x402 イニシャライザ(config/initializers/x402.rb)にルールをハードコード:

X402.configure do |config|
  config.wallet_address = Rails.application.credentials.dig(:x402, :wallet_address)
  config.facilitator = Rails.application.credentials.dig(:x402, :facilitator_url) ||
                       "https://facilitator.payai.network"
  # Production → Base mainnet (real USDC). Dev/test → Base Sepolia (free testnet USDC).
  config.chain = Rails.env.production? ? "base" : "base-sepolia"
  config.currency = "USDC"
  config.version = 1
  config.optimistic = false  # facilitator settle が返るのを待ってから続ける、tx_hash を同期取得するため
end

同じコードで dev は base-sepolia(無料テストトークン)、prod は base mainnet。デプロイ時に何も変えない。(この原則は前回のClaude に本番デプロイをやらせるで書いた——dev と prod で違う設定は全部 Rails.env で反転)。

optimistic = false の行が肝心:gem のデフォルト optimistic モードはリクエストを通してから後で精算するが、私たちはオフにする——action が返る前に settlement_result.transaction(tx_hash)を取って Purchase 行に同期で書きたいから。tx_hash のない Purchase レコードはユーザーには無価値——BaseScan を開いて取引を見たいはず。

フロントエンド:片や完全ホスト、片や手作り

Stripe 側の「フロントエンド」は 1 行:

<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
      class: "...",
      form: { class: "w-full", data: { turbo: false } } do %>
  <%= t("pricing.subscribe") %>
<% end %>

ユーザーがクリックすると、ブラウザは checkout.stripe.com へ。フロント側は 0 コード。

x402 側(93746d8)は Stimulus コントローラを書かせた:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // 遅延ロード、vendor を膨らませない
  const viem = await import("https://esm.run/viem@2")
  const { wrapFetchWithPayment } = await import("https://esm.run/[email protected]")

  const [account] = await window.ethereum.request({ method: "eth_requestAccounts" })
  const walletClient = viem.createWalletClient({ account, transport: viem.custom(window.ethereum) })
  const fetchWithPayment = wrapFetchWithPayment(fetch, walletClient)

  const res = await fetchWithPayment(this.endpointValue, {
    method: "POST",
    headers: { "Accept": "application/json" },
    body: new URLSearchParams(this.paramsValue)
  })
  // ...
}

注目すべき 2 点:

  1. viem + x402-fetch を遅延ロード(最初のクリック時に jsdelivr から取る)。この 2 つは合わせると大きく、vendor バンドルすると非課金ユーザーまで全員ダウンロードする羽目に。遅延ロードで「払いたい人だけ取る」になる。
  2. eth_requestAccounts の戻り値を使う、selectedAddress は使わないselectedAddress は deprecated、多くのウォレットは古い値を返す。Claude は最初 selectedAddress を使った(MDN ドキュメント通り)。私が前者に切り替えた。

もう 1 つ:エラーコードの列挙化。ウォレットの署名拒否は 4001、チェーンが違って切替が必要なら CHAIN_SWITCH、課金必要なら PAYMENT_REQUIREDerror.message で文字列マッチしてはいけない——ウォレットによって文言が違うし、テストが書けない。

落とし穴 #1:button_to + Turbo が Stripe の 302 を静かに飲み込む

527f700 のコミットは、ブラウザを 30 分睨んでようやく見つけた。

症状:/pricing の Subscribe を押す、何も起きない。コンソールエラーなし、ネットワークエラーなし、Rails ログは 200 で 302 → checkout.stripe.com/c/pay/cs_xxx を返している。なのにブラウザは微動だにしない。

原因:button_to<form method="post"> を生成し、Turbo がそのフォーム送信を傍受、レスポンスを TURBO_STREAM として処理する。TURBO_STREAM はクロスオリジンの 302 をフォローしない。レスポンスは Turbo に黙って飲まれ、ページは静止のまま。

修正:

 <%= button_to stripe_checkouts_subscription_path(plan: plan.key),
       class: "...",
-      form: { class: "w-full" } do %>
+      form: { class: "w-full", data: { turbo: false } } do %>

3 つのボタンが該当:/pricing の Subscribe、/pricing の「現在のプラン」カードの Manage(billing.stripe.com へジャンプ)、/accounts の Manage Subscription。それぞれに data-turbo=false を付け、それぞれに回帰テストを追加。

最初に Claude にデバッグさせたら 3 つの誤った方向を疑った:Stripe 設定ミス(違う)、redirect_uri ホワイトリスト(違う)、CORS(誤方向)。Turbo と Stripe の衝突は Stripe ドキュメントにも Turbo ドキュメントにもない——Claude の訓練データにもほぼない。こういう罠は network タブで 302 が返ってきているのを見て「じゃあなんでブラウザが追わない?」と自問するしかない。

落とし穴 #2:Failed to resolve module specifier 'x402-fetch'

x402-rails gem を入れた後、ブラウザコンソール:

Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.

でも私は明示的に await import("https://esm.run/[email protected]") で遅延ロードしている——完全 URL なのに、なぜ "resolve module specifier" が失敗する?

根本原因:x402-rails gem は @hotwired/stimulus に依存する Stimulus コントローラを同梱している。config/importmap.rb でこのパッケージを pin したが、対応する vendor ファイル vendor/javascript/@hotwired--stimulus.jsダウンロードされていない。importmap はファイルが無いことに気づき、生成された importmap からその pin を黙って捨てる。失敗しているのは私の x402-fetch ではなく、gem の Stimulus コントローラ。エラーは最も近い import に伝播する。

診断:bin/importmap json で実際に生成された importmap を出力し、config/importmap.rb と比較——json に出てこない pin があれば、対応する vendor ファイルが落ちていない。

修正:bin/importmap pin @hotwired/stimulus でファイルを実際に取得しなおす。

Claude は gem 統合コードを書くとき bin/importmap json を反射的にサニティチェックとして走らせない。これは人間がやるしかない。importmap を使っているなら、Stimulus コントローラ同梱の gem を入れた後は bin/importmap json を 1 回走らせて、pin が静かに捨てられていないことを確認すること。

落とし穴 #3:YAML が 0x... のウォレットアドレスを整数として解釈

credentials に:

x402:
  wallet_address: 0x1234abcd...

Rails がロードするとき、YAML は 0x1234abcd...整数(hex literal)として解釈。X402.configure がこの値を受け取った時点で型が壊れていて、gem 内部で paywall requirement を組むときに変な構造になる。

1 文字の修正:引用符を付ける。

x402:
  wallet_address: "0x1234abcd..."

Claude は credentials テンプレートを書くとき引用符を付けなかった——訓練データの YAML 例はほぼ裸文字列。プレフィックスがたまたま 0x / true / false / 数字のときだけ問題になる。この種の「YAML 特殊解釈」の罠は、本物の値を入れて初めて発火する。

なぜ 1 アプリに 2 つの決済レールが要るのか

Stripe はユーザーの 99% をカバー——クレカ/Apple Pay/Google Pay。$9.99/月のフローでは体験が圧倒的にいい。

x402 は残りの 1% の重要な人をカバー:暗号ネイティブのユーザー、ステーブルコインを使いたい海外ユーザー、自動化エージェントを書く開発者(彼らのエージェントは有料 API への自前支払いができる必要がある——402 はそのために設計された)。

肝心なプロダクト判断:月額プランには x402 を出さない。$9.99/月で毎月ウォレット署名するのは UX が酷い。年額 $99 だけ x402 を有効にして、摩擦コストを年 1 回に減らす。

<% if plan.interval == "year" %>
  <%= render "shared/x402_pay_button", ... %>
<% end %>

_plan_card.html.erb の 1 行 if で、どのカードが USDC 決済ボタンを出すか決まる。それだけ。


Claude に決済を統合させる、完全チェックリスト:

  1. 2 つのプロトコルを別々に理解してから Claude に書かせる。Stripe は hosted Checkout + webhook、x402 は HTTP 402 + ブラウザウォレット——Claude が自力で区別できると思うな。
  2. record メソッドはモデル層に置く。コントローラは 1 行呼ぶだけ、フィールドマッピングは全部モデル内に。inclusion: { in: %w[stripe x402] } を型ゲートとして付ける。
  3. 新プロトコルは手書きしてから gem に乗せ替える。前後の diff があなたの学習材料。
  4. チェーン/モードの切替は実行時に Rails.env で。Stripe test/live、x402 base-sepolia/base、全部 Rails.env.production? で反転。
  5. Stripe の button_to にはすべて data-turbo=false。さもないと Turbo がクロスオリジン 302 を静かに飲む。
  6. Stimulus コントローラ同梱の gem を入れた後は必ず bin/importmap json。importmap は vendor ファイルが無い pin を静かに捨てる。
  7. 数字プレフィックスっぽい credentials は全部引用符を付ける0x... / true / 07 は YAML が特殊解釈する。

Claude に決済を書かせる本当の難所はプロトコルそのものではなく、プロトコルの境界の統合(Turbo と Stripe、importmap と gem、YAML とウォレットアドレス)。そこは自分で座って見ているしかない瞬間。