Free

Claude'a iki ödeme entegrasyonu yaptırmak: Stripe + x402

Tek bir uygulamada tamamen farklı iki protokol — Stripe'ın hosted Checkout + webhook'u ve x402'nin HTTP 402 + tarayıcı cüzdanı. Üç sessiz hata, iki ödeme hattını birlikte çalıştıran tek mimari.


Yakın zamanda how2claude'un Pro tier'ına hem Stripe (kart/fiat) hem de x402 (EVM zincir üstü USDC) bağladım. Claude'a tamamen farklı iki protokolün entegrasyonunu yazdırmak — bir tarafta hosted Checkout + webhook, diğerinde HTTP 402 + tarayıcı cüzdanı — bütün bir gece oturumu aldı. Üç sessiz başarısızlığa takıldım, ve sonunda iki ödeme hattını birlikte çalıştıran bir mimari elde ettim.

Bu "Stripe nasıl entegre edilir" türü bir öğretici değil — onlardan zaten bolca var. İlginç olan: iki protokolün yan yana nasıl konumlandığı, Claude'un nerede en kolay yüz üstü düştüğü, ve hangi anlarda kendi başınıza oturup izlemek zorunda olduğunuz.


İki ödeme paradigması

Boyut Stripe x402
Tetikleyici button_to → checkout.stripe.com'a yönlendirme POST /x402/subscribe → HTTP 402 döndürür
Kullanıcı eylemi Stripe hosted sayfada kart girer Tarayıcı cüzdanında imzalar
Sonuç teslimi webhook (checkout.session.completed) Request X-PAYMENT header ile yeniden denenir, gem senkron settle eder
Saklamam gereken veri payment_intent_id + amount_total tx_hash + payer + amount
Protokol karmaşıklığı SDK her şeyi yapar viem + x402-fetch protokol handshake gerekir

Temelden farklı: Stripe kullanıcıyı kendi sayfasına iter ve siz dönüşte sadece webhook'u doğrularsınız; x402 baştan sona kendi domain'inizde kalır, protokol handshake'i HTTP katmanında yapar.

Bu ayrım aşağıdaki tüm mimari kararları belirler.

Controller'ları inceltin — record metodlarını model'e itin

Başta controller'lar field mapping ile doluydu:

# ❌ Erken sürüm
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... bir düzine satır field mapping
  )
end

İki hat da Purchase + Subscription'ı persist eder, ama field'lar tamamen farklı. Mapping'i controller'da tutmak her hattın aynı mantığı kopyalaması demek.

Migration (9f3e239) bunu model'e itti:

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

Toplam dört metod: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Controller tek satıra düşer:

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

Claude bu tür işte harika: her field'ı uslu uslu mapler, test ekler, validates :provider, inclusion: { in: %w[stripe x402] } ekler. İnsanlar "önce çalışsın" eğilimindedir ve field mapping controller'lara dağılıp asla dışarı çıkmaz.

Tempo: önce elle yazın, sonra gem'e geçin

b2f0333'te Claude'a ilk x402 entegrasyonunu elle yazdırdım — üç sınıf:

  • X402::PaymentHandler — 402 requirements oluştur, PAYMENT-SIGNATURE header'ı decode et
  • X402::FacilitatorClientx402.org/facilitator'ın /verify + /settle'ını sar
  • app/controllers/concerns/content_gate.rb — 402 header'ı algıla, PAYMENT-REQUIRED döndür

449 satır, çalışıyor, testler geçiyor.

Altı saat sonra (9f3e239) hepsini x402-rails gem'iyle (v1 protokol, non-optimistic mod) değiştirttim. Üç sınıfı sildim; controller'lar artık x402_paywall(amount:) DSL'ini kullanıyor ve request.env["x402.payment"] ile request.env["x402.settlement_result"]'ten okuyor.

Tempo önemli: önce elle yazmak protokolü anlamanızı sağlar, sonra gem sizi serbest bırakır. Gem'le başlarsanız, Claude gem'in dokümantasyonuna karşı yazar ve sizin 402 header'ında ne olduğunu veya /settle'ın ne yaptığını bilmezsiniz. Bir şey kırıldığında (her zaman bir şey kırılır), debug edecek zemininiz yok.

Bu örüntü her yeni protokol/servis için işler: Claude'a bir kez elle yazdırın, testleri yeşile çevirin, sonra gem'e değiştirtirin. İkisi arasındaki diff sizin çalışma materyalinizdir.

