Free

Lasciare che Claude integri due pagamenti: Stripe + x402

Due protocolli completamente diversi in un'app — Checkout hosted di Stripe + webhook e HTTP 402 + wallet browser di x402. Tre trappole silenziose, un'architettura che fa girare entrambi i binari.


Di recente ho collegato sia Stripe (carte/fiat) sia x402 (USDC on-chain EVM) al tier Pro di how2claude. Far scrivere a Claude integrazioni per due protocolli completamente diversi — uno Checkout hosted + webhook, l'altro HTTP 402 + wallet del browser — ha richiesto un'intera sessione serale. Sono inciampato in tre fallimenti silenziosi, e sono finito con un'architettura che fa girare entrambi i binari insieme.

Questo non è un tutorial "come integrare Stripe" — quelli sono ovunque. Le parti interessanti: come i due protocolli stanno fianco a fianco, dove Claude ha più probabilità di schiantarsi, e quali momenti ti obbligano a stare lì tu stesso.


Due paradigmi di pagamento

Dimensione Stripe x402
Trigger button_to → redirect a checkout.stripe.com POST /x402/subscribe → restituisce HTTP 402
Azione utente Inserisce carta nella pagina hosted Stripe Firma nel wallet del browser
Consegna risultato webhook (checkout.session.completed) Richiesta rifatta con header X-PAYMENT, il gem regola sincrono
Dati da persistere payment_intent_id + amount_total tx_hash + payer + amount
Complessità protocollo SDK fa tutto Serve handshake viem + x402-fetch

Fondamentalmente diversi: Stripe spinge l'utente sulla propria pagina e tu verifichi solo il webhook quando torna; x402 resta interamente sul tuo dominio dall'inizio alla fine, facendo l'handshake del protocollo al livello HTTP.

Questa distinzione guida ogni decisione architetturale qui sotto.

Dimagrisci i controller — spingi i metodi record nel model

All'inizio i controller erano pieni di mapping di campi:

# ❌ Versione iniziale
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... una dozzina di righe di mapping
  )
end

Entrambi i binari persistono Purchase + Subscription, ma i campi sono totalmente diversi. Il mapping nel controller significa che ogni binario copia la logica di mapping.

La migrazione (9f3e239) l'ha spinto nel 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

Quattro metodi in tutto: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Il controller diventa una riga:

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

Claude è ottimo in questo tipo di lavoro: mapperà diligentemente ogni campo, aggiungerà test e validates :provider, inclusion: { in: %w[stripe x402] }. Gli umani tendono a "prima farlo funzionare", e il mapping dei campi finisce sparso tra i controller senza mai scappare.

Il ritmo: a mano prima, poi migra al gem

In b2f0333 ho fatto scrivere a Claude la prima integrazione x402 a mano — tre classi:

  • X402::PaymentHandler — costruisce 402 requirements, decodifica header PAYMENT-SIGNATURE
  • X402::FacilitatorClient — avvolge /verify + /settle di x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — rileva header 402, restituisce PAYMENT-REQUIRED

449 righe, funzionante, test verdi.

Sei ore dopo (9f3e239) gli ho fatto sostituire tutto con il gem x402-rails (protocollo v1, modalità non-optimistic). Cancellate quelle tre classi; i controller ora usano il DSL x402_paywall(amount:) e leggono da request.env["x402.payment"] e request.env["x402.settlement_result"].

Il ritmo conta: scrivere a mano prima ti fa capire il protocollo, poi il gem ti libera. Se inizi con il gem, Claude scrive contro la documentazione del gem e non hai idea di cosa ci sia davvero nell'header 402 o cosa faccia /settle. Quando qualcosa si rompe (qualcosa si rompe sempre), non hai terreno per debuggare.

Questo pattern funziona per qualsiasi nuovo protocollo/servizio: fai scrivere a Claude a mano una volta, porta i test al verde, poi fallo passare al gem. Il diff tra i due è il tuo materiale di studio.

Cambia la chain con Rails.env a runtime, non a mano al deploy

