Free

Letting Claude Integrate Two Payment Rails — Stripe + x402

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.


Two payment paradigms

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.

Thin out the controllers — push record methods into the model

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.

The pacing: hand-roll first, then migrate to the gem

In b2f0333 I had Claude write the first x402 integration by hand — three classes:

  • X402::PaymentHandler — build 402 requirements, decode the PAYMENT-SIGNATURE header
  • X402::FacilitatorClient — wrap x402.org/facilitator's /verify + /settle
  • app/controllers/concerns/content_gate.rb — detect the 402 header, return PAYMENT-REQUIRED

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

Flip the chain by Rails.env, not by hand at deploy time

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.

Frontend: one side hosted, the other side hand-built

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:

  1. Lazy-load viem + x402-fetch (only fetched from jsdelivr on the first button click). These two packages are large; bundling them into vendor would force every non-paying user to download them. Lazy-load makes it "download only if you want to pay."
  2. Use 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.

Pitfall #1: button_to + Turbo silently eats Stripe's 302

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?"

Pitfall #2: Failed to resolve module specifier 'x402-fetch'

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.

Pitfall #3: YAML treats 0x... wallet address as an integer

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.

Why one app needs two payment rails

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:

  1. Understand the two protocols separately before letting Claude write code. Stripe goes hosted Checkout + webhook; x402 goes HTTP 402 + browser wallet — don't expect Claude to keep them straight on its own.
  2. Record methods belong in the model. Controllers call one line; all field mapping in the model. Add inclusion: { in: %w[stripe x402] } as a type gate.
  3. For new protocols, hand-roll first, then swap to the gem. The diff between the two is your study material.
  4. Flip chain/mode at runtime by Rails.env. Stripe test/live, x402 base-sepolia/base — all flipped via Rails.env.production?.
  5. Every Stripe button_to needs data-turbo=false. Otherwise Turbo silently eats the cross-origin 302.
  6. After installing any gem with Stimulus controllers, run bin/importmap json. importmap silently drops pins whose vendor files are missing.
  7. Quote any credentials that look like number prefixes. 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.