Free

לתת ל-Claude לשלב שני מסלולי תשלום: Stripe + x402

שני פרוטוקולים שונים לחלוטין באפליקציה אחת — 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 — דחוף את מתודות ה-record ל-model

בהתחלה ה-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, לעולם לא נמלט.

הקצב: ידני תחילה, אחר כך להגר ל-gem

ב-b2f0333 גרמתי ל-Claude לכתוב את האינטגרציה הראשונה של x402 ביד — שלושה classes:

  • X402::PaymentHandler — בניית 402 requirements, פענוח header PAYMENT-SIGNATURE
  • X402::FacilitatorClient — עטיפת /verify + /settle של x402.org/facilitator
  • app/controllers/concerns/content_gate.rb — זיהוי header 402, החזרת PAYMENT-REQUIRED

449 שורות, עובד, בדיקות עוברות.

שש שעות אחר כך (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 בין השניים הוא חומר הלימוד שלך.

החלף chain דרך Rails.env ב-runtime, לא ביד ב-deploy

ה-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: צד אחד מתארח, הצד השני בנוי ביד

ה"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)
  })
  // ...
}

שני דברים שכדאי לשים לב אליהם:

  1. טעינה עצלה של viem + x402-fetch (נטען רק מ-jsdelivr בהקלקה הראשונה על הכפתור). שני החבילות יחד גדולות; לעטוף אותן ב-vendor יכריח כל משתמש לא-משלם להוריד. טעינה עצלה הופכת את זה ל"הורד רק אם אתה רוצה לשלם".
  2. השתמש בתוצאת eth_requestAccounts, לא ב-selectedAddress. selectedAddress deprecated ורוב הארנקים מחזירים ערך מיושן. הגרסה הראשונה של Claude השתמשה ב-selectedAddress (לפי MDN docs); החלפתי.

עוד דבר אחד: מנה קודי שגיאה. הארנק סירב לחתימה זה 4001, chain שגוי צריך switch זה CHAIN_SWITCH, נדרש תשלום זה PAYMENT_REQUIRED. אל תעשה string-match על error.message — ארנקים מנסחים שונה ולא תכתוב בדיקות נגד זה.

מלכודת #1: button_to + Turbo בולע בשקט את 302 של Stripe

ה-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 ולשאול את עצמך "אז למה הדפדפן לא עקב?".

מלכודת #2: Failed to resolve module specifier 'x402-fetch'

אחרי התקנת ה-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 שהופל בשקט.

מלכודת #3: YAML מפרש כתובת ארנק 0x... כמספר שלם

ב-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 לשלב תשלומים — רשימת בדיקה מלאה:

  1. הבן את שני הפרוטוקולים בנפרד לפני שתתן ל-Claude לכתוב קוד. Stripe הולך עם Checkout מתארח + webhook; x402 הולך עם HTTP 402 + ארנק דפדפן — אל תצפה ש-Claude ישמור עליהם מובחנים לבדו.
  2. מתודות record שייכות ל-model. Controllers קוראים שורה אחת; כל מיפוי השדות ב-model. הוסף inclusion: { in: %w[stripe x402] } כשער טיפוס.
  3. עבור פרוטוקולים חדשים, ידני קודם, אחר כך עבור ל-gem. ה-diff בין השניים הוא חומר הלימוד שלך.
  4. הפוך chain/mode ב-runtime דרך Rails.env. Stripe test/live, x402 base-sepolia/base — הכל מופך דרך Rails.env.production?.
  5. כל button_to של Stripe צריך data-turbo=false. אחרת Turbo בולע בשקט את ה-302 cross-origin.
  6. אחרי התקנת כל gem עם controllers של Stimulus, הרץ bin/importmap json. importmap משמיט בשקט pins שקבצי ה-vendor שלהם חסרים.
  7. שים מרכאות סביב כל credentials שנראים כמו קידומות מספר. 0x... / true / 07 יקבלו אחרת ניתוח מיוחד של YAML.

החלקים הקשים של מתן ל-Claude לכתוב תשלומים אינם הפרוטוקולים עצמם — אלו גבולות האינטגרציה (Turbo לעומת Stripe, importmap לעומת gem, YAML לעומת כתובת ארנק). אלה הרגעים שבהם אתה צריך לשבת שם בעצמך.