Dwa zupełnie różne protokoły w jednej aplikacji — hostowany Checkout Stripe + webhook i HTTP 402 + portfel przeglądarki x402. Trzy ciche pułapki, jedna architektura dla obu torów.
Niedawno podpiąłem zarówno Stripe (karty/fiat), jak i x402 (USDC on-chain EVM) do tieru Pro how2claude. Kazanie Claude'owi napisać integracje dla dwóch zupełnie różnych protokołów — jeden hostowany Checkout + webhook, drugi HTTP 402 + portfel przeglądarki — zajęło całą wieczorną sesję. Wpadłem w trzy ciche pułapki i skończyłem z architekturą, która pędzi oba tory razem.
To nie jest tutorial "jak zintegrować Stripe" — takich jest wszędzie. Ciekawe kawałki: jak oba protokoły mieszczą się obok siebie, gdzie Claude najłatwiej się wywraca, i w jakich momentach musisz sam siedzieć i patrzeć.
| Wymiar | Stripe | x402 |
|---|---|---|
| Wyzwalacz | button_to → przekierowanie do checkout.stripe.com | POST /x402/subscribe → zwraca HTTP 402 |
| Akcja użytkownika | Wpisuje kartę na hostowanej stronie Stripe | Podpisuje w portfelu przeglądarki |
| Dostarczenie wyniku | webhook (checkout.session.completed) | Żądanie ponawiane z nagłówkiem X-PAYMENT, gem rozlicza synchronicznie |
| Dane do zapisania | payment_intent_id + amount_total | tx_hash + payer + amount |
| Złożoność protokołu | SDK robi wszystko | Potrzebny handshake viem + x402-fetch |
Fundamentalnie różne: Stripe wypycha użytkownika na własną stronę, a ty weryfikujesz tylko webhook przy powrocie; x402 zostaje całkowicie na twojej domenie od początku do końca, robiąc handshake protokołu w warstwie HTTP.
Ta różnica napędza każdą decyzję architektoniczną poniżej.
Na początku controllery były nafaszerowane mapowaniem pól:
# ❌ Wczesna wersja
def subscribe_via_stripe
session = Stripe::Checkout::Session.retrieve(params[:session_id])
Subscription.create!(
user: current_user,
provider: "stripe",
stripe_subscription_id: session.subscription,
# ... tuzin linii mapowania pól
)
end
Oba tory persystują Purchase + Subscription, ale pola są totalnie różne. Mapowanie w controllerze oznacza, że każdy tor kopiuje logikę mapowania.
Migracja (9f3e239) pchnęła to do modelu:
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
Łącznie cztery metody: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Controller staje się jedną linijką:
Purchase.record_x402!(article:, user:, payment:, settlement:)
Claude jest świetny w takiej pracy: posłusznie zmapuje każde pole, doda testy i validates :provider, inclusion: { in: %w[stripe x402] }. Ludzie skłaniają się do "najpierw niech zadziała" i mapowanie pól kończy rozsypane po controllerach, bez szansy na ucieczkę.
W b2f0333 kazałem Claude'owi napisać pierwszą integrację x402 ręcznie — trzy klasy:
X402::PaymentHandler — budowa 402 requirements, dekodowanie nagłówka PAYMENT-SIGNATUREX402::FacilitatorClient — owijka /verify + /settle dla x402.org/facilitatorapp/controllers/concerns/content_gate.rb — wykrywanie nagłówka 402, zwracanie PAYMENT-REQUIRED449 linii, działa, testy zielone.
Sześć godzin później (9f3e239) kazałem podmienić wszystko na gem x402-rails (protokół v1, tryb nie-optymistyczny). Usunąłem te trzy klasy; controllery używają teraz DSL x402_paywall(amount:) i czytają z request.env["x402.payment"] oraz request.env["x402.settlement_result"].
Rytm się liczy: pisanie ręczne pierwsze daje zrozumienie protokołu, potem gem cię uwalnia. Jeśli zaczniesz od gem, Claude pisze pod jego dokumentację, a ty nie masz pojęcia, co tak naprawdę jest w nagłówku 402 ani co robi /settle. Gdy coś pęknie (zawsze coś pęknie), nie masz gruntu do debugowania.
Ten wzorzec działa dla każdego nowego protokołu/serwisu: niech Claude napisze ręcznie raz, dopchaj testy do zielonego, potem niech podmieni na gem. Diff między dwoma to twój materiał do nauki.
Initializer x402 (config/initializers/x402.rb) hardkoduje regułę:
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 # czekaj na settle od facilitatora przed kontynuacją, żeby chwycić tx_hash synchronicznie
end
Ten sam kod: dev chodzi na base-sepolia (darmowe tokeny testowe), prod chodzi na base mainnet. Nic do zmiany przy deployu. (Ta zasada przyszła z poprzedniego artykułu Niech Claude wdraża na produkcję — cokolwiek różni się między dev a prod, przerzucaj przez Rails.env.)
Linia optimistic = false ma znaczenie: domyślny tryb optymistyczny gem przepuszcza żądanie i rozlicza potem; wyłączamy, bo chcemy settlement_result.transaction (tx_hash) zanim action zwróci, żeby zapisać synchronicznie do wiersza Purchase. Wiersz Purchase bez tx_hash jest bezwartościowy dla użytkownika — będzie chciał kliknąć i zobaczyć transakcję na BaseScan.
"Frontend" po stronie Stripe to jedna linia:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
form: { class: "w-full", data: { turbo: false } } do %>
<%= t("pricing.subscribe") %>
<% end %>
Użytkownik klika, przeglądarka skacze do checkout.stripe.com. Zero kodu frontendowego po twojej stronie.
Strona x402 (93746d8) potrzebowała controllera Stimulus:
// app/javascript/controllers/x402_payment_controller.js
async pay() {
// Lazy-load — nie rozdymaj bundle vendora
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)
})
// ...
}
Dwie rzeczy warte uwagi:
eth_requestAccounts, nie selectedAddress. selectedAddress jest deprecated i większość portfeli zwraca nieaktualną wartość. Pierwsza wersja Claude użyła selectedAddress (według dokumentów MDN); zmieniłem.Jeszcze jedno: enumeruj kody błędów. Portfel odrzucił podpis to 4001, zła chain potrzebna zmiana to CHAIN_SWITCH, wymaga płatności to PAYMENT_REQUIRED. Nie rób string-match na error.message — portfele formułują różnie i nie napiszesz testów przeciwko temu.
Commit 527f700 znalazłem po pół godzinie wpatrywania się w przeglądarkę.
Objaw: klik na przycisk Subscribe na /pricing, nic się nie dzieje. Żadnego błędu konsoli, żadnego błędu sieci. Log Rails pokazuje 200 z 302 → checkout.stripe.com/c/pay/cs_xxx. Przeglądarka się nie rusza.
Przyczyna: button_to generuje <form method="post">, a Turbo przechwytuje submit formularza i traktuje odpowiedź jak TURBO_STREAM. TURBO_STREAM nie podąża za 302 cross-origin. Odpowiedź jest cicho połknięta przez Turbo; strona stoi w miejscu.
Fix:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
- form: { class: "w-full" } do %>
+ form: { class: "w-full", data: { turbo: false } } do %>
Trzy przyciski dotknięte: Subscribe na /pricing, Manage na karcie "obecny plan" na /pricing (skacze do billing.stripe.com), i Manage Subscription na /accounts. Każdy dostał data-turbo=false i test regresyjny.
Gdy kazałem Claude'owi to zdebugować, zbadał trzy złe kierunki: konfiguracja Stripe (nie), białą listę redirect_uri (nie), CORS (zły kierunek). Konflikt Turbo/Stripe nie jest w dokumentacji Stripe ani w dokumentacji Turbo — a w danych treningowych Claude prawie nic o tym nie ma. Takie pułapki łapie się tylko oglądając 302 wracający w zakładce network i pytając siebie "to czemu przeglądarka za nim nie poszła?".
Po zainstalowaniu gem x402-rails, konsola przeglądarki:
Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.
Ale ja wyraźnie robię lazy-load przez await import("https://esm.run/[email protected]") — pełny URL — czemu więc "resolve module specifier"?
Pierwotna przyczyna: gem x402-rails ciągnie ze sobą controller Stimulus zależny od @hotwired/stimulus. Spinowałem ten pakiet w config/importmap.rb, ale odpowiedni plik vendora vendor/javascript/@hotwired--stimulus.js nigdy nie został pobrany. importmap zauważa, że plik brakuje i cicho zrzuca pin ze wygenerowanego importmapa. To, co zawodzi, to nie mój x402-fetch; to controller Stimulus gem-a. Błąd bąbelkuje do najbliższego import.
Diagnoza: bin/importmap json wypisuje rzeczywiście wygenerowany importmap. Porównaj z config/importmap.rb — każdy pin nieobecny w json oznacza, że jego plik vendora nie jest pobrany.
Fix: bin/importmap pin @hotwired/stimulus, żeby faktycznie ściągnąć plik.
Claude nie uruchamia odruchowo bin/importmap json jako sanity check po zainstalowaniu gem. To na tobie. Jeśli używasz importmap, po zainstalowaniu dowolnego gem z controllerami Stimulus uruchom bin/importmap json raz i potwierdź, że żaden pin nie został cicho zrzucony.
W credentials:
x402:
wallet_address: 0x1234abcd...
Gdy Rails to ładuje, YAML parsuje 0x1234abcd... jako integer (literal hex). Zanim X402.configure dotrze do wartości, typ jest zepsuty, a gem produkuje dziwne paywall requirements.
Fix jednoznakowy: dodaj cudzysłowy.
x402:
wallet_address: "0x1234abcd..."
Claude nie dał cudzysłowów pisząc szablon credentials — jego dane treningowe są pełne przykładów YAML z gołymi stringami. Odpala się tylko gdy prefiks przypadkowo jest 0x / true / false / cyfry. Ten typ pułapki "parsowania specjalnego YAML" odpala się tylko gdy wpiszesz prawdziwe wartości.
Stripe pokrywa 99% użytkowników — karta kredytowa / Apple Pay / Google Pay. Dla flow $9.99/miesiąc doświadczenie jest nie do pobicia.
x402 pokrywa pozostały 1% ważnych ludzi: użytkowników crypto-native, międzynarodowych użytkowników chcących stablecoinów, i developerów piszących automatyzowane agenty (których agenty muszą móc same płacić za dostęp do płatnych API — do tego został zaprojektowany 402).
Kluczowa decyzja produktowa: tier miesięczny nie dostaje x402. $9.99/miesiąc z podpisem portfela co miesiąc to okropne UX. Włączamy x402 tylko na rocznym $99, gdzie tarcie amortyzuje się do raz na rok.
<% if plan.interval == "year" %>
<%= render "shared/x402_pay_button", ... %>
<% end %>
Jeden if w _plan_card.html.erb decyduje, które karty pokazują przycisk USDC. Tak po prostu.
Niech Claude zintegruje płatności — pełna lista kontrolna:
inclusion: { in: %w[stripe x402] } jako bramę typu.Rails.env.production?.data-turbo=false. Inaczej Turbo cicho połyka cross-origin 302.bin/importmap json. importmap cicho zrzuca piny, których pliki vendora są nieobecne.0x... / true / 07 dostaną inaczej specjalne parsowanie YAML.Trudne części dawania Claude'owi pisania płatności to nie same protokoły — to granice integracyjne (Turbo vs Stripe, importmap vs gem, YAML vs adres portfela). To są momenty, w których musisz siedzieć sam.