Free

Hacer que Claude integre dos pasarelas de pago: Stripe + x402

Dos protocolos completamente distintos en una app — Checkout alojado de Stripe + webhook y HTTP 402 + wallet del navegador de x402. Tres fallos silenciosos, una arquitectura que corre ambas vías.


Hace poco conecté tanto Stripe (tarjetas/fiat) como x402 (USDC on-chain en EVM) al tier Pro de how2claude. Hacer que Claude escribiera integraciones para dos protocolos completamente distintos — uno con Checkout alojado + webhook, el otro con HTTP 402 + wallet del navegador — me llevó toda una sesión nocturna. Tropecé con tres fallos silenciosos y terminé con una arquitectura que corre ambas vías a la vez.

Esto no es un tutorial de "cómo integrar Stripe" — esos están por todos lados. Lo interesante: cómo encajan los dos protocolos, dónde Claude tropieza con más facilidad y qué momentos exigen que te sientes a vigilarlo.


Dos paradigmas de pago

Dimensión Stripe x402
Disparador button_to → redirige a checkout.stripe.com POST /x402/subscribe → devuelve HTTP 402
Acción del usuario Introduce tarjeta en página alojada por Stripe Firma en wallet del navegador
Entrega de resultado webhook (checkout.session.completed) Petición reintentada con cabecera X-PAYMENT, gem liquida síncronamente
Datos a persistir payment_intent_id + amount_total tx_hash + payer + amount
Complejidad de protocolo El SDK lo hace todo Necesita handshake viem + x402-fetch

Fundamentalmente distintos: Stripe empuja al usuario a su propia página y tú solo verificas el webhook al volver; x402 se queda en tu dominio de principio a fin, haciendo el handshake del protocolo en la capa HTTP.

Esa distinción dirige cada decisión arquitectónica que sigue.

Adelgaza los controllers — empuja los métodos record al modelo

Inicialmente los controllers estaban llenos de mapeo de campos:

# ❌ Versión 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,
    # ... una docena de líneas de mapeo de campos
  )
end

Ambas vías persisten Purchase + Subscription, pero los campos son completamente distintos. Mapeo en el controller significa que cada vía copia la lógica de mapeo.

La migración (9f3e239) lo empujó al 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

Cuatro métodos en total: Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!. El controller se convierte en una línea:

Purchase.record_x402!(article:, user:, payment:, settlement:)

Claude es excelente en este tipo de trabajo: mapeará obedientemente cada campo, añadirá tests y validates :provider, inclusion: { in: %w[stripe x402] }. Los humanos tienden al "primero que funcione" y el mapeo de campos termina disperso por los controllers, sin escapar nunca.

El ritmo: a mano primero, luego migra al gem

En b2f0333 hice que Claude escribiera la primera integración x402 a mano — tres clases:

  • X402::PaymentHandler — construir 402 requirements, decodificar la cabecera PAYMENT-SIGNATURE
  • X402::FacilitatorClient — envolver /verify + /settle de x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — detectar la cabecera 402, devolver PAYMENT-REQUIRED

449 líneas, funcionando, tests pasando.

Seis horas después (9f3e239) le hice cambiar todo por el gem x402-rails (protocolo v1, modo no-optimista). Borré esas tres clases; los controllers ahora usan el DSL x402_paywall(amount:) y leen de request.env["x402.payment"] y request.env["x402.settlement_result"].

El ritmo importa: escribir a mano primero te hace entender el protocolo, luego el gem te libera. Si empiezas con el gem, Claude escribe contra los docs del gem y tú no tienes idea de qué hay realmente en la cabecera 402 ni qué hace /settle. Cuando algo se rompa (algo siempre se rompe), no tienes desde dónde depurar.

Este patrón funciona para cualquier protocolo o servicio nuevo: que Claude lo escriba a mano una vez, pasa los tests y luego cámbialo al gem. El diff entre los dos es tu material de estudio.

Cambia de cadena con Rails.env, no a mano en deploy

