Free

Laisser Claude intégrer deux paiements : Stripe + x402

Deux protocoles complètement différents dans une seule app — Checkout hébergé de Stripe + webhook, et HTTP 402 + wallet navigateur de x402. Trois pièges silencieux, une architecture qui fait tourner les deux voies.


J'ai récemment branché à la fois Stripe (cartes/fiat) et x402 (USDC on-chain EVM) sur le tier Pro de how2claude. Laisser Claude écrire les intégrations pour deux protocoles complètement différents — un Checkout hébergé + webhook, l'autre HTTP 402 + wallet navigateur — a pris une session complète de soirée. J'ai buté sur trois échecs silencieux, et je me suis retrouvé avec une architecture qui fait tourner les deux voies ensemble.

Ce n'est pas un tutoriel "comment intégrer Stripe" — il y en a partout. Les parties intéressantes : comment les deux protocoles tiennent côte à côte, où Claude est le plus susceptible de se planter, et quels moments vous obligent à rester vous-même à regarder.


Deux paradigmes de paiement

Dimension Stripe x402
Déclencheur button_to → redirection vers checkout.stripe.com POST /x402/subscribe → renvoie HTTP 402
Action utilisateur Saisit la carte sur la page hébergée Stripe Signe dans le wallet navigateur
Livraison du résultat webhook (checkout.session.completed) Requête retentée avec en-tête X-PAYMENT, le gem règle de façon synchrone
Données à persister payment_intent_id + amount_total tx_hash + payer + amount
Complexité du protocole Le SDK fait tout Besoin du handshake viem + x402-fetch

Fondamentalement différents : Stripe pousse l'utilisateur vers sa propre page et vous vérifiez juste le webhook quand il revient ; x402 reste entièrement sur votre domaine du début à la fin, faisant le handshake du protocole sur la couche HTTP.

Cette distinction pilote chaque décision d'architecture qui suit.

Amincir les controllers — pousser les méthodes record dans le model

Au début les controllers étaient remplis de mapping de champs :

# ❌ Première 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,
    # ... une douzaine de lignes de mapping de champs
  )
end

Les deux voies persistent Purchase + Subscription, mais les champs sont complètement différents. Le mapping dans le controller signifie que chaque voie copie la logique de mapping.

La migration (9f3e239) l'a poussé dans le 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

Quatre méthodes en tout : Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Le controller devient une ligne :

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

Claude excelle dans ce genre de travail : il mappera docilement chaque champ, ajoutera des tests, et ajoutera validates :provider, inclusion: { in: %w[stripe x402] }. Les humains ont tendance à "d'abord faire marcher" et le mapping des champs finit éparpillé dans les controllers, sans jamais s'en échapper.

Le rythme : à la main d'abord, puis migrer vers le gem

Dans b2f0333 j'ai fait écrire à Claude la première intégration x402 à la main — trois classes :

  • X402::PaymentHandler — construire les 402 requirements, décoder l'en-tête PAYMENT-SIGNATURE
  • X402::FacilitatorClient — envelopper /verify + /settle de x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — détecter l'en-tête 402, renvoyer PAYMENT-REQUIRED

449 lignes, qui marchent, tests qui passent.

Six heures plus tard (9f3e239) je lui ai fait tout remplacer par le gem x402-rails (protocole v1, mode non-optimiste). J'ai supprimé ces trois classes ; les controllers utilisent maintenant le DSL x402_paywall(amount:) et lisent depuis request.env["x402.payment"] et request.env["x402.settlement_result"].

Le rythme compte : écrire à la main d'abord vous fait comprendre le protocole, puis le gem vous libère. Si vous commencez avec le gem, Claude écrit contre la doc du gem et vous n'avez aucune idée de ce qui est réellement dans l'en-tête 402 ni de ce que fait /settle. Quand quelque chose casse (quelque chose casse toujours), vous n'avez rien pour débugger.

Ce pattern fonctionne pour n'importe quel nouveau protocole ou service : faites écrire Claude à la main une fois, passez les tests en vert, puis faites-le basculer vers le gem. Le diff entre les deux est votre matériel d'étude.

Basculer la chaîne par Rails.env, pas à la main au déploiement

