x402-rails
Now supporting x402 v2!
⚠️ Note: This gem now defaults to x402 protocol v2. If you need v1 compatibility, set
config.version = 1in your initializer. See Protocol Versions for details on the differences.
Accept instant blockchain micropayments in your Rails applications using the x402 payment protocol.
Supports 18 networks including Base, Polygon, Avalanche, Sei, Solana, and more.
Features
- 1 line of code to accept digital dollars (USDC)
- No fees on supported networks (Base)
- ~1 second response times (optimistic mode)
- $0.001 minimum payment amounts
- Optimistic & non-optimistic settlement modes
- Automatic settlement after successful responses
- API paywall with 402 payment-required responses
- Bazaar discovery to list your routes in facilitator catalogs (PayAI, Coinbase CDP)
- Coinbase CDP facilitator support with built-in auth
- Rails 7.0+ compatible
Example Video
https://github.com/user-attachments/assets/05983bb3-7422-4c06-97ab-2fb53d6428cc
Installation
Add to your Gemfile:
gem 'x402-rails'
Then run:
bundle install
Quick Start
1. Configure the gem
Generate the initializer:
bin/rails generate x402:install
Then edit config/initializers/x402.rb:
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS'] # Your recipient wallet
config.facilitator = "https://www.x402.org/facilitator"
config.chain = "base-sepolia" # or "base" for base mainnet
config.currency = "USDC"
config.optimistic = false # Forces to check for settlement before giving response.
end
2. Protect your endpoints
Use x402_paywall in any controller action:
class ApiController < ApplicationController
def weather
x402_paywall(amount: 0.001) # $0.001 in USD
return if performed?
render json: {
temperature: 72,
paid_by: request.env['x402.payment'][:payer]
}
end
end
That's it! Your endpoint now requires payment.
3. (Optional) Make it discoverable
Declare discovery metadata and agents can find your route in facilitator catalogs (see Bazaar Discovery):
class ApiController < ApplicationController
x402_discovery only: :weather,
input: { "city" => "San Francisco" },
input_schema: { "properties" => { "city" => { "type" => "string" } } },
output: { example: { "temperature" => 72 } }
end
Indexing happens when a client pays: the extension rides the 402, the paying client echoes it, and the facilitator catalogs the route on settle.
Usage Patterns
Direct Method Call
Call x402_paywall in any action:
def show
x402_paywall(amount: 0.01)
return if performed?
# Action continues after payment verified
render json: @data
end
Before Action Hook
Protect multiple actions:
class PremiumController < ApplicationController
before_action :require_payment, only: [:show, :index]
def show
# Payment already verified
render json: @premium_content
end
private
def require_payment
x402_paywall(amount: 0.001, chain: "base")
return if performed?
end
end
Per-Action Pricing
Different prices for different actions:
def basic_data
x402_paywall(amount: 0.001)
return if performed?
render json: basic_info
end
def premium_data
x402_paywall(amount: 0.01)
return if performed?
render json: premium_info
end
Configuration Options
Global Configuration
Set defaults in config/initializers/x402.rb:
X402.configure do |config|
# Required: Your wallet address where payments will be received
config.wallet_address = ENV['X402_WALLET_ADDRESS']
# Facilitator service URL (default: "https://www.x402.org/facilitator")
config.facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://www.x402.org/facilitator")
# Blockchain network (default: "base-sepolia")
# Built-in: base, base-sepolia, polygon, polygon-amoy, avalanche, avalanche-fuji,
# sei, sei-testnet, iotex, peaq, xlayer, xlayer-testnet,
# skale-base, skale-base-sepolia, kiteai, kiteai-testnet,
# solana, solana-devnet
config.chain = ENV.fetch("X402_CHAIN", "base-sepolia")
# Payment token (default: "USDC")
# Currently only USDC is supported
config.currency = ENV.fetch("X402_CURRENCY","USDC")
# Optimistic mode (default: false)
# true: Fast response, settle payment after response is sent
# false: Wait for blockchain settlement before sending response
config.optimistic = ENV.fetch("X402_OPTIMISTIC", "false") == "true"
end
Configuration Attributes
| Attribute | Required | Default | Description |
|---|---|---|---|
wallet_address |
Yes | - | Your Ethereum wallet address where payments will be received |
facilitator |
No | "https://www.x402.org/facilitator" |
Facilitator service URL for payment verification and settlement |
chain |
No | "base-sepolia" |
Blockchain network (see built-in list above) |
currency |
No | "USDC" |
Payment token symbol (currently only USDC supported) |
optimistic |
No | false |
true: respond before settlement; false: settle before responding |
version |
No | 2 |
Protocol version (1 or 2). See Protocol Versions section |
cdp_api_key_id |
No | ENV["CDP_API_KEY_ID"] |
Coinbase CDP API key id — only used when the facilitator is CDP |
cdp_api_key_secret |
No | ENV["CDP_API_KEY_SECRET"] |
Coinbase CDP API key secret (ECDSA PEM or Ed25519 base64) |
Custom Chains and Tokens
You can register custom EVM chains and tokens beyond the built-in options.
Register a Custom Chain
Add support for any EVM-compatible chain beyond the 18 built-in networks:
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
# Register Arbitrum (not built-in)
config.register_chain(
name: "arbitrum",
chain_id: 42161,
standard: "eip155"
)
# Register the token for that chain
config.register_token(
chain: "arbitrum",
symbol: "USDC",
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
decimals: 6,
name: "USD Coin",
version: "2"
)
config.chain = "arbitrum"
config.currency = "USDC"
end
Register a Custom Token on a Built-in Chain
⚠️ Note: The Facilitator used must support the specified chain and token to ensure proper functionality.
Accept different tokens on existing chains:
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
# Accept WETH on Base instead of USDC
config.register_token(
chain: "base",
symbol: "WETH",
address: "0x4200000000000000000000000000000000000006",
decimals: 18,
name: "Wrapped Ether",
version: "1"
)
config.chain = "base"
config.currency = "WETH"
end
Token Registration Parameters
| Parameter | Required | Description |
|---|---|---|
chain |
Yes | Chain name (built-in or custom registered) |
symbol |
Yes | Token symbol (e.g., "USDC", "WETH") |
address |
Yes | Token contract address |
decimals |
Yes | Token decimals (e.g., 6 for USDC, 18 for WETH) |
name |
Yes | Token name for EIP-712 domain |
version |
No | EIP-712 version (default: "1") |
Note: Custom chains and tokens are only supported for EVM (eip155) networks. Solana chains use a different implementation.
Accept Multiple Payment Options
Allow clients to pay on any of several supported chains by using config.accept():
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
# Register a custom chain not included in the built-in list
config.register_chain(name: "arbitrum", chain_id: 42161, standard: "eip155")
config.register_token(
chain: "arbitrum",
symbol: "USDC",
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
decimals: 6,
name: "USD Coin",
version: "2"
)
# Accept payments on multiple chains (built-in + custom)
config.accept(chain: "base-sepolia", currency: "USDC")
config.accept(chain: "polygon-amoy", currency: "USDC")
config.accept(chain: "arbitrum", currency: "USDC")
end
When config.accept() is used, the 402 response will include all accepted payment options:
{
"accepts": [
{ "network": "eip155:84532", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", ... },
{ "network": "eip155:80002", "asset": "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", ... }
]
}
Clients can then choose which chain to pay on based on their preferences or available funds.
Per-accept wallet addresses: You can specify different recipient addresses per chain:
config.accept(chain: "base-sepolia", currency: "USDC", wallet_address: "0xWallet1")
config.accept(chain: "polygon-amoy", currency: "USDC", wallet_address: "0xWallet2")
Fallback behavior: If no config.accept() calls are made, the default config.chain and config.currency are used.
Protocol Versions
x402-rails supports both v1 and v2 of the x402 protocol. v2 is the default.
Key Differences
| Feature | v1 (Legacy) | v2 (Default) |
|---|---|---|
| Network format | Simple names (base-sepolia) |
CAIP-2 (eip155:84532) |
| Payment header | X-PAYMENT |
PAYMENT-SIGNATURE |
| Response header | X-PAYMENT-RESPONSE |
PAYMENT-RESPONSE |
| Requirements | Body only | PAYMENT-REQUIRED header + body |
| Amount field | maxAmountRequired |
amount |
v2 (Default)
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
config.version = 2 # Default, can be omitted
end
v2 uses CAIP-2 network identifiers (eip155:84532) and the PAYMENT-SIGNATURE header. Payment requirements are sent in both the PAYMENT-REQUIRED header (base64-encoded) and the response body (JSON).
v1 (Legacy)
X402.configure do |config|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
config.version = 1
end
v1 uses simple network names (base-sepolia) and the X-PAYMENT header. Payment requirements are sent only in the response body.
Per-Endpoint Version
Override the version for specific endpoints:
def premium_v2
x402_paywall(amount: 0.001, version: 2)
return if performed?
render json: { data: "v2 endpoint" }
end
def legacy_v1
x402_paywall(amount: 0.001, version: 1)
return if performed?
render json: { data: "v1 endpoint" }
end
Bazaar Discovery
Facilitators with a discovery layer (PayAI, Coinbase CDP Bazaar) index your route into their public catalog when a payment carries the x402 Bazaar discovery extension. Declare it per action and the gem attaches it to every v2 402 for that action; paying clients echo it into their payment payload, and the gem forwards the echo to the facilitator on verify/settle — that's what creates the catalog entry.
Note: Parameterized routes (e.g.
/weather/:city) are cataloged per concrete URL — the gem does not emit the optionalrouteTemplatefield.
GET route (query params)
Omit body_type for GET/HEAD/DELETE — input describes query params:
class WeatherController < ApplicationController
x402_discovery only: :show,
input: { "city" => "San Francisco", "units" => "celsius" },
input_schema: {
"type" => "object",
"properties" => {
"city" => { "type" => "string", "description" => "City name" },
"units" => { "type" => "string", "enum" => ["celsius", "fahrenheit"] },
},
"required" => ["city"],
},
output: { example: { "weather" => "foggy", "temperature" => 15 } }
def show
x402_paywall(amount: 0.001)
return if performed?
render json: { weather: "foggy", temperature: 15 }
end
end
POST route (JSON body)
Pass body_type: "json" — input is now an example request body. Give every field a description: it's what agents read in the catalog to call you correctly.
class SearchController < ApplicationController
x402_discovery only: :create,
body_type: "json",
input: {
"query" => "solar panels",
"filters" => { "max_price" => 100 },
},
input_schema: {
"type" => "object",
"properties" => {
"query" => { "type" => "string", "description" => "Search terms" },
"filters" => {
"type" => "object",
"properties" => { "max_price" => { "type" => "number" } },
},
},
"required" => ["query"],
},
output: {
example: { "results" => [{ "title" => "…", "price" => 42 }] },
schema: { "properties" => { "results" => { "type" => "array" } } },
}
def create
x402_paywall(amount: 0.005)
return if performed?
render json: { results: search_results }
end
end
Multiple routes in one controller
Each action gets its own declaration; undeclared actions emit no extension:
class ReportsController < ApplicationController
x402_discovery only: :create, body_type: "json",
input: { "ticker" => "AAPL" },
input_schema: { "properties" => { "ticker" => { "type" => "string" } }, "required" => ["ticker"] },
output: { example: { "report_id" => "rep_123" } }
x402_discovery only: :summary,
input: { "report_id" => "rep_123" },
input_schema: { "properties" => { "report_id" => { "type" => "string" } }, "required" => ["report_id"] },
output: { example: { "summary" => "…" } }
end
Full control
Build the extension yourself (e.g. to share schemas with your validators), or attach per-call:
x402_discovery only: :create, extensions: X402::DiscoveryExtension.declare(
body_type: "json",
input: MyApi::EXAMPLE_BODY,
input_schema: MyApi::BODY_SCHEMA,
output: { example: MyApi::EXAMPLE_RESPONSE },
)
# or, inside an action:
x402_paywall(amount: 0.005, extensions: my_extensions)
Indexing rules
- The example
inputmust validate againstinput_schema— facilitators silently skip routes whose extension fails validation. Keep example and schema in sync. methodis stamped from the actual request at render time — omit it; a declared value is overwritten.description:onx402_discoverysets the 402'sresource.description— the text catalogs display for the route. A description alone just names the route; declaring input/output metadata is what makes it discoverable.- Catalogs are per-facilitator — an entry appears only in the catalog of the facilitator that settled the payment. To appear in both PayAI and CDP, settle at least one payment through each.
- Indexing is a side-effect of a real payment; there is no registration API.
Verify a listing:
X402::FacilitatorClient.new.discovery_resources(type: "http")
# or against a specific facilitator:
X402::FacilitatorClient.new("https://facilitator.payai.network").discovery_resources
Facilitators
Any x402 facilitator works via X402_FACILITATOR_URL / config.facilitator. Auth is applied automatically:
| Facilitator | URL | Auth |
|---|---|---|
| x402.org (default) | https://www.x402.org/facilitator |
none |
| PayAI | https://facilitator.payai.network |
none |
| Coinbase CDP | https://api.cdp.coinbase.com/platform/v2/x402 |
Bearer JWT, built in |
Using Coinbase CDP
- Create a project + API key at cdp.coinbase.com (ECDSA and Ed25519 keys both work).
- Set the env vars CDP's own SDKs use — the gem picks them up automatically:
X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
CDP_API_KEY_ID=your-key-id
CDP_API_KEY_SECRET=your-key-secret
Or configure explicitly:
X402.configure do |config|
config.facilitator = "https://api.cdp.coinbase.com/platform/v2/x402"
config.cdp_api_key_id = Rails.application.credentials.dig(:cdp, :api_key_id)
config.cdp_api_key_secret = Rails.application.credentials.dig(:cdp, :api_key_secret)
end
Every verify/settle/supported/discovery request then carries a per-request, URI-bound Bearer JWT (2-minute expiry), matching @coinbase/cdp-sdk. Note: CDP's Bazaar discovery currently covers Base and Base Sepolia USDC.
Environment Variables
Configure via environment variables:
# Required
X402_WALLET_ADDRESS=0xYourAddress
# Optional (with defaults)
X402_FACILITATOR_URL=https://www.x402.org/facilitator
X402_CHAIN=base-sepolia
X402_CURRENCY=USDC
X402_OPTIMISTIC=true # "true" or "false"
# Coinbase CDP facilitator auth (only needed when X402_FACILITATOR_URL is CDP)
CDP_API_KEY_ID=
CDP_API_KEY_SECRET=
# Solana fee payer overrides (required when using a non-default facilitator)
# The default fee payer is for the Coinbase facilitator (x402.org).
# Each facilitator manages its own fee payer — check your facilitator's /supported endpoint.
X402_FEE_PAYER= # Global override for all Solana chains
X402_SOLANA_FEE_PAYER= # Solana mainnet override
X402_SOLANA_DEVNET_FEE_PAYER= # Solana devnet override
# Example: PayAI facilitator (https://facilitator.payai.network)
# X402_FACILITATOR_URL=https://facilitator.payai.network
# X402_SOLANA_FEE_PAYER=2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4
# X402_SOLANA_DEVNET_FEE_PAYER=2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4
Fee payer lookup priority:
config.fee_payer(programmatic, global)- Per-chain ENV variable (e.g.,
X402_SOLANA_DEVNET_FEE_PAYER) X402_FEE_PAYER(ENV, global)- Built-in default from chain config
Examples
Weather API
class WeatherController < ApplicationController
def current
x402_paywall(amount: 0.001)
return if performed?
render json: { temp: 72, condition: "sunny" }
end
def forecast
x402_paywall(amount: 0.01)
return if performed?
render json: { forecast: [...] }
end
end
x402 Architecture
┌──────────┐ ┌──────────┐ ┌─────────────┐
│ Client │─────▶│ Rails │─────▶│ Facilitator │
│ │ │ x402 │ │ (x402.org) │
└──────────┘ └──────────┘ └─────────────┘
│ │ │
│ │ ▼
│ │ ┌──────────────┐
│ │ │ Blockchain │
│ │ │ (Base) │
└──────────────────┴─────────────┴──────────────┘
Error Handling
The gem raises these errors:
X402::ConfigurationError- Invalid configurationX402::InvalidPaymentError- Invalid payment payloadX402::FacilitatorError- Facilitator communication issues
Security
- Payments validated via EIP-712 signatures
- Nonce prevents replay attacks
- Time windows limit authorization validity
- Facilitator verifies all parameters
- Settlement happens on-chain (immutable)
Requirements
- Ruby 3.0+
- Rails 7.0+
Resources
License
MIT License. See LICENSE.txt.