Ruby SDK for the RAIL0 stablecoin payment gateway.
RAIL0 is an immutable smart contract that brings the authorize → capture → refund lifecycle of card networks to stablecoin (USDC / EIP-3009) payments — no intermediaries, no protocol fees, no permission required. This SDK is a REST client for the RAIL0 gateway that sits in front of the contract, covering the full payment lifecycle plus account, wallet, catalog, and webhook management. It mirrors the rail0-go and rail0-ts SDKs.
- Ruby ≥ 2.6
- For SIWE login and off-chain signing:
eth(~> 0.5) andsiwe-rb(~> 0.2)
The core HTTP client has no runtime dependencies (Ruby stdlib only). The eth
and siwe-rb gems are loaded lazily — require "rail0" works without them, and
they are needed only when you call client.auth.login or Rail0::Signing.
Add to your Gemfile:
gem "rail0"
# Only if you use SIWE login or off-chain signing:
gem "eth", "~> 0.5"
gem "siwe-rb", "~> 0.2"A full authorize → capture flow. Every on-chain operation is two-phase: a
*_prepare call returns an unsigned transaction, which you sign locally with
Rail0::Signing.sign_transaction, then the matching submit call broadcasts it.
require "rail0"
require "rail0/signing"
client = Rail0::Client.new(base_url: "https://api.rail0.xyz")
# 1. Payer creates the payment — response embeds the EIP-3009 signing payload.
payment = client.payments.create(
chain_id: 84532,
mode: "authorize",
amount: "50000000", # 50 USDC (6 decimals)
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
payer: "0xBuyer…",
payee: "0xMerchant…"
)
rail0_id = payment[:rail0_id]
# 2. Payer signs the EIP-3009 payload off-chain and deposits the signature.
sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
client.payments.sign(rail0_id, { signature: sig.to_hex })
# 3. Payee prepares + broadcasts the on-chain authorize tx (signs it locally).
prep = client.payments.authorize_prepare(rail0_id)
raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
client.payments.authorize(rail0_id, { signed_transaction: raw }) # HTTP 202 (async)
# 4. Poll until the authorization confirms.
loop do
state = client.payments.get(rail0_id)
break if state[:status] == "authorized"
sleep 2
end
# 5. Payee captures the escrowed funds (partial capture is supported).
cap = client.payments.capture_prepare(rail0_id, "50000000")
raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
client.payments.capture(rail0_id, { signed_transaction: raw })All methods return a Hash with symbol keys (or raise Rail0::ApiError). A
payment id argument accepts either the payment UUID or its rail0_id (bytes32).
Each on-chain operation is a prepare → submit pair:
- prepare —
client.payments.<op>_prepare(...)returns a transaction whoseunsigned_transactionis the EIP-1559 field-set to sign. - sign —
Rail0::Signing.sign_transaction(unsigned, private_key)returns the signed raw tx. - submit —
client.payments.<op>(id, { signed_transaction: raw })broadcasts it. The gateway acknowledges with HTTP 202 and confirms asynchronously — pollclient.payments.get(id)until the status settles.
Wallets that sign and broadcast in one step (e.g. MetaMask via
eth_sendTransaction) skip steps 2–3 and report the hash instead:
client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…" })| Operation | Caller | What it does |
|---|---|---|
authorize_prepare + authorize |
payee | Broadcast the authorize tx; funds move to escrow |
charge_prepare + charge |
payee | One-shot authorize + capture; no escrow window |
capture_prepare + capture |
payee | Move escrowed funds to the merchant (partial supported) |
void_prepare + void |
payee | Cancel the hold, return funds to the payer (only before any capture) |
release_prepare + release |
anyone | Return uncaptured escrow to the payer |
refund_prepare (phase 1+2) + refund |
payee | Return captured funds to the payer via EIP-3009 |
dispute_prepare + dispute |
payer | Open a dispute (signal-only) |
close_dispute_prepare + close_dispute |
payer | Close an open dispute |
Payment statuses: unsigned, signed, authorized, charged, captured,
partially_captured, voided, released, refunded.
Transaction statuses: pending, submitting, submitted, confirmed, failed.
JWT-protected endpoints (wallet management, webhooks, payments.list) need a
Sign-In With Ethereum token. login runs the full handshake; pass the returned
token to the client via headers.
auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
# => { token:, address:, account_id:, name:, expires_at: }
# login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
# SIWE_CHAIN_ID policy differs (e.g. login(private_key:, domain:, chain_id: 5042002)).
authed = Rail0::Client.new(
base_url: "https://api.rail0.xyz",
headers: { "Authorization" => "Bearer #{auth[:token]}" }
)Lower-level building blocks are also available:
nonce = client.auth.nonce # POST /auth/nonces
session = client.auth.verify(message: siwe_msg, signature: sig) # POST /authclient.chains.list # GET /blockchains
client.chains.list(network_type: "testnet", symbol: "ETH")
client.tokens.list # GET /tokens
client.tokens.list(chain_id: 84532, symbol: "USDC")client.health.get # GET /health → { status:, api_version:, contract_version:, db:, … }Buyer-facing discovery of a merchant's accepted wallets/tokens — no JWT. Provide
exactly one of account_id or address.
client.payment_methods.list(account_id: "018e…") # all the merchant's wallets
client.payment_methods.list(address: "0xMerchant…") # just that walletWallets live under /accounts/{account_id}/wallets; every method takes the
account id first. id_or_address accepts the wallet UUID or its 0x address.
client.wallets.list(account_id, chain_id: 84532, active: true)
# => { data: [ { id:, address:, label:, active:, tokens: [...] } ], meta: { page:, per_page:, total: } }
client.wallets.get(account_id, id_or_address)
client.wallets.create(account_id, address: "0x…", label: "Treasury")
client.wallets.update(account_id, id_or_address, label: "Renamed", active: false)
client.wallets.delete(account_id, id_or_address) # 204
client.wallets.balances(account_id, id_or_address, chain_id: 84532) # live on-chain balancesclient.payments.create(params, idempotency_key: nil) # or keyword fields
client.payments.get(id)
client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
client.payments.transactions(id, operation: "capture")
client.payments.sign(id, { signature: "0x…" })
client.payments.disputes(id, status: "open") # one payment's dispute history
# Account-level: every dispute (open AND closed) across your payments, each with
# the parent payment embedded. A closed dispute drops out of the payments
# `disputed` filter (current-state) but still appears here.
client.disputes.list(status: "closed", sort: "-opened_at")payments.list/transactions/disputes and disputes.list return a paginated
{ data:, meta: { page:, per_page:, total: } } envelope.
# Phase 1 — amount only → returns a signing payload for the payee to sign.
p1 = client.payments.refund_prepare(rail0_id, amount: "20000000")
sig = Rail0::Signing.sign_payload(MERCHANT_PRIVATE_KEY, p1[:signing_payload])
# Phase 2 — amount + signature → returns the unsigned on-chain tx.
p2 = client.payments.refund_prepare(rail0_id, amount: "20000000", signature: sig.to_hex)
raw = Rail0::Signing.sign_transaction(p2[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
client.payments.refund(rail0_id, { signed_transaction: raw })Disputes are authorized on-chain by the payer (no JWT) and follow the same prepare → submit pattern:
prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
client.payments.dispute(rail0_id, { signed_transaction: raw })
# … later …
client.payments.close_dispute_prepare(rail0_id)
client.payments.close_dispute(rail0_id, { signed_transaction: raw })Every wrapper delegates to the generic form, useful for dynamic operations:
client.payments.prepare(id, "capture", { amount: "1" })
client.payments.submit(id, "capture", { signed_transaction: raw })
client.payments.submit_by_hash(id, "capture", { transaction_hash: "0x…" })A webhook subscribes to exactly one topic (see Rail0::Resources::Webhooks::TOPICS).
hook = client.webhooks.create(
name: "orders", callback_url: "https://merchant.example/hook", topic: "payments.captured"
)
hook[:shared_secret] # shown once — verify delivery signatures with it
client.webhooks.list(topic: "payments.captured", active: true)
client.webhooks.get(id)
client.webhooks.update(id, callback_url: "https://new.example/hook")
client.webhooks.enable(id)
client.webhooks.disable(id)
client.webhooks.rotate_secret(id) # returns a fresh shared_secret
client.webhooks.reset_circuit(id)
client.webhooks.event_callbacks(id, status: "failed")
client.webhooks.delete(id) # 204Account-scoped: the numbers are the merchant's own, so a buyer's account-less token is
refused with account_required.
client.analytics.summary(status: "captured")
# => { count: 42, disputed_count: 1 }
# Volume needs BOTH token and chain_id — amounts in different tokens (or the same symbol
# on different chains) are different units, and summing them would produce a number that
# looks meaningful and isn't.
client.analytics.summary(token: usdc, chain_id: 84_532)
# => { count: 12, disputed_count: 0, volume: { token: "0x…", chain_id: 84532, total: "12000000" } }
client.analytics.timeseries(interval: "day", from: "2026-07-01T00:00:00Z")
# => [{ bucket: "2026-07-01", count: 3 }, …] oldest first
client.analytics.breakdown(by: "chain")
# => [{ chain_id: 84532, count: 9 }, …] by: token | chain | mode | statusAll three take the same filters — mode, status, token, chain_id, from, to — so
the same question can be asked at three resolutions: one total, a series over time, a split
by dimension. Token and chain rows carry per-token volume; mode and status rows are counts
only, for the reason above.
Requires the eth gem. No private key ever leaves your process.
| Method | Use |
|---|---|
sign_payload(private_key, signing_payload) |
Sign the EIP-3009 payload from a create/refund response (picks Transfer vs Receive by primaryType) — the recommended path |
sign_transaction(unsigned_transaction, private_key) |
Sign a prepare step's unsigned EIP-1559 transaction; returns the 0x raw tx |
sign_transfer_with_authorization(private_key, domain, params) |
Raw EIP-3009 TransferWithAuthorization signer |
sign_authorize(params) / sign_charge(params) |
Lower-level payer signers built from an explicit SignPaymentParams |
require "rail0/signing"
sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
client.payments.sign(rail0_id, { signature: sig.to_hex })Pass any callable as logger to receive a Rail0::LogEntry per request attempt.
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER)
# [rail0] GET 200 https://.../payments/0x… 87ms
# Or route into your own logger:
log = Logger.new($stdout)
client = Rail0::Client.new(
base_url: "https://api.rail0.xyz",
logger: ->(e) { e.error ? log.error("rail0: #{e.error}") : log.debug("rail0: #{e.method} #{e.status} #{e.duration_ms}ms") }
)Non-2xx responses raise Rail0::ApiError, carrying the gateway's code/title/detail
triple:
begin
client.payments.capture(rail0_id, { signed_transaction: raw })
rescue Rail0::ApiError => e
e.status # 422
e.error # "insufficient_token_balance" — branch on this, and only this
e.title # "Not enough balance" — a heading
e.detail # a sentence you can show a user verbatim (also e.message)
e.hint # this SDK's own extra advice, nil when it has none
enderror is the only field to branch on. It is the specific condition, read from the
gateway's code and falling back to the older error sub-code and then to status (the
wider family), so an older gateway still yields the most specific value it sent.
title and detail come from the gateway's error catalogue, so the same condition always
reads the same way whichever endpoint surfaced it; detail is written to be shown to a
user as-is.
hint (or Rail0.describe_error(code)) is this SDK's own advice — a supplement to
detail, present only for codes with a next step worth adding. The codes span four
families, and the last two are the ones most requests actually hit, neither raised by
RAIL0 itself:
| Family | Examples |
|---|---|
| Request & state guards | not_capturable, amount_exceeds_refundable, not_the_payee |
| RAIL0 custom errors | not_payee, already_captured, refund_expired |
| Token reverts | insufficient_token_balance, invalid_token_signature, authorization_already_used |
| Broadcast rejections | insufficient_gas_funds, nonce_too_low, replacement_underpriced |
A failed transaction carries the same triple as error_code, error_title and
error_detail, whether it reverted on-chain or was refused before broadcast.
Rail0::Client.new(
base_url: "https://api.rail0.xyz",
headers: { "Authorization" => "Bearer …" }, # for JWT-protected endpoints
timeout: 30, # seconds (default 30)
max_retries: 0, # network-error retries (default 0)
retry_delay: 0.2, # base delay, doubles each attempt
logger: Rail0::DEBUG_LOGGER # optional
)Only network errors and timeouts are retried; HTTP error responses are not.
gen/generate.rb regenerates lib/rail0/types.rb from the gateway OpenAPI schema
lib/rail0/
client.rb Rail0::Client — entry point
http_client.rb internal HTTP client (Net::HTTP, retry, pagination, logging)
api_error.rb Rail0::ApiError (code/title/detail + #hint)
error_hints.rb Rail0.describe_error — per-code next steps, shared with the other SDKs
signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
stablecoins.rb stablecoin address registry
types.rb generated Struct docs of the gateway schema (reference only)
version.rb Rail0::VERSION
resources/
auth.rb SIWE authentication
chains.rb public blockchain catalog
tokens.rb public token catalog
health.rb gateway health check
payment_methods.rb public payment-method discovery
wallets.rb account-scoped wallet management (JWT)
payments.rb payment lifecycle + disputes
webhooks.rb webhook subscription management (JWT)
analytics.rb account-scoped payment analytics (JWT)
query.rb shared query-string helper
bundle install
bundle exec rspec # run the test suite
# Regenerate lib/rail0/types.rb after a gateway schema change:
# defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
ruby gen/generate.rb