L'initializer x402 (config/initializers/x402.rb) codifica la regola in hard-code:

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  # aspetta il settle del facilitator prima di continuare, per prendere tx_hash sincrono
end

Stesso codice: dev gira su base-sepolia (token di test gratis), prod gira su base mainnet. Niente da cambiare al deploy. (Questo principio veniva dall'articolo precedente Lasciare che Claude faccia il deploy in produzione — qualsiasi cosa differisca tra dev e prod, ribaltala via Rails.env.)

La riga optimistic = false conta: la modalità ottimistica di default del gem fa passare la richiesta e riconcilia dopo; la spegniamo perché vogliamo settlement_result.transaction (il tx_hash) prima che l'action ritorni, per scriverlo sincrono nella riga Purchase. Una riga Purchase senza tx_hash non vale niente per l'utente — vuole cliccare e vedere la transazione su BaseScan.

Frontend: un lato hosted, l'altro fatto a mano

Il "frontend" lato Stripe è una riga:

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

Utente clicca, browser salta a checkout.stripe.com. Zero codice frontend dal tuo lato.

Il lato x402 (93746d8) ha avuto bisogno di un controller Stimulus:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // Lazy-load — non gonfiare il bundle 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)
  })
  // ...
}

Due cose da notare:

  1. Lazy-load di viem + x402-fetch (preso da jsdelivr solo al primo click del bottone). Questi due pacchetti insieme sono grandi; metterli in vendor costringerebbe ogni utente non pagante a scaricarli. Il lazy-load lo trasforma in "scarica solo se vuoi pagare".
  2. Usa il risultato di eth_requestAccounts, non selectedAddress. selectedAddress è deprecato e la maggior parte dei wallet restituisce un valore obsoleto. La prima versione di Claude usava selectedAddress (secondo i docs MDN); ho cambiato.

Un'altra cosa: enumera i codici di errore. Wallet ha rifiutato la firma è 4001, chain sbagliata serve switch è CHAIN_SWITCH, pagamento richiesto è PAYMENT_REQUIRED. Non fare string-match su error.message — i wallet formulano in modo diverso e non puoi scrivere test contro di esso.

Trappola #1: button_to + Turbo inghiotte silenziosamente il 302 di Stripe

Il commit 527f700 l'ho trovato dopo aver fissato il browser per mezz'ora.

Sintomo: click sul bottone Subscribe di /pricing, non succede niente. Nessun errore console, nessun errore di rete. Il log Rails mostra 200 che ritorna un 302 → checkout.stripe.com/c/pay/cs_xxx. Il browser non si muove.

Causa: button_to genera un <form method="post">, e Turbo intercetta il submit del form, trattando la risposta come TURBO_STREAM. TURBO_STREAM non segue 302 cross-origin. La risposta viene silenziosamente inghiottita da Turbo; la pagina resta ferma.

Fix:

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

Tre bottoni colpiti: Subscribe di /pricing, bottone Manage sulla card "piano attuale" di /pricing (che salta a billing.stripe.com), e Manage Subscription di /accounts. Ognuno ha ricevuto data-turbo=false e un test di regressione.

Quando ho fatto debuggare questo a Claude, ha esplorato tre direzioni sbagliate: configurazione Stripe (no), whitelist redirect_uri (no), CORS (direzione sbagliata). Il conflitto Turbo/Stripe non è nei docs di Stripe né nei docs di Turbo — e non c'è quasi nulla al riguardo nei dati di addestramento di Claude. Si becca solo guardando il 302 tornare nella tab network e chiedendosi "allora perché il browser non l'ha seguito?".

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

Dopo aver installato il gem x402-rails, console del browser:

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

Ma sto esplicitamente lazy-loading via await import("https://esm.run/[email protected]") — URL completa — allora perché "resolve module specifier"?

