Free

Claude zwei Zahlungswege integrieren lassen: Stripe + x402

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.


Zwei Zahlungsparadigmen

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.

Controller ausdünnen — Record-Methoden ins Model schieben

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.

Das Tempo: zuerst handgeschrieben, dann zum Gem migrieren

In b2f0333 ließ ich Claude die erste x402-Integration von Hand schreiben — drei Klassen:

  • X402::PaymentHandler — 402 Requirements bauen, PAYMENT-SIGNATURE-Header dekodieren
  • X402::FacilitatorClient/verify + /settle von x402.org/facilitator wrappen
  • app/controllers/concerns/content_gate.rb — 402-Header erkennen, PAYMENT-REQUIRED zurückgeben

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

Chain per Rails.env zur Laufzeit umschalten, nicht per Hand beim Deploy

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.

Frontend: eine Seite gehostet, die andere handgebaut

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:

  1. Lazy-load von viem + x402-fetch (erst beim ersten Button-Klick von jsdelivr geholt). Diese zwei Pakete sind zusammen groß; im vendor zu bundlen würde alle nicht-zahlenden User zum Download zwingen. Lazy-load macht daraus "nur herunterladen, wenn du zahlen willst".
  2. Das Ergebnis von 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.

Falle #1: button_to + Turbo schluckt Stripes 302 still

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

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

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.

Falle #3: YAML interpretiert 0x... Wallet-Adresse als Integer

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.

Warum eine App zwei Zahlungsschienen braucht

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:

  1. Verstehe die zwei Protokolle separat, bevor du Claude Code schreiben lässt. Stripe geht hosted Checkout + Webhook; x402 geht HTTP 402 + Browser-Wallet — erwarte nicht, dass Claude sie von selbst auseinanderhält.
  2. Record-Methoden gehören ins Model. Controller rufen eine Zeile; alles Field-Mapping im Model. Füge inclusion: { in: %w[stripe x402] } als Typ-Gate hinzu.
  3. Bei neuen Protokollen erst von Hand, dann auf Gem umsteigen. Das Diff zwischen beiden ist dein Studienmaterial.
  4. Chain/Mode zur Laufzeit per Rails.env umschalten. Stripe test/live, x402 base-sepolia/base — alles per Rails.env.production? umgeschaltet.
  5. Jedes Stripe-button_to braucht data-turbo=false. Sonst schluckt Turbo den cross-origin 302 still.
  6. Nach dem Installieren eines Gems mit Stimulus-Controllern, lauf bin/importmap json. importmap lässt Pins, deren vendor-Dateien fehlen, still fallen.
  7. Setz Anführungszeichen um credentials, die wie Zahlen-Präfixe aussehen. 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.