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.
| 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.
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.
En b2f0333 hice que Claude escribiera la primera integración x402 a mano — tres clases:
X402::PaymentHandler — construir 402 requirements, decodificar la cabecera PAYMENT-SIGNATUREX402::FacilitatorClient — envolver /verify + /settle de x402.org/facilitatorapp/controllers/concerns/content_gate.rb — detectar la cabecera 402, devolver PAYMENT-REQUIRED449 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.
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.
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:
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.
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ó?".
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.
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.
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:
inclusion: { in: %w[stripe x402] } como compuerta de tipo.Rails.env.production?.data-turbo=false. Si no, Turbo se traga silenciosamente el 302 cross-origin.bin/importmap json. importmap descarta silenciosamente pins cuyos archivos vendor faltan.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.