Causa radice: il gem x402-rails porta con sé un controller Stimulus che dipende da @hotwired/stimulus. Avevo pinnato quel pacchetto in config/importmap.rb, ma il file vendor corrispondente vendor/javascript/@hotwired--stimulus.js non era mai stato scaricato. importmap nota che il file manca e droppa silenziosamente il pin dall'importmap generato. Ciò che fallisce non è il mio x402-fetch; è il controller Stimulus del gem. L'errore bolla fino all'import più vicino.

Diagnosi: bin/importmap json stampa l'importmap realmente generato. Confronta con config/importmap.rb — qualsiasi pin assente dal json significa che il suo file vendor non è scaricato.

Fix: bin/importmap pin @hotwired/stimulus per tirare giù davvero il file.

Claude non lancia bin/importmap json per riflesso come sanity check dopo aver installato un gem. Spetta a te. Se usi importmap, dopo aver installato qualsiasi gem che porta controller Stimulus, lancia bin/importmap json una volta e conferma che nessun pin sia stato droppato silenziosamente.

Trappola #3: YAML interpreta l'indirizzo wallet 0x... come intero

Nei credentials:

x402:
  wallet_address: 0x1234abcd...

Quando Rails carica questo, YAML parsa 0x1234abcd... come intero (letterale hex). Quando X402.configure raggiunge il valore, il tipo è rotto, e il gem produce paywall requirement strani.

Fix di un carattere: aggiungi virgolette.

x402:
  wallet_address: "0x1234abcd..."

Claude non ha messo virgolette scrivendo il template credentials — i suoi dati di addestramento sono pieni di esempi YAML con stringhe nude. Scatta solo quando il prefisso è per caso 0x / true / false / cifre. Questo tipo di trappola "parsing speciale YAML" scatta solo quando riempi valori reali.

Perché un'app ha bisogno di due binari di pagamento

Stripe copre il 99% degli utenti — carta di credito / Apple Pay / Google Pay. Per un flusso a $9.99/mese, l'esperienza è imbattibile.

x402 copre il restante 1% di persone importanti: utenti cripto-nativi, utenti internazionali che vogliono stablecoin, e sviluppatori che scrivono agent automatizzati (i cui agent devono poter pagare da soli per accedere a API a pagamento — 402 è stato progettato per questo).

Decisione di prodotto chiave: il tier mensile non ottiene x402. $9.99/mese con firma wallet ogni mese è UX terribile. Abilitiamo x402 solo sull'annuale a $99, dove l'attrito si ammortizza a una volta all'anno.

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

Un if in _plan_card.html.erb decide quali card mostrano il bottone USDC. Semplice così.


Lasciare che Claude integri pagamenti — checklist completa:

  1. Capisci i due protocolli separatamente prima di far scrivere codice a Claude. Stripe va con hosted Checkout + webhook; x402 va con HTTP 402 + wallet del browser — non aspettarti che Claude li tenga separati da solo.
  2. I metodi record appartengono al model. I controller chiamano una riga; tutto il mapping dei campi nel model. Aggiungi inclusion: { in: %w[stripe x402] } come porta di tipo.
  3. Per protocolli nuovi, a mano prima, poi passa al gem. Il diff tra i due è il tuo materiale di studio.
  4. Cambia chain/mode a runtime via Rails.env. Stripe test/live, x402 base-sepolia/base — tutto ribaltato via Rails.env.production?.
  5. Ogni button_to Stripe ha bisogno di data-turbo=false. Altrimenti Turbo inghiotte silenziosamente il 302 cross-origin.
  6. Dopo aver installato qualsiasi gem con controller Stimulus, lancia bin/importmap json. importmap droppa silenziosamente pin i cui file vendor mancano.
  7. Metti virgolette a qualsiasi credential che sembra un prefisso numerico. 0x... / true / 07 altrimenti prendono parsing speciale YAML.

Le parti difficili nel far scrivere pagamenti a Claude non sono i protocolli stessi — sono i confini di integrazione (Turbo vs Stripe, importmap vs gem, YAML vs indirizzo wallet). Quelli sono i momenti in cui devi stare lì tu stesso.