Free

Fazendo o Claude integrar duas formas de pagamento: Stripe + x402

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.


Dois paradigmas de pagamento

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.

Afina os controllers — empurra os métodos record pro modelo

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.

O ritmo: à mão primeiro, depois migra pro gem

Em b2f0333 fiz o Claude escrever a primeira integração x402 à mão — três classes:

  • X402::PaymentHandler — montar 402 requirements, decodificar header PAYMENT-SIGNATURE
  • X402::FacilitatorClient — envolver /verify + /settle do x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — detectar header 402, retornar PAYMENT-REQUIRED

449 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.

Vira a chain por Rails.env, não na mão no deploy

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.

Frontend: um lado hospedado, outro lado feito à mão

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:

  1. Lazy-load de viem + x402-fetch (só busca do jsdelivr no primeiro clique do botão). Esses dois pacotes são grandes; meter no vendor bundle obrigaria todo usuário não-pagante a baixar. Lazy-load vira "baixa só se quer pagar".
  2. Usa o resultado de 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.

Armadilha #1: button_to + Turbo engole silenciosamente o 302 da Stripe

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?".

Armadilha #2: Failed to resolve module specifier 'x402-fetch'

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.

Armadilha #3: YAML interpreta endereço de carteira 0x... como inteiro

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.

Por que um app precisa de duas vias de pagamento

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:

  1. Entenda os dois protocolos separadamente antes de deixar o Claude escrever código. Stripe vai com Checkout hospedado + webhook; x402 vai com HTTP 402 + carteira do navegador — não espere que o Claude mantenha eles separados sozinho.
  2. Métodos record vão no modelo. Controllers chamam uma linha; todo mapeamento de campos no modelo. Adiciona inclusion: { in: %w[stripe x402] } como porta de tipo.
  3. Pra protocolos novos, à mão primeiro, depois troca pro gem. O diff entre os dois é seu material de estudo.
  4. Vira chain/modo em runtime via Rails.env. Stripe test/live, x402 base-sepolia/base — tudo virado via Rails.env.production?.
  5. Todo button_to da Stripe precisa de data-turbo=false. Senão o Turbo engole silenciosamente o 302 cross-origin.
  6. Depois de instalar qualquer gem com controllers Stimulus, roda bin/importmap json. importmap descarta silenciosamente pins cujos arquivos vendor estão ausentes.
  7. Põe aspas em qualquer credencial que pareça prefixo numérico. 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.