Two completely different protocols in one app — Stripe's hosted Checkout + webhook, and x402's HTTP 402 + browser wallet. Three silent-failure traps, one architecture that runs both rails.
I recently wired both Stripe (cards/fiat) and x402 (EVM on-chain USDC) into how2claude's Pro tier. Letting Claude write integrations for two completely different protocols — one hosted Checkout + webhook, the other HTTP 402 + browser wallet — took a full evening session. Hit three silent failures, and ended up with an architecture that runs both rails together.
This isn't a "how to integrate Stripe" tutorial — those are everywhere. The interesting parts: how the two protocols fit alongside each other, where Claude is most likely to face-plant, and which moments you have to actually sit and watch it.
| Dimension | Stripe | x402 |
|---|---|---|
| Trigger | button_to → redirect to checkout.stripe.com | POST /x402/subscribe → returns HTTP 402 |
| User action | Enter card on Stripe-hosted page | Sign in browser wallet |
| Result delivery | webhook (checkout.session.completed) | Request retried with X-PAYMENT header, gem settles synchronously |
| Data I need to persist | payment_intent_id + amount_total | tx_hash + payer + amount |
| Protocol complexity | SDK does it all | Need viem + x402-fetch protocol handshake |
Fundamentally different: Stripe pushes the user away to its own page and you only verify the webhook when they come back; x402 stays entirely on your domain, doing the protocol handshake at the HTTP layer.
That distinction drives every architecture decision below.
Initially the controllers were stuffed with field mapping:
# ❌ Early version
def subscribe_via_stripe
session = Stripe::Checkout::Session.retrieve(params[:session_id])
Subscription.create!(
user: current_user,
provider: "stripe",
stripe_subscription_id: session.subscription,
# ... a dozen lines of field mapping
)
end
Both rails persist Purchase + Subscription, but the fields are completely different. Field mapping in the controller means each rail copies the mapping logic.
The migration (9f3e239) pushed it into the model:
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
Four methods total: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. The controller becomes one line:
Purchase.record_x402!(article:, user:, payment:, settlement:)
Claude is great at this kind of work: it'll dutifully map every field, add tests, and add validates :provider, inclusion: { in: %w[stripe x402] }. Humans tend to "just get it working first", and the field mapping ends up scattered across controllers, never to escape.
In b2f0333 I had Claude write the first x402 integration by hand — three classes:
X402::PaymentHandler — build 402 requirements, decode the PAYMENT-SIGNATURE headerX402::FacilitatorClient — wrap x402.org/facilitator's /verify + /settleapp/controllers/concerns/content_gate.rb — detect the 402 header, return PAYMENT-REQUIRED449 lines, working, tests passing.
Six hours later (9f3e239) I had it swap the whole thing for the x402-rails gem (v1 protocol, non-optimistic mode). Deleted those three classes; the controllers now use the x402_paywall(amount:) DSL and read from request.env["x402.payment"] and request.env["x402.settlement_result"].
The pacing matters: hand-rolling first lets you understand the protocol, then the gem frees you up. If you start with the gem, Claude writes against the gem's docs and you have no idea what's actually inside the 402 header or what /settle is doing. When something breaks (something always breaks), you have nothing to debug from.
This pattern works for any new protocol or service: have Claude hand-roll once, get tests green, then have it swap to the gem. The diff between the two is your study material.
The x402 initializer (config/initializers/x402.rb) hard-codes the rule:
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 # wait for facilitator settlement before continuing, so we can grab tx_hash sync
end
Same code: dev runs base-sepolia (free test tokens), prod runs base mainnet. Nothing to change at deploy time. (This principle came from the previous article Letting Claude Deploy to Production — anything that differs between dev and prod, flip via Rails.env.)
The optimistic = false line matters: the gem's default optimistic mode lets the request through and reconciles afterward; we turn it off because we want settlement_result.transaction (the tx_hash) before the action returns, so it gets written into the Purchase row synchronously. A Purchase row without a tx_hash is worthless to the user — they want to click through to BaseScan and see the transaction.
The Stripe-side "frontend" is one line:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
form: { class: "w-full", data: { turbo: false } } do %>
<%= t("pricing.subscribe") %>
<% end %>
User clicks, browser jumps to checkout.stripe.com. Zero frontend code on your side.
The x402 side (93746d8) needed a Stimulus controller:
// app/javascript/controllers/x402_payment_controller.js
async pay() {
// Lazy-load — don't bloat the vendor bundle
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)
})
// ...
}
Two things worth noting:
eth_requestAccounts's result, not selectedAddress. selectedAddress is deprecated and most wallets return a stale value. Claude's first version used selectedAddress (per MDN docs); I switched it.One more thing: enumerate error codes. Wallet rejected signature is 4001, wrong chain needs switching is CHAIN_SWITCH, payment required is PAYMENT_REQUIRED. Don't string-match error.message — wallets word things differently and you can't write tests against it.
Commit 527f700 is one I sat watching the browser for half an hour to find.
Symptom: click the Subscribe button on /pricing, nothing happens. No console error, no network error. Rails log shows 200 returned with a 302 → checkout.stripe.com/c/pay/cs_xxx. Browser doesn't move.
Cause: button_to generates a <form method="post">, and Turbo intercepts the form submission, treating the response as TURBO_STREAM. TURBO_STREAM does not follow cross-origin 302s. The response gets silently swallowed; the page stays put.
Fix:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
- form: { class: "w-full" } do %>
+ form: { class: "w-full", data: { turbo: false } } do %>
Three buttons affected: /pricing's Subscribe, the Manage button on /pricing's "current plan" card (jumps to billing.stripe.com), and /accounts' Manage Subscription. Each got data-turbo=false and a regression test.
When I had Claude debug this, it explored three wrong directions: Stripe config (no), redirect_uri whitelist (no), CORS (wrong direction). The Turbo/Stripe conflict isn't in Stripe's docs and isn't in Turbo's docs — and there's almost nothing about it in Claude's training data either. You only catch it by watching the network tab see the 302 come back, then asking yourself "so why didn't the browser follow it?"
After installing the x402-rails gem, the browser console:
Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.
But I'm explicitly lazy-loading via await import("https://esm.run/[email protected]") — full URL — so why "resolve module specifier"?
Root cause: x402-rails gem ships a Stimulus controller that depends on @hotwired/stimulus. I had pinned that package in config/importmap.rb, but the corresponding vendor file vendor/javascript/@hotwired--stimulus.js was never downloaded. importmap notices the file is missing and silently drops the pin from the generated importmap. What's failing isn't my x402-fetch; it's the gem's Stimulus controller. The error bubbles up to the nearest import.
Diagnosis: bin/importmap json outputs the actual generated importmap. Compare it against config/importmap.rb — any pin missing from the json means its vendor file isn't downloaded.
Fix: bin/importmap pin @hotwired/stimulus to actually pull the file down.
Claude doesn't reflexively run bin/importmap json as a sanity check after installing a gem. That's on you. If you're using importmap, after installing any gem that ships Stimulus controllers, run bin/importmap json once and confirm no pins got silently dropped.
In credentials:
x402:
wallet_address: 0x1234abcd...
When Rails loads this, YAML parses 0x1234abcd... as an integer (hex literal). By the time X402.configure reaches the value, the type is wrong, and the gem produces strange paywall requirements.
One-character fix: add quotes.
x402:
wallet_address: "0x1234abcd..."
Claude didn't quote the value when writing the credentials template — its training data is full of bare-string YAML examples. Only triggers when the prefix happens to be 0x / true / false / digits. This kind of "YAML special parsing" trap only fires when you fill in real values.
Stripe covers 99% of users — credit card / Apple Pay / Google Pay. For a $9.99/month flow, the experience can't be beat.
x402 covers the remaining 1% of important people: crypto-native users, international users wanting stablecoins, and developers writing automated agents (whose agents need to be able to pay for paid API access — that's what 402 was designed for).
Key product call: monthly tier doesn't get x402. $9.99/month with a wallet signature every month is awful UX. We only enable x402 on the $99 yearly, where the friction amortizes to once per year.
<% if plan.interval == "year" %>
<%= render "shared/x402_pay_button", ... %>
<% end %>
One if in _plan_card.html.erb decides which cards show the USDC button. That simple.
Letting Claude integrate payments — full checklist:
inclusion: { in: %w[stripe x402] } as a type gate.Rails.env.production?.data-turbo=false. Otherwise Turbo silently eats the cross-origin 302.bin/importmap json. importmap silently drops pins whose vendor files are missing.0x... / true / 07 get YAML-special-parsed otherwise.The hard parts of letting Claude write payments aren't the protocols themselves — they're the integration boundaries (Turbo vs Stripe, importmap vs gem, YAML vs wallet address). Those are the moments you have to sit there yourself.