L'initializer x402 (config/initializers/x402.rb) code la règle en dur :

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  # attend le règlement du facilitator avant de continuer, pour attraper tx_hash en sync
end

Même code : dev tourne sur base-sepolia (tokens de test gratuits), prod tourne sur base mainnet. Rien à changer au déploiement. (Ce principe vient de l'article précédent Laisser Claude déployer en production — tout ce qui diffère entre dev et prod, bascule via Rails.env.)

La ligne optimistic = false compte : le mode optimiste par défaut du gem laisse passer la requête et réconcilie après ; on le désactive parce qu'on veut settlement_result.transaction (le tx_hash) avant que l'action ne retourne, pour l'écrire synchrone dans la ligne Purchase. Une ligne Purchase sans tx_hash ne vaut rien pour l'utilisateur — il veut cliquer et voir la transaction sur BaseScan.

Frontend : un côté hébergé, l'autre fait à la main

Le "frontend" côté Stripe est une ligne :

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

L'utilisateur clique, le navigateur saute vers checkout.stripe.com. Zéro code frontend de votre côté.

Le côté x402 (93746d8) a eu besoin d'un controller Stimulus :

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // Chargement différé — ne pas gonfler le 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)
  })
  // ...
}

Deux choses à noter :

  1. Chargement différé de viem + x402-fetch (seulement récupéré depuis jsdelivr au premier clic sur le bouton). Ces deux paquets sont gros ; les bundler dans vendor forcerait tous les utilisateurs non-payants à les télécharger. Le lazy-load en fait "télécharge seulement si tu veux payer".
  2. Utiliser le résultat de eth_requestAccounts, pas selectedAddress. selectedAddress est deprecated et la plupart des wallets renvoient une valeur obsolète. La première version de Claude utilisait selectedAddress (selon la doc MDN) ; j'ai basculé.

Une chose de plus : énumérer les codes d'erreur. Signature refusée par le wallet c'est 4001, mauvaise chaîne qui nécessite un switch c'est CHAIN_SWITCH, paiement requis c'est PAYMENT_REQUIRED. Ne faites pas de string-match sur error.message — les wallets formulent différemment et vous ne pouvez pas écrire de tests dessus.

Piège #1 : button_to + Turbo avale silencieusement le 302 de Stripe

Le commit 527f700 est un que j'ai trouvé après avoir surveillé le navigateur une demi-heure.

Symptôme : clic sur le bouton Subscribe de /pricing, rien ne se passe. Pas d'erreur console, pas d'erreur réseau. Le log Rails montre un 200 renvoyant un 302 → checkout.stripe.com/c/pay/cs_xxx. Le navigateur ne bouge pas.

Cause : button_to génère un <form method="post">, et Turbo intercepte la soumission du form, traitant la réponse comme TURBO_STREAM. TURBO_STREAM ne suit pas les 302 cross-origin. La réponse est silencieusement avalée par Turbo ; la page reste figée.

Fix :

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

Trois boutons concernés : le Subscribe de /pricing, le Manage de la carte "plan actuel" sur /pricing (qui saute vers billing.stripe.com), et le Manage Subscription de /accounts. Chacun a reçu data-turbo=false et un test de régression.

Quand j'ai fait débugger Claude, il a exploré trois mauvaises directions : config Stripe (non), whitelist redirect_uri (non), CORS (mauvaise direction). Le conflit Turbo/Stripe n'est pas dans la doc Stripe ni dans la doc Turbo — et il n'y a presque rien dessus dans les données d'entraînement de Claude. On ne l'attrape qu'en regardant le 302 revenir dans l'onglet network puis en se demandant "alors pourquoi le navigateur ne l'a pas suivi ?".

Piège #2 : Failed to resolve module specifier 'x402-fetch'

Après avoir installé le gem x402-rails, console du navigateur :

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

Mais je fais explicitement du lazy-load via await import("https://esm.run/[email protected]") — URL complète — alors pourquoi "resolve module specifier" ?

