שני פרוטוקולים שונים לחלוטין באפליקציה אחת — Checkout מתארח של Stripe + webhook, ו-HTTP 402 + ארנק דפדפן של x402. שלוש מלכודות שקטות, ארכיטקטורה אחת שמריצה את שני המסלולים.
לאחרונה חיברתי גם Stripe (כרטיסי אשראי/פיאט) וגם x402 (USDC on-chain ב-EVM) ל-tier Pro של how2claude. לתת ל-Claude לכתוב אינטגרציות לשני פרוטוקולים שונים לחלוטין — האחד Checkout מתארח + webhook, השני HTTP 402 + ארנק דפדפן — לקח סשן ערב שלם. נתקלתי בשלוש כשלים שקטים, וסיימתי עם ארכיטקטורה שמריצה את שני המסלולים יחד.
זה לא מדריך "איך לשלב Stripe" — כאלה יש בכל מקום. החלקים המעניינים: איך שני הפרוטוקולים מסתדרים זה לצד זה, איפה Claude הכי עלול להתרסק, ואילו רגעים מחייבים אותך לשבת ולצפות בעצמך.
| ממד | Stripe | x402 |
|---|---|---|
| טריגר | button_to → הפניה ל-checkout.stripe.com | POST /x402/subscribe → מחזיר HTTP 402 |
| פעולת משתמש | מכניס כרטיס בעמוד מתארח של Stripe | חותם בארנק דפדפן |
| מסירת תוצאה | webhook (checkout.session.completed) | בקשה נשלחת שוב עם header X-PAYMENT, ה-gem מסדר סינכרוני |
| נתונים לשמירה | payment_intent_id + amount_total | tx_hash + payer + amount |
| מורכבות פרוטוקול | ה-SDK עושה הכל | צריך handshake viem + x402-fetch |
שונים מהותית: Stripe דוחף את המשתמש לעמוד שלו ואתה רק מאמת את ה-webhook כשחוזר; x402 נשאר כולו בדומיין שלך מתחילה ועד סוף, עושה את ה-handshake של הפרוטוקול בשכבת ה-HTTP.
ההבחנה הזו מניעה כל החלטת ארכיטקטורה למטה.
בהתחלה ה-controllers היו דחוסים במיפוי שדות:
# ❌ גרסה מוקדמת
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, אבל השדות שונים לחלוטין. מיפוי ב-controller אומר שכל מסלול מעתיק את לוגיקת המיפוי.
המיגרציה (9f3e239) דחפה את זה ל-model:
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!. ה-controller הופך לשורה אחת:
Purchase.record_x402!(article:, user:, payment:, settlement:)
Claude נהדר בעבודה מהסוג הזה: הוא ימפה בצייתנות כל שדה, יוסיף בדיקות, ויוסיף validates :provider, inclusion: { in: %w[stripe x402] }. בני אדם נוטים ל"קודם שיעבוד" ומיפוי השדות נשאר מפוזר על פני ה-controllers, לעולם לא נמלט.
ב-b2f0333 גרמתי ל-Claude לכתוב את האינטגרציה הראשונה של x402 ביד — שלושה classes:
X402::PaymentHandler — בניית 402 requirements, פענוח header PAYMENT-SIGNATUREX402::FacilitatorClient — עטיפת /verify + /settle של x402.org/facilitatorapp/controllers/concerns/content_gate.rb — זיהוי header 402, החזרת PAYMENT-REQUIRED449 שורות, עובד, בדיקות עוברות.
שש שעות אחר כך (9f3e239) גרמתי לו להחליף הכל ל-gem x402-rails (פרוטוקול v1, מצב לא-אופטימיסטי). מחקתי את שלושת ה-classes; ה-controllers משתמשים כעת ב-DSL x402_paywall(amount:) וקוראים מ-request.env["x402.payment"] ומ-request.env["x402.settlement_result"].
הקצב חשוב: לכתוב ביד קודם מאלץ אותך להבין את הפרוטוקול, אחר כך ה-gem משחרר אותך. אם תתחיל עם ה-gem, Claude כותב מול תיעוד ה-gem ואין לך מושג מה באמת בתוך header 402 או מה /settle עושה. כשמשהו נשבר (תמיד משהו נשבר), אין לך קרקע לדבג.
התבנית הזו עובדת לכל פרוטוקול/שירות חדש: תן ל-Claude לכתוב ביד פעם אחת, תביא את הבדיקות לירוק, אחר כך תן לו להחליף ל-gem. ה-diff בין השניים הוא חומר הלימוד שלך.
ה-initializer של 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 (tokens בדיקה חינמיים), prod רץ base mainnet. אין מה לשנות ב-deploy. (העיקרון הזה בא מהמאמר הקודם לתת ל-Claude לפרוס ל-production — כל מה ששונה בין dev ל-prod, הפוך דרך Rails.env.)
השורה optimistic = false חשובה: מצב האופטימיסטי כברירת מחדל של ה-gem מעביר את הבקשה ומסדיר אחרי; אנחנו מכבים כי אנחנו רוצים את settlement_result.transaction (ה-tx_hash) לפני שה-action מחזירה, כדי לכתוב סינכרוני לשורת Purchase. שורת Purchase ללא tx_hash חסרת ערך למשתמש — הוא ירצה להקליק ולראות את העסקה ב-BaseScan.
ה"frontend" בצד 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. אפס קוד frontend מצידך.
צד x402 (93746d8) דרש controller Stimulus:
// app/javascript/controllers/x402_payment_controller.js
async pay() {
// טעינה עצלה — אל תנפח את ה-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)
})
// ...
}
שני דברים שכדאי לשים לב אליהם:
eth_requestAccounts, לא ב-selectedAddress. selectedAddress deprecated ורוב הארנקים מחזירים ערך מיושן. הגרסה הראשונה של Claude השתמשה ב-selectedAddress (לפי MDN docs); החלפתי.עוד דבר אחד: מנה קודי שגיאה. הארנק סירב לחתימה זה 4001, chain שגוי צריך switch זה CHAIN_SWITCH, נדרש תשלום זה PAYMENT_REQUIRED. אל תעשה string-match על error.message — ארנקים מנסחים שונה ולא תכתוב בדיקות נגד זה.
ה-commit 527f700 הוא מה שמצאתי אחרי חצי שעה של לבהות בדפדפן.
תסמין: הקלקה על כפתור Subscribe ב-/pricing, שום דבר לא קורה. אין שגיאת console, אין שגיאת רשת. לוג Rails מראה 200 מחזיר 302 → checkout.stripe.com/c/pay/cs_xxx. הדפדפן לא זז.
סיבה: button_to יוצר <form method="post">, ו-Turbo יירט את שליחת הטופס, מעביד את התגובה כ-TURBO_STREAM. TURBO_STREAM לא עוקב אחר 302 cross-origin. התגובה נבלעת בשקט על ידי 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, console הדפדפן:
Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.
אבל אני במפורש עושה טעינה עצלה דרך await import("https://esm.run/[email protected]") — URL מלא — אז למה "resolve module specifier"?
סיבת שורש: ה-gem x402-rails מגיע עם controller Stimulus שתלוי ב-@hotwired/stimulus. קבעתי את החבילה הזו ב-config/importmap.rb, אבל קובץ ה-vendor המקביל vendor/javascript/@hotwired--stimulus.js מעולם לא הורד. importmap שם לב שהקובץ חסר ובשקט מפיל את ה-pin מה-importmap שנוצר. מה שנכשל הוא לא ה-x402-fetch שלי; זה ה-controller Stimulus של ה-gem. השגיאה בועלת עד ה-import הקרוב ביותר.
אבחון: bin/importmap json מוציא את ה-importmap שנוצר בפועל. השווה מול config/importmap.rb — כל pin חסר מה-json אומר שקובץ ה-vendor שלו לא הורד.
תיקון: bin/importmap pin @hotwired/stimulus כדי באמת למשוך את הקובץ.
Claude לא מריץ bin/importmap json באופן רפלקסי כ-sanity check אחרי התקנת gem. זה עליך. אם אתה משתמש ב-importmap, אחרי התקנת כל gem שמגיע עם controllers של Stimulus הרץ bin/importmap json פעם אחת ואשר שאין pin שהופל בשקט.
ב-credentials:
x402:
wallet_address: 0x1234abcd...
כאשר Rails טוען את זה, YAML מנתח את 0x1234abcd... כ-מספר שלם (hex literal). עד ש-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% הנותר של אנשים חשובים: משתמשי קריפטו טבעיים, משתמשים בינלאומיים שרוצים stablecoins, ומפתחים שכותבים agents אוטומטיים (ש-agents שלהם צריכים להיות מסוגלים לשלם בעצמם עבור גישה ל-APIs בתשלום — לשם כך 402 תוכנן).
החלטת מוצר מרכזית: ה-tier החודשי לא מקבל 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 בולע בשקט את ה-302 cross-origin.bin/importmap json. importmap משמיט בשקט pins שקבצי ה-vendor שלהם חסרים.0x... / true / 07 יקבלו אחרת ניתוח מיוחד של YAML.החלקים הקשים של מתן ל-Claude לכתוב תשלומים אינם הפרוטוקולים עצמם — אלו גבולות האינטגרציה (Turbo לעומת Stripe, importmap לעומת gem, YAML לעומת כתובת ארנק). אלה הרגעים שבהם אתה צריך לשבת שם בעצמך.