Dua protokol yang sangat berbeda dalam satu app — Checkout hosted Stripe + webhook dan HTTP 402 + wallet browser x402. Tiga jebakan gagal-diam, satu arsitektur yang jalan keduanya.
Baru-baru ini saya pasang Stripe (kartu/fiat) dan x402 (USDC on-chain di EVM) sekaligus ke tier Pro how2claude. Membiarkan Claude menulis integrasi untuk dua protokol yang sangat berbeda — satu Checkout hosted + webhook, satunya HTTP 402 + wallet browser — makan satu sesi malam penuh. Kena tiga jebakan gagal-diam, dan akhirnya punya arsitektur yang menjalankan kedua jalur sekaligus.
Ini bukan tutorial "cara integrasi Stripe" — yang itu udah berserakan. Bagian menariknya: gimana dua protokol pas berdampingan, di mana Claude paling gampang nyungsep, dan momen mana yang harus kamu duduk dan awasi sendiri.
| Dimensi | Stripe | x402 |
|---|---|---|
| Pemicu | button_to → redirect ke checkout.stripe.com | POST /x402/subscribe → balikin HTTP 402 |
| Aksi user | Masukin kartu di halaman hosted Stripe | Tanda tangan di wallet browser |
| Pengiriman hasil | webhook (checkout.session.completed) | Request di-retry dengan header X-PAYMENT, gem settle sinkron |
| Data yang harus disimpan | payment_intent_id + amount_total | tx_hash + payer + amount |
| Kompleksitas protokol | SDK ngerjain semua | Butuh handshake viem + x402-fetch |
Beda fundamental: Stripe ngedorong user ke halamannya sendiri dan kamu cuma verifikasi webhook saat dia balik; x402 nempel di domain kamu dari awal sampai akhir, ngerjain handshake protokol di layer HTTP.
Perbedaan itu nyetir setiap keputusan arsitektur di bawah.
Awalnya controller dijejali field mapping:
# ❌ Versi awal
def subscribe_via_stripe
session = Stripe::Checkout::Session.retrieve(params[:session_id])
Subscription.create!(
user: current_user,
provider: "stripe",
stripe_subscription_id: session.subscription,
# ... selusin baris field mapping
)
end
Kedua jalur sama-sama persist Purchase + Subscription, tapi field-nya beda total. Mapping di controller artinya tiap jalur nyalin logika mapping.
Migrasi (9f3e239) ngedorong ke 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
Total empat method: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Controller jadi satu baris:
Purchase.record_x402!(article:, user:, payment:, settlement:)
Claude bagus banget di kerjaan kayak gini: dia bakal mapping tiap field rapi, nambah test, dan nambah validates :provider, inclusion: { in: %w[stripe x402] }. Manusia cenderung "pokoknya jalan dulu" dan field mapping berakhir berserak di antara controller, gak pernah keluar.
Di b2f0333 saya bikin Claude nulis integrasi x402 pertama dengan tangan — tiga kelas:
X402::PaymentHandler — bangun 402 requirements, decode header PAYMENT-SIGNATUREX402::FacilitatorClient — bungkus /verify + /settle dari x402.org/facilitatorapp/controllers/concerns/content_gate.rb — deteksi header 402, balikin PAYMENT-REQUIRED449 baris, jalan, test lewat.
Enam jam kemudian (9f3e239) saya tukerin semua ke gem x402-rails (protokol v1, mode non-optimistic). Hapus tiga kelas itu; controller sekarang pake DSL x402_paywall(amount:) dan baca dari request.env["x402.payment"] dan request.env["x402.settlement_result"].
Ritmenya penting: nulis tangan dulu bikin kamu paham protokolnya, terus gem ngebebasin kamu. Kalau langsung mulai dari gem, Claude nulis sesuai docs gem dan kamu gak ngerti apa yang ada di header 402 atau apa yang /settle lakuin. Pas ada yang rusak (selalu ada yang rusak), kamu gak punya pijakan buat debug.
Pola ini jalan buat protokol/servis baru apapun: bikin Claude nulis tangan sekali, hijaukan test, terus tukerin ke gem. Diff antara keduanya jadi materi belajar kamu.
Initializer x402 (config/initializers/x402.rb) hardcode aturannya:
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 # tunggu settle facilitator sebelum lanjut, supaya bisa ambil tx_hash sinkron
end
Kode sama: dev jalan base-sepolia (token test gratis), prod jalan base mainnet. Gak ada yang harus diubah saat deploy. (Prinsip ini dari artikel sebelumnya Membiarkan Claude Deploy ke Production — apa pun yang beda antara dev dan prod, flip lewat Rails.env.)
Baris optimistic = false itu penting: mode optimistic default gem ngeloloskan request dan rekonsiliasi belakangan; kita matiin karena pengen settlement_result.transaction (tx_hash-nya) sebelum action return, biar ditulis sinkron ke baris Purchase. Baris Purchase tanpa tx_hash gak ada gunanya buat user — dia mau klik dan lihat transaksinya di BaseScan.
"Frontend" Stripe satu baris:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
form: { class: "w-full", data: { turbo: false } } do %>
<%= t("pricing.subscribe") %>
<% end %>
User klik, browser lompat ke checkout.stripe.com. Nol kode frontend di pihak kamu.
Sisi x402 (93746d8) butuh controller Stimulus:
// app/javascript/controllers/x402_payment_controller.js
async pay() {
// Lazy-load — jangan gembungin vendor bundle
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)
})
// ...
}
Dua hal yang patut diperhatikan:
eth_requestAccounts, jangan selectedAddress. selectedAddress udah deprecated, kebanyakan wallet balikin nilai usang. Versi pertama Claude pake selectedAddress (sesuai docs MDN); saya ganti.Satu lagi: enumerate kode error. Wallet tolak signature itu 4001, chain salah perlu switch itu CHAIN_SWITCH, perlu bayar itu PAYMENT_REQUIRED. Jangan string-match error.message — wallet beda kata-katanya dan kamu gak bisa nulis test buatnya.
Commit 527f700 itu yang saya pelototin browser setengah jam baru ketemu.
Gejala: klik tombol Subscribe di /pricing, gak ada apa-apa. Tanpa error console, tanpa error network. Log Rails nunjukin 200 balikin 302 → checkout.stripe.com/c/pay/cs_xxx. Browser gak bergerak.
Penyebab: button_to generate <form method="post">, dan Turbo intercept submit form itu, ngeperlakuin response sebagai TURBO_STREAM. TURBO_STREAM gak ngikutin 302 cross-origin. Response ditelan diam-diam sama Turbo; halaman diam aja.
Fix:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
- form: { class: "w-full" } do %>
+ form: { class: "w-full", data: { turbo: false } } do %>
Tiga tombol kena: Subscribe di /pricing, tombol Manage di card "current plan" /pricing (lompat ke billing.stripe.com), dan Manage Subscription di /accounts. Masing-masing dapet data-turbo=false dan test regresi.
Pas saya suruh Claude debug ini, dia jelajah tiga arah yang salah: konfigurasi Stripe (bukan), whitelist redirect_uri (bukan), CORS (arah salah). Konflik Turbo/Stripe gak ada di docs Stripe maupun docs Turbo — dan hampir gak ada di data training Claude juga. Jebakan kayak gini cuma ketangkep dengan ngeliat 302 balik di tab network terus nanya diri sendiri "lho kenapa browsernya gak ngikutin?".
Habis install gem x402-rails, console browser:
Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.
Padahal saya secara eksplisit lazy-load pake await import("https://esm.run/[email protected]") — URL lengkap — kenapa "resolve module specifier"?
Akar penyebab: gem x402-rails sendiri bawa controller Stimulus yang depend ke @hotwired/stimulus. Saya pin paket itu di config/importmap.rb, tapi file vendor yang sesuai vendor/javascript/@hotwired--stimulus.js gak pernah di-download. importmap sadar file-nya gak ada dan diam-diam ngebuang pin itu dari importmap yang di-generate. Yang gagal bukan x402-fetch saya; yang gagal controller Stimulus dari gem-nya. Error-nya bubble up ke import terdekat.
Diagnosa: bin/importmap json ngeluarin importmap yang sebenernya di-generate. Bandingin sama config/importmap.rb — pin yang gak ada di json artinya file vendor-nya gak di-download.
Fix: bin/importmap pin @hotwired/stimulus buat beneran narik file-nya.
Claude gak refleks jalanin bin/importmap json sebagai sanity check habis install gem. Itu urusan kamu. Kalau pake importmap, abis install gem apa pun yang bawa controller Stimulus, jalanin bin/importmap json sekali dan pastiin gak ada pin yang kebuang diam-diam.
Di credentials:
x402:
wallet_address: 0x1234abcd...
Pas Rails load, YAML parse 0x1234abcd... sebagai integer (literal hex). Saat X402.configure dapet nilainya, tipenya udah rusak, dan gem ngehasilin paywall requirements aneh.
Fix satu karakter: tambah quote.
x402:
wallet_address: "0x1234abcd..."
Claude gak ngasih quote pas nulis template credentials — data training-nya penuh contoh YAML dengan string telanjang. Cuma muncul saat prefix kebetulan 0x / true / false / digit. Jebakan "parsing khusus YAML" kayak gini cuma muncul saat kamu isi nilai beneran.
Stripe nutupin 99% user — kartu kredit / Apple Pay / Google Pay. Buat alur $9.99/bulan, pengalamannya gak terkalahkan.
x402 nutupin sisa 1% orang penting: user crypto-native, user internasional yang pengen stablecoin, dan developer yang nulis agen otomatis (yang agennya butuh bisa bayar sendiri buat akses API berbayar — buat itu 402 dirancang).
Keputusan produk kunci: tier bulanan gak dapet x402. $9.99/bulan dengan tanda tangan wallet tiap bulan UX-nya horor. Cuma aktifin x402 di tahunan $99, di mana friksi diamortisasi jadi setahun sekali.
<% if plan.interval == "year" %>
<%= render "shared/x402_pay_button", ... %>
<% end %>
Satu if di _plan_card.html.erb nentuin card mana yang nampilin tombol USDC. Sesederhana itu.
Membiarkan Claude integrasi pembayaran — checklist lengkap:
inclusion: { in: %w[stripe x402] } sebagai gerbang tipe.Rails.env.production?.data-turbo=false. Kalau gak Turbo nelan diam-diam 302 cross-origin.bin/importmap json. importmap diam-diam ngebuang pin yang file vendor-nya gak ada.0x... / true / 07 kena parsing khusus YAML.Bagian susah biarin Claude nulis pembayaran bukan protokolnya sendiri — tapi batas integrasinya (Turbo vs Stripe, importmap vs gem, YAML vs alamat wallet). Itu momen yang harus kamu duduk sendiri.