El initializer de x402 (config/initializers/x402.rb) hardcodea la regla:

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  # esperar la liquidación del facilitator antes de continuar, para tomar tx_hash síncronamente
end

Mismo código: dev corre base-sepolia (tokens de prueba gratis), prod corre base mainnet. Nada que cambiar al desplegar. (Este principio viene del artículo anterior Dejar que Claude despliegue a producción — todo lo que difiera entre dev y prod, voltéalo con Rails.env.)

La línea optimistic = false importa: el modo optimista por defecto del gem deja pasar la petición y reconcilia después; lo apagamos porque queremos settlement_result.transaction (el tx_hash) antes de que la action retorne, para escribirlo síncronamente en la fila Purchase. Una fila Purchase sin tx_hash no le sirve al usuario — quiere clicar y ver la transacción en BaseScan.

Frontend: un lado alojado, el otro hecho a mano

El "frontend" de Stripe es una línea:

<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
      class: "...",
      form: { class: "w-full", data: { turbo: false } } do %>
  <%= t("pricing.subscribe") %>
<% end %>

Usuario clica, navegador salta a checkout.stripe.com. Cero código de frontend de tu lado.

El lado x402 (93746d8) necesitó un controller Stimulus:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // Carga diferida — no infles el bundle 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)
  })
  // ...
}

Dos cosas a destacar:

  1. Carga diferida de viem + x402-fetch (sólo se trae de jsdelivr al primer click del botón). Estos dos paquetes son grandes; meterlos al vendor obligaría a todo usuario no-pagador a descargarlos. La carga diferida lo convierte en "descarga sólo si quieres pagar".
  2. Usa el resultado de eth_requestAccounts, no selectedAddress. selectedAddress está deprecado y la mayoría de wallets devuelven valor desactualizado. La primera versión de Claude usó selectedAddress (según los docs de MDN); lo cambié.

Una cosa más: enumerar códigos de error. Wallet rechazó firma es 4001, cadena equivocada que necesita switch es CHAIN_SWITCH, requiere pago es PAYMENT_REQUIRED. No hagas string-match contra error.message — los wallets lo redactan diferente y no puedes escribir tests contra eso.

Trampa #1: button_to + Turbo se traga silenciosamente el 302 de Stripe

El commit 527f700 lo encontré tras vigilar el navegador media hora.

Síntoma: clic al botón Subscribe en /pricing, no pasa nada. Sin error en consola, sin error de red. El log de Rails muestra 200 devolviendo un 302 → checkout.stripe.com/c/pay/cs_xxx. El navegador no se mueve.

Causa: button_to genera un <form method="post">, y Turbo intercepta el envío del form, tratando la respuesta como TURBO_STREAM. TURBO_STREAM no sigue 302s cross-origin. La respuesta queda silenciosamente engullida; la página se queda quieta.

Fix:

 <%= button_to stripe_checkouts_subscription_path(plan: plan.key),
       class: "...",
-      form: { class: "w-full" } do %>
+      form: { class: "w-full", data: { turbo: false } } do %>

Tres botones afectados: el Subscribe de /pricing, el Manage en la tarjeta "plan actual" de /pricing (que salta a billing.stripe.com) y el Manage Subscription de /accounts. Cada uno recibió data-turbo=false y un test de regresión.

Cuando le dije a Claude que depurara esto, exploró tres direcciones equivocadas: configuración de Stripe (no), whitelist de redirect_uri (no), CORS (dirección equivocada). El conflicto Turbo/Stripe no está en los docs de Stripe ni en los de Turbo — y casi no hay nada al respecto en los datos de entrenamiento de Claude. Sólo lo cazas viendo el 302 volver en la pestaña network y preguntándote "¿entonces por qué el navegador no lo siguió?".

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

Tras instalar el gem x402-rails, la consola del navegador:

Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.

Pero estoy cargando explícitamente con await import("https://esm.run/[email protected]") — URL completa — entonces ¿por qué "resolve module specifier"?

