Два совершенно разных протокола в одном приложении — хостируемый 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. Диф между двумя — ваш учебный материал.
Инициализатор 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, не та цепочка нужен 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 адрес кошелька). Это моменты, когда ты должен сидеть там сам.