Zwei völlig unterschiedliche Protokolle in einer App — Stripes gehostetes Checkout + Webhook und x402s HTTP 402 + Browser-Wallet. Drei stille Fallen, eine Architektur, die beide Schienen bedient.
Ich habe kürzlich sowohl Stripe (Karten/Fiat) als auch x402 (EVM On-Chain USDC) an how2claudes Pro-Tier angeschlossen. Claude Integrationen für zwei völlig unterschiedliche Protokolle schreiben zu lassen — eins gehostetes Checkout + Webhook, das andere HTTP 402 + Browser-Wallet — hat eine ganze Abendsession gekostet. Drei stille Fehler getroffen und am Ende eine Architektur, die beide Schienen zusammen fährt.
Das ist kein "Wie integriere ich Stripe"-Tutorial — davon gibt es überall. Das Interessante: wie die beiden Protokolle nebeneinander Platz finden, wo Claude am ehesten auf die Nase fällt, und in welchen Momenten man selbst sitzen und zuschauen muss.
| Dimension | Stripe | x402 |
|---|---|---|
| Trigger | button_to → Redirect zu checkout.stripe.com | POST /x402/subscribe → gibt HTTP 402 zurück |
| User-Aktion | Karte auf Stripe-gehosteter Seite eingeben | In Browser-Wallet signieren |
| Ergebnis-Zustellung | Webhook (checkout.session.completed) | Anfrage erneut mit X-PAYMENT-Header, Gem settled synchron |
| Zu persistierende Daten | payment_intent_id + amount_total | tx_hash + payer + amount |
| Protokoll-Komplexität | SDK macht alles | Braucht viem + x402-fetch Protokoll-Handshake |
Fundamental verschieden: Stripe schiebt den User auf seine eigene Seite, und du verifizierst nur den Webhook bei der Rückkehr; x402 bleibt von Anfang bis Ende auf deiner Domain und erledigt den Protokoll-Handshake auf der HTTP-Ebene.
Diese Unterscheidung treibt jede Architekturentscheidung unten.
Anfangs steckten die Controller voller Field-Mapping:
# ❌ Frühe 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,
# ... ein Dutzend Zeilen Field-Mapping
)
end
Beide Schienen persistieren Purchase + Subscription, aber die Felder sind komplett verschieden. Mapping im Controller bedeutet, dass jede Schiene die Mapping-Logik kopiert.
Die Migration (9f3e239) hat es ins Model geschoben:
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
Vier Methoden insgesamt: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Der Controller wird zu einer Zeile:
Purchase.record_x402!(article:, user:, payment:, settlement:)
Claude ist großartig bei solcher Arbeit: Er wird brav jedes Feld mappen, Tests hinzufügen und validates :provider, inclusion: { in: %w[stripe x402] } ergänzen. Menschen neigen zum "erst mal zum Laufen bringen", und das Field-Mapping endet verstreut über die Controller, ohne je zu entkommen.
In b2f0333 ließ ich Claude die erste x402-Integration von Hand schreiben — drei Klassen:
X402::PaymentHandler — 402 Requirements bauen, PAYMENT-SIGNATURE-Header dekodierenX402::FacilitatorClient — /verify + /settle von x402.org/facilitator wrappenapp/controllers/concerns/content_gate.rb — 402-Header erkennen, PAYMENT-REQUIRED zurückgeben449 Zeilen, funktionierend, Tests grün.
Sechs Stunden später (9f3e239) ließ ich alles durch das x402-rails Gem (v1 Protokoll, non-optimistic Modus) ersetzen. Diese drei Klassen gelöscht; die Controller nutzen jetzt das DSL x402_paywall(amount:) und lesen aus request.env["x402.payment"] und request.env["x402.settlement_result"].
Das Tempo zählt: Erst von Hand schreiben lässt dich das Protokoll verstehen, dann befreit dich das Gem. Wenn du mit dem Gem anfängst, schreibt Claude gegen die Gem-Dokumentation und du hast keine Ahnung, was wirklich im 402-Header steckt oder was /settle macht. Wenn etwas kaputtgeht (irgendwas geht immer kaputt), hast du keinen Boden zum Debuggen.
Dieses Muster funktioniert für jedes neue Protokoll/jeden neuen Dienst: Lass Claude es einmal von Hand schreiben, Tests grün bekommen, dann zum Gem wechseln. Das Diff zwischen beiden ist dein Studienmaterial.
Der x402-Initializer (config/initializers/x402.rb) kodiert die Regel hart:
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 # warte auf Facilitator-Settlement, um tx_hash synchron zu erhalten
end
Gleicher Code: dev läuft auf base-sepolia (kostenlose Test-Token), prod läuft auf base mainnet. Nichts zu ändern beim Deploy. (Das Prinzip kam vom vorherigen Artikel Claude in Produktion deployen lassen — alles, was sich zwischen dev und prod unterscheidet, per Rails.env umschalten.)
Die Zeile optimistic = false zählt: der Standard-Optimistic-Modus des Gems lässt die Anfrage durch und rechnet danach ab; wir schalten ihn aus, weil wir settlement_result.transaction (den tx_hash) brauchen, bevor die Action zurückkehrt, um ihn synchron in die Purchase-Zeile zu schreiben. Eine Purchase-Zeile ohne tx_hash ist für den User wertlos — er will durchklicken und die Transaktion auf BaseScan sehen.
Das Stripe-seitige "Frontend" ist eine Zeile:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
form: { class: "w-full", data: { turbo: false } } do %>
<%= t("pricing.subscribe") %>
<% end %>
User klickt, Browser springt zu checkout.stripe.com. Null Frontend-Code auf deiner Seite.
Die x402-Seite (93746d8) brauchte einen Stimulus-Controller:
// app/javascript/controllers/x402_payment_controller.js
async pay() {
// Lazy-load — vendor-Bundle nicht aufblähen
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)
})
// ...
}
Zwei bemerkenswerte Dinge:
eth_requestAccounts verwenden, nicht selectedAddress. selectedAddress ist deprecated und die meisten Wallets geben einen veralteten Wert zurück. Claudes erste Version verwendete selectedAddress (laut MDN-Docs); ich habe umgestellt.Noch eine Sache: Fehlercodes enumerieren. Wallet hat Signatur abgelehnt ist 4001, falsche Chain braucht Switch ist CHAIN_SWITCH, Zahlung erforderlich ist PAYMENT_REQUIRED. Kein String-Match auf error.message — Wallets formulieren unterschiedlich und du kannst keine Tests dagegen schreiben.
Commit 527f700 fand ich nach einer halben Stunde Browser-Beobachtung.
Symptom: Klick auf Subscribe-Button auf /pricing, nichts passiert. Kein Console-Fehler, kein Network-Fehler. Rails-Log zeigt 200 mit 302 → checkout.stripe.com/c/pay/cs_xxx. Browser bewegt sich nicht.
Ursache: button_to generiert ein <form method="post">, und Turbo fängt den Form-Submit ab und behandelt die Antwort als TURBO_STREAM. TURBO_STREAM folgt keinen cross-origin 302s. Die Antwort wird still von Turbo geschluckt; die Seite bleibt stehen.
Fix:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
- form: { class: "w-full" } do %>
+ form: { class: "w-full", data: { turbo: false } } do %>
Drei Buttons betroffen: Subscribe auf /pricing, Manage-Button auf der "aktueller Plan"-Karte auf /pricing (springt zu billing.stripe.com), und Manage Subscription auf /accounts. Jeder bekam data-turbo=false und einen Regressionstest.
Als ich Claude das debuggen ließ, erkundete er drei falsche Richtungen: Stripe-Config (nein), redirect_uri-Whitelist (nein), CORS (falsche Richtung). Der Turbo/Stripe-Konflikt steht weder in der Stripe-Doku noch in der Turbo-Doku — und in Claudes Trainingsdaten ist dazu fast nichts. Man erwischt es nur, indem man den 302 im Network-Tab zurückkommen sieht und sich fragt: "Warum ist der Browser ihm dann nicht gefolgt?".
Nach der Installation des x402-rails Gems, Browser-Console:
Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.
Aber ich lade explizit lazy via await import("https://esm.run/[email protected]") — volle URL — warum also "resolve module specifier"?
Wurzelursache: das x402-rails Gem bringt einen Stimulus-Controller mit, der von @hotwired/stimulus abhängt. Ich hatte das Paket in config/importmap.rb gepinnt, aber die entsprechende vendor-Datei vendor/javascript/@hotwired--stimulus.js wurde nie heruntergeladen. importmap bemerkt, dass die Datei fehlt, und lässt den Pin still aus dem generierten importmap fallen. Was fehlschlägt, ist nicht mein x402-fetch; es ist der Stimulus-Controller des Gems. Der Fehler bubble-t zum nächsten Import hoch.
Diagnose: bin/importmap json gibt den tatsächlich generierten importmap aus. Vergleiche mit config/importmap.rb — jeder Pin, der im json fehlt, bedeutet, dass seine vendor-Datei nicht heruntergeladen ist.
Fix: bin/importmap pin @hotwired/stimulus, um die Datei wirklich zu ziehen.
Claude führt bin/importmap json nicht reflexhaft als Sanity-Check nach einer Gem-Installation aus. Das liegt an dir. Wenn du importmap nutzt, lauf nach dem Installieren eines Gems mit Stimulus-Controllern einmal bin/importmap json und bestätige, dass keine Pins still fallen gelassen wurden.
In credentials:
x402:
wallet_address: 0x1234abcd...
Wenn Rails das lädt, parst YAML 0x1234abcd... als Integer (Hex-Literal). Wenn X402.configure den Wert erreicht, ist der Typ kaputt, und das Gem produziert seltsame Paywall-Requirements.
Ein-Zeichen-Fix: Anführungszeichen hinzufügen.
x402:
wallet_address: "0x1234abcd..."
Claude hat beim Schreiben des credentials-Templates keine Anführungszeichen gesetzt — seine Trainingsdaten sind voller nackter-String-YAML-Beispiele. Feuert nur, wenn der Präfix zufällig 0x / true / false / Ziffern ist. Diese Art "YAML-Sonderparsing"-Falle feuert nur, wenn du echte Werte einfüllst.
Stripe deckt 99% der User ab — Kreditkarte / Apple Pay / Google Pay. Für einen $9.99/Monat-Flow ist die Erfahrung unschlagbar.
x402 deckt die verbleibenden 1% wichtigen Leute ab: krypto-native User, internationale User, die Stablecoins wollen, und Entwickler, die automatisierte Agents schreiben (deren Agents müssen selbst für bezahlten API-Zugang zahlen können — dafür wurde 402 entworfen).
Wichtige Produktentscheidung: Monatlicher Tier bekommt kein x402. $9.99/Monat mit Wallet-Signatur jeden Monat ist schreckliches UX. Wir aktivieren x402 nur auf dem $99-Jahres-Tier, wo die Reibung sich auf einmal pro Jahr amortisiert.
<% if plan.interval == "year" %>
<%= render "shared/x402_pay_button", ... %>
<% end %>
Ein if in _plan_card.html.erb entscheidet, welche Karten den USDC-Button zeigen. So einfach.
Claude Zahlungen integrieren lassen — komplette Checkliste:
inclusion: { in: %w[stripe x402] } als Typ-Gate hinzu.Rails.env.production? umgeschaltet.data-turbo=false. Sonst schluckt Turbo den cross-origin 302 still.bin/importmap json. importmap lässt Pins, deren vendor-Dateien fehlen, still fallen.0x... / true / 07 bekommen sonst YAML-Sonderparsing.Die harten Teile beim Claude-Zahlungen-Schreiben sind nicht die Protokolle selbst — sondern die Integrations-Grenzen (Turbo vs Stripe, importmap vs Gem, YAML vs Wallet-Adresse). Das sind die Momente, in denen du selbst dort sitzen musst.