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.
| 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.
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.
b2f0333'te Claude'a ilk x402 entegrasyonunu elle yazdırdım — üç sınıf:
X402::PaymentHandler — 402 requirements oluştur, PAYMENT-SIGNATURE header'ı decode etX402::FacilitatorClient — x402.org/facilitator'ın /verify + /settle'ını sarapp/controllers/concerns/content_gate.rb — 402 header'ı algıla, PAYMENT-REQUIRED döndür449 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.
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.
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:
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.
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.
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.
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.
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:
inclusion: { in: %w[stripe x402] } ekle.Rails.env.production? ile çevrilir.data-turbo=false gerekir. Aksi halde Turbo cross-origin 302'yi sessizce yutar.bin/importmap json çalıştır. importmap vendor dosyaları eksik olan pin'leri sessizce düşürür.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.