Два цілком різні протоколи в одному застосунку — хостинг-Checkout від Stripe + webhook, і HTTP 402 + браузерний гаманець від x402. Три тихі провали, одна архітектура для обох шляхів.
Нещодавно я підключив до Pro-тарифу how2claude і Stripe (карти/фіат), і x402 (USDC on-chain в EVM). Змусити Claude написати інтеграції для двох цілком різних протоколів — один хостинг-Checkout + webhook, другий HTTP 402 + браузерний гаманець — зайняло цілий вечір сесії. Налетів на три тихі провали і врешті отримав архітектуру, що жене обидва канали разом.
Це не туторіал "як інтегрувати Stripe" — таких повно. Цікаве: як два протоколи вживаються поруч, де Claude найлегше падає ниць, і в які моменти доведеться сидіти й дивитися самому.
| Вимір | Stripe | x402 |
|---|---|---|
| Тригер | button_to → редирект на checkout.stripe.com | POST /x402/subscribe → повертає HTTP 402 |
| Дія користувача | Введення картки на хостинг-сторінці Stripe | Підпис у браузерному гаманці |
| Доставка результату | webhook (checkout.session.completed) | Запит повторюється з заголовком X-PAYMENT, gem закриває синхронно |
| Дані для збереження | payment_intent_id + amount_total | tx_hash + payer + amount |
| Складність протоколу | SDK робить усе | Потрібен handshake viem + x402-fetch |
Принципово різні: Stripe виштовхує користувача на свою сторінку, а ви перевіряєте лише webhook при поверненні; x402 залишається на вашому домені з початку до кінця, виконуючи handshake протоколу на рівні HTTP.
Ця відмінність задає всі подальші архітектурні рішення.
Спочатку контролери були забиті мапінгом полів:
# ❌ Рання версія
def subscribe_via_stripe
session = Stripe::Checkout::Session.retrieve(params[:session_id])
Subscription.create!(
user: current_user,
provider: "stripe",
stripe_subscription_id: session.subscription,
# ... з десяток рядків мапінгу
)
end
Обидва канали зберігають Purchase + Subscription, але поля цілковито різні. Мапінг у контролері означає, що кожен канал копіює логіку мапінгу.
Міграція (9f3e239) штовхнула це в модель:
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
Чотири методи: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. Контролер стає однорядковим:
Purchase.record_x402!(article:, user:, payment:, settlement:)
Claude чудово справляється з такою роботою: слухняно змапить кожне поле, додасть тести, додасть validates :provider, inclusion: { in: %w[stripe x402] }. Люди схильні до "спочатку щоб працювало", і мапінг полів залишається розкиданим по контролерах без шансу звідти вибратися.
У b2f0333 я змусив Claude написати першу інтеграцію x402 руками — три класи:
X402::PaymentHandler — побудова 402 requirements, декодування заголовка PAYMENT-SIGNATUREX402::FacilitatorClient — обгортка /verify + /settle для x402.org/facilitatorapp/controllers/concerns/content_gate.rb — виявлення заголовка 402, віддача PAYMENT-REQUIRED449 рядків, працює, тести зелені.
Через шість годин (9f3e239) я змусив замінити все це gem-ом x402-rails (протокол v1, не-оптимістичний режим). Видалив ті три класи; контролери тепер використовують DSL x402_paywall(amount:) і читають з request.env["x402.payment"] та request.env["x402.settlement_result"].
Темп важить: спершу писати руками — щоб зрозуміти протокол, потім gem вас звільняє. Якщо почнете з gem, Claude пише за його документацією, а ви гадки не маєте, що насправді в заголовку 402 або що робить /settle. Коли щось зламається (щось завжди ламається), немає ґрунту для дебагінгу.
Цей патерн працює для будь-якого нового протоколу/сервісу: нехай Claude напише руками один раз, приведе тести до зеленого, потім замінить на gem. Diff між ними — ваш навчальний матеріал.
Ініціалізатор x402 (config/initializers/x402.rb) хардкодить правило:
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 # чекаємо settle facilitator перед продовженням, щоб синхронно отримати tx_hash
end
Той же код: dev ганяє base-sepolia (безкоштовні тестові токени), prod — base mainnet. Нічого не змінювати при деплої. (Цей принцип прийшов з попередньої статті Віддаємо продакшен-деплой Claude — усе, що відрізняється між dev і prod, перемикається через Rails.env.)
Рядок optimistic = false важливий: оптимістичний режим gem за замовчуванням пропускає запит і звіряється пізніше; ми вимикаємо, бо хочемо отримати settlement_result.transaction (tx_hash) до того, як action повернеться, щоб записати синхронно в рядок Purchase. Рядок Purchase без tx_hash нічого не вартий користувачу — він хоче клацнути й побачити транзакцію на BaseScan.
"Фронтенд" зі сторони Stripe — один рядок:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
form: { class: "w-full", data: { turbo: false } } do %>
<%= t("pricing.subscribe") %>
<% end %>
Користувач клацає, браузер стрибає на checkout.stripe.com. З вашого боку — нуль фронтенд-коду.
Сторона x402 (93746d8) потребувала Stimulus-контролер:
// app/javascript/controllers/x402_payment_controller.js
async pay() {
// Лінива загрузка — не роздуваємо vendor-бандл
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)
})
// ...
}
Дві важливі речі:
eth_requestAccounts, а не selectedAddress. selectedAddress застарів і більшість гаманців повертають застаріле значення. Перша версія Claude використовувала selectedAddress (за документацією MDN); я переключив.Ще одне: перелічуй коди помилок. Гаманець відхилив підпис — 4001, не та chain потрібен switch — CHAIN_SWITCH, потрібна оплата — PAYMENT_REQUIRED. Не роби string-match за error.message — гаманці формулюють по-різному і тести проти цього не напишеш.
Коміт 527f700 — той, заради якого я півгодини витріщався в браузер.
Симптом: клік по кнопці Subscribe на /pricing — нічого не трапляється. Ні помилок консолі, ні помилок мережі. Лог Rails показує 200 з 302 → checkout.stripe.com/c/pay/cs_xxx. Браузер не рухається.
Причина: button_to генерує <form method="post">, а Turbo перехоплює submit форми та обробляє відповідь як TURBO_STREAM. TURBO_STREAM не слідує за cross-origin 302. Відповідь тихо проковтнута Turbo; сторінка стоїть на місці.
Виправлення:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
- form: { class: "w-full" } do %>
+ form: { class: "w-full", data: { turbo: false } } do %>
Три кнопки зачеплені: Subscribe на /pricing, кнопка Manage на картці "поточний план" на /pricing (стрибає на billing.stripe.com), і Manage Subscription на /accounts. Кожній додано data-turbo=false і регресійний тест.
Коли я дав Claude дебажити це, він дослідив три неправильні напрямки: конфігурація Stripe (ні), білий список redirect_uri (ні), CORS (неправильний напрямок). Конфлікт Turbo/Stripe немає ні в документації Stripe, ні в документації Turbo — і в тренувальних даних Claude про це майже нічого. Такі пастки ловляться лише тим, що бачиш 302 у вкладці network і питаєш себе "чому ж браузер не пішов?".
Після встановлення gem x402-rails, консоль браузера:
Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.
Але я ж явно роблю ліниву загрузку через await import("https://esm.run/[email protected]") — повний URL — чому "resolve module specifier"?
Корінна причина: gem x402-rails тягне з собою Stimulus-контролер, що залежить від @hotwired/stimulus. Я пінив цей пакет у config/importmap.rb, але відповідний vendor-файл vendor/javascript/@hotwired--stimulus.js ніколи не завантажувався. importmap помічає відсутність файлу і тихо викидає пін із згенерованого importmap. Те, що падає, — не мій x402-fetch; це Stimulus-контролер gem-а. Помилка піднімається до найближчого import.
Діагностика: bin/importmap json виводить реально згенерований importmap. Порівняйте з config/importmap.rb — будь-який пін, відсутній у json, означає, що його vendor-файл не завантажено.
Виправлення: bin/importmap pin @hotwired/stimulus, щоб дійсно стягти файл.
Claude не запускає bin/importmap json рефлекторно як sanity-чек після встановлення gem. Це на тобі. Якщо використовуєш importmap, після встановлення будь-якого gem зі Stimulus-контролерами — запусти bin/importmap json один раз і підтверди, що жоден пін не був тихо викинутий.
У credentials:
x402:
wallet_address: 0x1234abcd...
Коли Rails завантажує це, YAML парсить 0x1234abcd... як ціле (hex-літерал). До моменту, коли X402.configure отримує значення, тип зіпсовано, і gem продукує дивні paywall requirements.
Виправлення в один символ: додати лапки.
x402:
wallet_address: "0x1234abcd..."
Claude не поставив лапки, коли писав шаблон credentials — його тренувальні дані повні YAML-прикладів з голими рядками. Спрацьовує лише коли префікс випадково 0x / true / false / цифри. Цей вид пастки "спеціальний парсинг YAML" спрацьовує лише коли заповнюєш справжні значення.
Stripe покриває 99% користувачів — кредитка / Apple Pay / Google Pay. Для потоку $9.99/міс досвід неперевершений.
x402 покриває решту 1% важливих людей: крипто-нативних користувачів, міжнародних користувачів, яким потрібні стейблкоїни, і розробників автоматизованих агентів (агенти мають уміти платити самі за доступ до платних API — для цього й спроектовано 402).
Ключове продуктове рішення: місячний тариф не отримує x402. $9.99/міс з підписом гаманця щомісяця — жахливий UX. Вмикаємо x402 лише на $99 річному, де тертя амортизується до разу на рік.
<% if plan.interval == "year" %>
<%= render "shared/x402_pay_button", ... %>
<% end %>
Один if у _plan_card.html.erb вирішує, які картки показують кнопку USDC. Просто як це.
Віддати Claude інтеграцію платежів — повний чеклист:
inclusion: { in: %w[stripe x402] } як ворота типу.Rails.env.production?.data-turbo=false. Інакше Turbo тихо ковтає cross-origin 302.bin/importmap json. importmap тихо викидає піни, чиї vendor-файли відсутні.0x... / true / 07 інакше потрапляють під спеціальний парсинг YAML.Важкі частини роботи Claude з платежами не в самих протоколах — а в інтеграційних межах (Turbo vs Stripe, importmap vs gem, YAML vs адреса гаманця). Це моменти, коли ти маєш сидіти там сам.