Dois protocolos totalmente diferentes em um app — Checkout hospedado da Stripe + webhook e HTTP 402 + carteira do navegador da x402. Três falhas silenciosas, uma arquitetura que roda ambas as vias.
Recentemente plugei tanto Stripe (cartões/fiat) quanto x402 (USDC on-chain em EVM) no tier Pro do how2claude. Fazer o Claude escrever integrações pra dois protocolos completamente diferentes — um Checkout hospedado + webhook, o outro HTTP 402 + carteira do navegador — levou uma sessão noturna inteira. Pisei em três falhas silenciosas e terminei com uma arquitetura que roda ambas as vias juntas.
Isso não é tutorial de "como integrar Stripe" — esses estão por aí aos montes. As partes interessantes: como os dois protocolos se acomodam lado a lado, onde o Claude tem mais probabilidade de cair, e em que momentos você tem que sentar e olhar você mesmo.
| Dimensão | Stripe | x402 |
|---|---|---|
| Gatilho | button_to → redireciona pra checkout.stripe.com | POST /x402/subscribe → retorna HTTP 402 |
| Ação do usuário | Coloca cartão na página hospedada da Stripe | Assina na carteira do navegador |
| Entrega de resultado | webhook (checkout.session.completed) | Requisição reexecutada com header X-PAYMENT, gem liquida síncrono |
| Dados pra persistir | payment_intent_id + amount_total | tx_hash + payer + amount |
| Complexidade do protocolo | SDK faz tudo | Precisa de handshake viem + x402-fetch |
Fundamentalmente diferente: Stripe empurra o usuário pra própria página dela e você só verifica o webhook quando ele volta; x402 fica inteiramente no seu domínio, fazendo o handshake do protocolo na camada HTTP.
Essa distinção dirige toda decisão de arquitetura abaixo.
No início os controllers estavam abarrotados de mapeamento de campos:
# ❌ Versão inicial
def subscribe_via_stripe
session = Stripe::Checkout::Session.retrieve(params[:session_id])
Subscription.create!(
user: current_user,
provider: "stripe",
stripe_subscription_id: session.subscription,
# ... uma dúzia de linhas de mapeamento
)
end
Ambas as vias persistem Purchase + Subscription, mas os campos são totalmente diferentes. Mapeamento no controller significa que cada via copia a lógica de mapeamento.
A migração (9f3e239) empurrou pro modelo:
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
Quatro métodos no total: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. O controller vira uma linha:
Purchase.record_x402!(article:, user:, payment:, settlement:)
Claude é ótimo nesse tipo de trabalho: ele vai mapear cada campo direitinho, adicionar testes e validates :provider, inclusion: { in: %w[stripe x402] }. Humanos tendem a "primeiro fazer funcionar" e o mapeamento de campos termina espalhado pelos controllers, sem nunca escapar.
Em b2f0333 fiz o Claude escrever a primeira integração x402 à mão — três classes:
X402::PaymentHandler — montar 402 requirements, decodificar header PAYMENT-SIGNATUREX402::FacilitatorClient — envolver /verify + /settle do x402.org/facilitatorapp/controllers/concerns/content_gate.rb — detectar header 402, retornar PAYMENT-REQUIRED449 linhas, funcionando, testes passando.
Seis horas depois (9f3e239) mandei trocar tudo pelo gem x402-rails (protocolo v1, modo não-otimista). Apaguei essas três classes; os controllers agora usam o DSL x402_paywall(amount:) e leem de request.env["x402.payment"] e request.env["x402.settlement_result"].
O ritmo importa: escrever à mão primeiro faz você entender o protocolo, depois o gem te liberta. Se começar com o gem, o Claude escreve contra os docs do gem e você não tem ideia do que tem dentro do header 402 nem do que /settle faz. Quando algo quebrar (algo sempre quebra), você não tem chão pra debugar.
Esse padrão funciona pra qualquer protocolo ou serviço novo: faz o Claude escrever à mão uma vez, deixa o teste verde e depois manda trocar pelo gem. O diff entre os dois é seu material de estudo.
O initializer do x402 (config/initializers/x402.rb) hardcoda a regra:
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 # espera o settle do facilitator antes de continuar, pra pegar tx_hash síncrono
end
Mesmo código: dev roda base-sepolia (tokens de teste grátis), prod roda base mainnet. Nada pra mudar no deploy. (Esse princípio veio do artigo anterior Deixando o Claude fazer deploy em produção — qualquer coisa que difira entre dev e prod, vira via Rails.env.)
A linha optimistic = false importa: o modo otimista padrão do gem deixa a requisição passar e reconcilia depois; a gente desliga porque quer settlement_result.transaction (o tx_hash) antes da action retornar, pra escrever síncrono na linha do Purchase. Uma linha Purchase sem tx_hash não vale nada pro usuário — ele quer clicar e ver a transação no BaseScan.
O "frontend" da Stripe é uma linha:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
form: { class: "w-full", data: { turbo: false } } do %>
<%= t("pricing.subscribe") %>
<% end %>
Usuário clica, navegador pula pra checkout.stripe.com. Zero código de frontend do seu lado.
O lado x402 (93746d8) precisou de um controller Stimulus:
// app/javascript/controllers/x402_payment_controller.js
async pay() {
// Lazy-load — não infla o 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)
})
// ...
}
Duas coisas pra notar:
eth_requestAccounts, não selectedAddress. selectedAddress é deprecated e a maioria das carteiras retorna valor desatualizado. A primeira versão do Claude usou selectedAddress (segundo docs do MDN); troquei.Mais uma coisa: enumera códigos de erro. Carteira rejeitou assinatura é 4001, chain errada precisa de switch é CHAIN_SWITCH, pagamento exigido é PAYMENT_REQUIRED. Não faz string-match em error.message — carteiras escrevem diferente e você não consegue escrever testes contra isso.
O commit 527f700 é um que eu fiquei meia hora encarando o navegador pra achar.
Sintoma: clica no botão Subscribe em /pricing, nada acontece. Sem erro de console, sem erro de rede. Log do Rails mostra 200 retornando um 302 → checkout.stripe.com/c/pay/cs_xxx. Navegador não se mexe.
Causa: button_to gera um <form method="post">, e o Turbo intercepta o submit do form, tratando a resposta como TURBO_STREAM. TURBO_STREAM não segue 302s cross-origin. A resposta é silenciosamente engolida; a página fica parada.
Fix:
<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
class: "...",
- form: { class: "w-full" } do %>
+ form: { class: "w-full", data: { turbo: false } } do %>
Três botões afetados: o Subscribe de /pricing, o Manage no card "plano atual" de /pricing (que pula pra billing.stripe.com), e o Manage Subscription de /accounts. Cada um ganhou data-turbo=false e um teste de regressão.
Quando pedi pro Claude debugar isso, ele explorou três direções erradas: configuração da Stripe (não), whitelist de redirect_uri (não), CORS (direção errada). O conflito Turbo/Stripe não está nos docs da Stripe nem nos do Turbo — e quase não tem nada sobre isso nos dados de treino do Claude. Você só pega isso vendo o 302 voltar na aba network e se perguntando "então por que o navegador não seguiu?".
Depois de instalar o gem x402-rails, console do navegador:
Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.
Mas eu estou explicitamente fazendo lazy-load com await import("https://esm.run/[email protected]") — URL completa — então por que "resolve module specifier"?
Causa raiz: o gem x402-rails traz um controller Stimulus que depende de @hotwired/stimulus. Eu pinnei esse pacote em config/importmap.rb, mas o arquivo vendor correspondente vendor/javascript/@hotwired--stimulus.js nunca foi baixado. importmap nota que o arquivo falta e silenciosamente tira o pin do importmap gerado. O que está falhando não é meu x402-fetch; é o controller Stimulus do gem. O erro borbulha pro import mais próximo.
Diagnóstico: bin/importmap json mostra o importmap realmente gerado. Compara com config/importmap.rb — qualquer pin ausente do json significa que o arquivo vendor não foi baixado.
Fix: bin/importmap pin @hotwired/stimulus pra realmente trazer o arquivo.
Claude não roda bin/importmap json reflexivamente como sanity check depois de instalar um gem. Isso fica por conta de você. Se usa importmap, depois de instalar qualquer gem que traga controllers Stimulus, roda bin/importmap json uma vez e confirma que nenhum pin foi silenciosamente perdido.
Em credentials:
x402:
wallet_address: 0x1234abcd...
Quando o Rails carrega isso, YAML parseia 0x1234abcd... como inteiro (literal hex). Quando X402.configure recebe o valor, o tipo está quebrado, e o gem produz paywall requirements estranhos.
Fix de um caractere: adiciona aspas.
x402:
wallet_address: "0x1234abcd..."
Claude não pôs aspas escrevendo o template de credentials — os dados de treino dele estão cheios de exemplos YAML com strings nuas. Só dispara quando o prefixo por acaso é 0x / true / false / dígitos. Esse tipo de armadilha "parsing especial do YAML" só dispara quando você preenche valores reais.
Stripe cobre 99% dos usuários — cartão de crédito / Apple Pay / Google Pay. Pro fluxo de $9.99/mês, a experiência é imbatível.
x402 cobre o 1% restante de gente importante: usuários cripto-nativos, usuários internacionais que querem stablecoins, e desenvolvedores escrevendo agentes automatizados (cujos agentes precisam poder pagar por acesso a APIs pagas — pra isso o 402 foi desenhado).
Decisão chave de produto: o tier mensal não recebe x402. $9.99/mês com assinatura de carteira todo mês é UX horrível. Só ativamos x402 no anual de $99, onde a fricção amortiza pra uma vez por ano.
<% if plan.interval == "year" %>
<%= render "shared/x402_pay_button", ... %>
<% end %>
Um if em _plan_card.html.erb decide quais cards mostram o botão USDC. Simples assim.
Fazer o Claude integrar pagamentos — checklist completa:
inclusion: { in: %w[stripe x402] } como porta de tipo.Rails.env.production?.data-turbo=false. Senão o Turbo engole silenciosamente o 302 cross-origin.bin/importmap json. importmap descarta silenciosamente pins cujos arquivos vendor estão ausentes.0x... / true / 07 levam parsing especial do YAML.A parte difícil de fazer o Claude escrever pagamentos não são os protocolos em si — são as bordas de integração (Turbo vs Stripe, importmap vs gem, YAML vs endereço de carteira). Esses são os momentos em que você tem que sentar lá você mesmo.