全く違う 2 つのプロトコル(Stripe ホスト型 + x402 オンチェーンウォレット)の決済統合を Claude に書かせる。3 つの静かな失敗を踏みつつ、両方の決済レールを 1 アプリで動かす構成を組む。
最近 how2claude の Pro プランに Stripe(クレカ/法定通貨)と x402(EVM オンチェーン USDC)の両方を組み込んだ。Claude に全く違う 2 つのプロトコルの決済統合を書かせる——一方はホスト型 Checkout + webhook、もう一方は HTTP 402 + ブラウザウォレット——丸一晩のセッションがかかった。3 つの静かな失敗を踏みつつ、両方の決済レールを 1 アプリで動かす構成を組み終えた。
これは「Stripe をどう統合するか」のチュートリアルじゃない——そんな文書はいくらでもある。注目点は:2 つのプロトコルがどう並び立つか、Claude はどこで派手にコケやすいか、どの瞬間に自分でじっと見ている必要があるか。
| 軸 | 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 拡張レイヤでそのままプロトコルハンドシェイクを完結させる。
この差が、後段のあらゆる構成判断を決める。
最初コントローラはフィールドマッピングで膨らんでいた:
# ❌ 初期版
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] } を足す。人間は「とりあえず動かす」を優先しがちで、フィールドマッピングがコントローラ間に散ったまま戻ってこない。
b2f0333 で初めて Claude に x402 統合を書かせたとき、3 つのクラスを手書きで作った:
X402::PaymentHandler — 402 requirements を構築、PAYMENT-SIGNATURE header をデコードX402::FacilitatorClient — x402.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 があなたの学習材料。
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 点:
eth_requestAccounts の戻り値を使う、selectedAddress は使わない。selectedAddress は deprecated、多くのウォレットは古い値を返す。Claude は最初 selectedAddress を使った(MDN ドキュメント通り)。私が前者に切り替えた。もう 1 つ:エラーコードの列挙化。ウォレットの署名拒否は 4001、チェーンが違って切替が必要なら CHAIN_SWITCH、課金必要なら PAYMENT_REQUIRED。error.message で文字列マッチしてはいけない——ウォレットによって文言が違うし、テストが書けない。
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 が返ってきているのを見て「じゃあなんでブラウザが追わない?」と自問するしかない。
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 が静かに捨てられていないことを確認すること。
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 特殊解釈」の罠は、本物の値を入れて初めて発火する。
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 に決済を統合させる、完全チェックリスト:
inclusion: { in: %w[stripe x402] } を型ゲートとして付ける。Rails.env.production? で反転。data-turbo=false。さもないと Turbo がクロスオリジン 302 を静かに飲む。bin/importmap json。importmap は vendor ファイルが無い pin を静かに捨てる。0x... / true / 07 は YAML が特殊解釈する。Claude に決済を書かせる本当の難所はプロトコルそのものではなく、プロトコルの境界の統合(Turbo と Stripe、importmap と gem、YAML とウォレットアドレス)。そこは自分で座って見ているしかない瞬間。