Chain'i Rails.env ile çalışma zamanında çevirin, deploy'da elle değil

x402 initializer'ı (config/initializers/x402.rb) kuralı hardcode eder:

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  # facilitator settle'ını bekle, tx_hash'i senkron alabilmek için
end

Aynı kod: dev base-sepolia (ücretsiz test token) çalıştırır, prod base mainnet çalıştırır. Deploy'da değiştirilecek bir şey yok. (Bu prensip önceki yazıdan geldi Claude'a Prodüksiyon Deploy Yaptırmak — dev ile prod arasında farklı olan her şey, Rails.env ile çevrilir.)

optimistic = false satırı önemli: gem'in default optimistic modu request'i geçirir, sonra mutabakat yapar; biz kapatıyoruz çünkü action dönmeden önce settlement_result.transaction'ı (tx_hash'i) almak ve Purchase satırına senkron yazmak istiyoruz. tx_hash'siz bir Purchase satırı kullanıcı için değersiz — BaseScan'e tıklayıp işlemi görmek isteyecek.

Frontend: bir taraf hosted, diğer taraf elden yapım

Stripe tarafının "frontend"i tek satır:

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

Kullanıcı tıklar, tarayıcı checkout.stripe.com'a atlar. Sizin tarafta sıfır frontend kodu.

x402 tarafı (93746d8) bir Stimulus controller gerektirdi:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // Lazy-load — vendor bundle'ı şişirme
  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)
  })
  // ...
}

Dikkat çeken iki şey:

  1. viem + x402-fetch lazy-load (sadece ilk buton tıklamasında jsdelivr'dan çekilir). Bu iki paket birlikte büyük; vendor'a paketlemek tüm ödemeyen kullanıcıları indirmeye zorlar. Lazy-load bunu "ödeme yapacaksan indir"e çevirir.
  2. eth_requestAccounts'ın sonucunu kullan, selectedAddress'i değil. selectedAddress deprecated ve çoğu cüzdan eski değer döndürür. Claude'un ilk versiyonu selectedAddress kullanmıştı (MDN docs'a göre); ben birincisine değiştirdim.

Bir şey daha: hata kodlarını sayıya dök. Cüzdan imzayı reddetti 4001, yanlış chain switch gerekiyor CHAIN_SWITCH, ödeme gerekli PAYMENT_REQUIRED. error.message üzerinde string-match yapma — cüzdanlar farklı yazıyor ve buna karşı test yazamazsın.

Tuzak #1: button_to + Turbo, Stripe'ın 302'sini sessizce yutar

527f700 commit'i, tarayıcıyı yarım saat izleyerek bulduğum bir tuzak.

Belirti: /pricing'deki Subscribe butonuna tıkla, hiçbir şey olmaz. Console hatası yok, network hatası yok. Rails log 200 dönüp 302 → checkout.stripe.com/c/pay/cs_xxx gösteriyor. Tarayıcı kıpırdamıyor.

Sebep: button_to <form method="post"> üretir, ve Turbo o form gönderimini yakalayıp yanıtı TURBO_STREAM olarak işler. TURBO_STREAM cross-origin 302'leri takip etmez. Yanıt sessizce Turbo tarafından yutulur; sayfa duruyor.

Fix:

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

Etkilenen üç buton: /pricing'in Subscribe'ı, /pricing'in "current plan" kartındaki Manage (billing.stripe.com'a atlar) ve /accounts'taki Manage Subscription. Her birine data-turbo=false ve regression test eklendi.

Claude'a bunu debug ettirdiğimde üç yanlış yön araştırdı: Stripe yapılandırması (hayır), redirect_uri whitelist (hayır), CORS (yanlış yön). Turbo/Stripe çatışması Stripe dokümantasyonunda da Turbo dokümantasyonunda da yok — ve Claude'un eğitim verisinde de bu konuda neredeyse hiçbir şey yok. Bu tür tuzakları sadece network sekmesinde 302'nin geri geldiğini görüp "öyleyse tarayıcı neden takip etmedi?" diye kendinize sorarak yakalarsınız.

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

x402-rails gem'i kurduktan sonra tarayıcı console'u:

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

Ama ben açıkça await import("https://esm.run/[email protected]") ile lazy-load ediyorum — tam URL — neden "resolve module specifier"?