Cause racine : le gem x402-rails embarque un controller Stimulus qui dépend de @hotwired/stimulus. J'avais pin ce paquet dans config/importmap.rb, mais le fichier vendor correspondant vendor/javascript/@hotwired--stimulus.js n'avait jamais été téléchargé. importmap remarque que le fichier manque et lâche silencieusement le pin de l'importmap généré. Ce qui échoue n'est pas mon x402-fetch ; c'est le controller Stimulus du gem. L'erreur bulle jusqu'à l'import le plus proche.

Diagnostic : bin/importmap json sort l'importmap réellement généré. Comparez à config/importmap.rb — tout pin absent du json signifie que son fichier vendor n'est pas téléchargé.

Fix : bin/importmap pin @hotwired/stimulus pour vraiment récupérer le fichier.

Claude ne lance pas bin/importmap json par réflexe comme sanity check après l'installation d'un gem. C'est à vous. Si vous utilisez importmap, après l'installation de n'importe quel gem qui embarque des controllers Stimulus, lancez bin/importmap json une fois et confirmez qu'aucun pin n'a été silencieusement lâché.

Piège #3 : YAML interprète l'adresse de wallet 0x... comme un entier

Dans credentials :

x402:
  wallet_address: 0x1234abcd...

Quand Rails charge ceci, YAML parse 0x1234abcd... comme un entier (hex literal). Au moment où X402.configure reçoit la valeur, le type est cassé, et le gem produit des paywall requirements bizarres.

Fix d'un caractère : ajouter des guillemets.

x402:
  wallet_address: "0x1234abcd..."

Claude n'a pas mis de guillemets en écrivant le template credentials — ses données d'entraînement sont remplies d'exemples YAML avec des strings nues. Ne se déclenche que quand le préfixe est par hasard 0x / true / false / des chiffres. Ce type de piège "parsing spécial YAML" ne se déclenche que quand vous remplissez de vraies valeurs.

Pourquoi une app a besoin de deux voies de paiement

Stripe couvre 99% des utilisateurs — carte de crédit / Apple Pay / Google Pay. Pour un flux à $9.99/mois, l'expérience est imbattable.

x402 couvre le 1% restant de gens importants : utilisateurs crypto-natifs, utilisateurs internationaux voulant des stablecoins, et développeurs écrivant des agents automatisés (dont les agents ont besoin de pouvoir payer eux-mêmes l'accès à des API payantes — c'est pour ça que 402 a été conçu).

Décision produit clé : le tier mensuel n'a pas x402. $9.99/mois avec une signature de wallet tous les mois c'est une UX atroce. On n'active x402 que sur l'annuel à $99, où la friction s'amortit à une fois par an.

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

Un if dans _plan_card.html.erb décide quelles cartes affichent le bouton USDC. Aussi simple que ça.


Laisser Claude intégrer les paiements — checklist complète :

  1. Comprenez les deux protocoles séparément avant de laisser Claude écrire le code. Stripe fait hosted Checkout + webhook ; x402 fait HTTP 402 + wallet navigateur — n'espérez pas que Claude les tienne à part tout seul.
  2. Les méthodes record appartiennent au model. Les controllers appellent une ligne ; tout le mapping dans le model. Ajoutez inclusion: { in: %w[stripe x402] } comme portail de type.
  3. Pour les nouveaux protocoles, à la main d'abord, puis bascule vers le gem. Le diff entre les deux est votre matériel d'étude.
  4. Bascule chaîne/mode à l'exécution via Rails.env. Stripe test/live, x402 base-sepolia/base — tout basculé via Rails.env.production?.
  5. Tout button_to Stripe a besoin de data-turbo=false. Sinon Turbo avale silencieusement le 302 cross-origin.
  6. Après l'installation de n'importe quel gem avec des controllers Stimulus, lancez bin/importmap json. importmap lâche silencieusement les pins dont les fichiers vendor manquent.
  7. Mettez des guillemets autour de toute credential qui ressemble à un préfixe numérique. 0x... / true / 07 subissent un parsing spécial YAML autrement.

Les parties difficiles pour laisser Claude écrire des paiements ne sont pas les protocoles eux-mêmes — ce sont les frontières d'intégration (Turbo contre Stripe, importmap contre gem, YAML contre adresse de wallet). Ce sont les moments où vous devez rester assis là vous-même.