Causa raíz: el gem x402-rails trae un controller Stimulus que depende de @hotwired/stimulus. Lo había pinneado en config/importmap.rb, pero el archivo vendor correspondiente vendor/javascript/@hotwired--stimulus.js nunca se descargó. importmap nota que el archivo falta y silenciosamente quita el pin del importmap generado. Lo que está fallando no es mi x402-fetch; es el controller Stimulus del gem. El error burbujea al import más cercano.

Diagnóstico: bin/importmap json muestra el importmap realmente generado. Compara contra config/importmap.rb — cualquier pin ausente del json significa que su archivo vendor no está descargado.

Fix: bin/importmap pin @hotwired/stimulus para realmente bajar el archivo.

Claude no corre bin/importmap json por reflejo como sanity check tras instalar un gem. Eso te toca a ti. Si usas importmap, tras instalar cualquier gem que traiga controllers Stimulus, corre bin/importmap json una vez y confirma que ningún pin se cayó silenciosamente.

Trampa #3: YAML interpreta dirección de wallet 0x... como entero

En credentials:

x402:
  wallet_address: 0x1234abcd...

Cuando Rails carga esto, YAML parsea 0x1234abcd... como entero (literal hex). Para cuando X402.configure recibe el valor, el tipo está roto y el gem produce paywall requirements raros.

Fix de un carácter: comillas.

x402:
  wallet_address: "0x1234abcd..."

Claude no puso comillas al escribir la plantilla de credentials — sus datos de entrenamiento están llenos de ejemplos YAML con strings desnudas. Sólo se dispara cuando el prefijo casualmente es 0x / true / false / dígitos. Esta clase de trampa "parsing especial de YAML" sólo se dispara cuando rellenas valores reales.

Por qué una app necesita dos vías de pago

Stripe cubre el 99% de los usuarios — tarjeta de crédito / Apple Pay / Google Pay. Para un flujo de $9.99/mes, la experiencia no se puede superar.

x402 cubre el 1% restante de gente importante: usuarios cripto-nativos, usuarios internacionales que quieren stablecoins, y desarrolladores escribiendo agentes automatizados (cuyos agentes necesitan poder pagar por acceso a APIs pagadas — para eso se diseñó 402).

Decisión clave de producto: al tier mensual no le ponemos x402. $9.99/mes con firma de wallet cada mes es UX pésima. Sólo activamos x402 en el anual de $99, donde la fricción se amortiza a una vez por año.

<% if plan.interval == "year" %>
  <%= render "shared/x402_pay_button", ... %>
<% end %>

Un if en _plan_card.html.erb decide qué tarjetas muestran el botón USDC. Así de simple.


Hacer que Claude integre pagos — checklist completa:

  1. Entiende los dos protocolos por separado antes de dejar que Claude escriba código. Stripe va con Checkout alojado + webhook; x402 va con HTTP 402 + wallet del navegador — no esperes que Claude los mantenga separados solo.
  2. Los métodos record van en el modelo. Los controllers llaman una línea; todo el mapeo de campos en el modelo. Añade inclusion: { in: %w[stripe x402] } como compuerta de tipo.
  3. Para protocolos nuevos, primero a mano, luego cambia al gem. El diff entre ambos es tu material de estudio.
  4. Cambia chain/modo en runtime con Rails.env. Stripe test/live, x402 base-sepolia/base — todo volteado vía Rails.env.production?.
  5. Cada button_to de Stripe necesita data-turbo=false. Si no, Turbo se traga silenciosamente el 302 cross-origin.
  6. Tras instalar cualquier gem con controllers Stimulus, corre bin/importmap json. importmap descarta silenciosamente pins cuyos archivos vendor faltan.
  7. Pon comillas a cualquier credencial que parezca prefijo numérico. 0x... / true / 07 reciben parsing especial de YAML.

La parte difícil de hacer que Claude escriba pagos no son los protocolos en sí — son los bordes de integración (Turbo vs Stripe, importmap vs gem, YAML vs dirección de wallet). Esos son los momentos en los que tienes que sentarte tú mismo.