Kök sebep: x402-rails gem'i @hotwired/stimulus'a bağımlı bir Stimulus controller'ı ile geliyor. O paketi config/importmap.rb'de pin'lemiştim, ama ilgili vendor dosyası vendor/javascript/@hotwired--stimulus.js hiç indirilmemiş. importmap dosyanın eksik olduğunu fark eder ve pin'i sessizce üretilen importmap'tan düşürür. Başarısız olan benim x402-fetch'im değil; gem'in Stimulus controller'ı. Hata en yakın import'a yukarı baloncuklanır.

Tanı: bin/importmap json gerçekten üretilen importmap'i çıkarır. config/importmap.rb ile karşılaştırın — json'da olmayan bir pin varsa, vendor dosyası indirilmemiş demektir.

Fix: bin/importmap pin @hotwired/stimulus ile dosyayı gerçekten çekmek.

Claude bir gem kurduktan sonra refleks olarak bin/importmap json'ı sanity check yapmaz. Bu size kalmış. importmap kullanıyorsanız, Stimulus controller içeren herhangi bir gem kurduktan sonra bir kez bin/importmap json çalıştırın ve sessizce hiçbir pin'in düşmediğini doğrulayın.

Tuzak #3: YAML, 0x... cüzdan adresini integer olarak yorumlar

credentials'da:

x402:
  wallet_address: 0x1234abcd...

Rails bunu yüklediğinde, YAML 0x1234abcd...'i integer (hex literal) olarak parse eder. X402.configure değeri aldığında tip bozulmuş olur, ve gem garip paywall requirements üretir.

Tek karakterlik fix: tırnak ekle.

x402:
  wallet_address: "0x1234abcd..."

Claude credentials template'ini yazarken tırnak koymadı — eğitim verisi çıplak string YAML örnekleriyle dolu. Sadece prefix tesadüfen 0x / true / false / rakamlar olduğunda tetikler. Bu tür "YAML özel parsing" tuzağı sadece gerçek değerleri doldurduğunuzda ateşler.

Bir uygulama neden iki ödeme hattına ihtiyaç duyar

Stripe kullanıcıların %99'unu kapsar — kredi kartı / Apple Pay / Google Pay. $9.99/ay akış için deneyim emsalsiz.

x402 kalan %1 önemli kişiyi kapsar: kripto-yerli kullanıcılar, stablecoin isteyen uluslararası kullanıcılar, ve otomatik agent yazan geliştiriciler (agent'larının ücretli API'lere erişim için kendileri ödeyebilmesi gerekir — 402 bunun için tasarlandı).

Önemli ürün kararı: aylık tier'a x402 verilmiyor. $9.99/ay her ay cüzdan imzasıyla berbat UX. Sadece $99 yıllıkta x402 etkinleştiriyoruz, sürtünme yılda bire amortize oluyor.

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

_plan_card.html.erb'deki bir if hangi kartların USDC butonu göstereceğine karar verir. O kadar basit.


Claude'a ödeme entegrasyonu yaptırmak — tam çek listesi:

  1. Claude'a kod yazdırmadan önce iki protokolü ayrı ayrı anla. Stripe hosted Checkout + webhook ile, x402 HTTP 402 + tarayıcı cüzdanı ile gider — Claude'un kendi başına ayırabilmesini bekleme.
  2. Record metodları model'e ait. Controller bir satır çağırır; tüm field mapping model içinde. Tip kapısı olarak inclusion: { in: %w[stripe x402] } ekle.
  3. Yeni protokoller için önce elle yaz, sonra gem'e geç. İkisi arasındaki diff senin çalışma materyalin.
  4. Chain/mode'u runtime'da Rails.env ile çevir. Stripe test/live, x402 base-sepolia/base — hepsi Rails.env.production? ile çevrilir.
  5. Her Stripe button_to'ya data-turbo=false gerekir. Aksi halde Turbo cross-origin 302'yi sessizce yutar.
  6. Stimulus controller içeren herhangi bir gem kurduktan sonra bin/importmap json çalıştır. importmap vendor dosyaları eksik olan pin'leri sessizce düşürür.
  7. Sayı prefix'i gibi görünen credentials'lara tırnak koy. 0x... / true / 07 aksi halde YAML-özel-parsing alır.

Claude'a ödeme yazdırmanın zor kısmı protokollerin kendisi değil — entegrasyon sınırları (Turbo vs Stripe, importmap vs gem, YAML vs cüzdan adresi). Bunlar kendi başınıza orada oturmanız gereken anlardır.