Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ PATH
remote: .
specs:
rail0 (1.2.0)
logger (~> 1.6)

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -43,6 +44,7 @@ GEM
http-2 (>= 1.1.3)
keccak (1.3.3)
konstructor (1.0.2)
logger (1.7.0)
mini_portile2 (2.8.9)
openssl (3.3.3)
pkg-config (1.6.5)
Expand Down Expand Up @@ -109,6 +111,7 @@ CHECKSUMS
httpx (1.7.8) sha256=6d769465ed608287a272ba0e4700fc22cee6f0335d80bd5c2effaf7fb7bd2a3a
keccak (1.3.3) sha256=970dcb1e78b8c3129ba2baff8a5baa5cebf391136db1f4018e8ccc9130794557
konstructor (1.0.2) sha256=fd6ac9eb1dadc1e520e06042aa2ef0122d6a070e06cde86db5a54c0c7bfe2e31
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
openssl (3.3.3) sha256=d46902138f2987c13122fab826030a11c2bb9b8a16394215cbfc5062c5e2d335
pkg-config (1.6.5) sha256=33f9f81c5322983d22b439b8b672f27777b406fea23bfec74ff14bbeb42ec733
Expand Down
65 changes: 60 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,33 @@ client.webhooks.event_callbacks(id, status: "failed")
client.webhooks.delete(id) # 204
```

## Analytics (JWT)

Account-scoped: the numbers are the merchant's own, so a buyer's account-less token is
refused with `account_required`.

```ruby
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 | status
```

All 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.

## Signing helpers (`Rail0::Signing`)

Requires the `eth` gem. No private key ever leaves your process.
Expand Down Expand Up @@ -297,18 +324,44 @@ client = Rail0::Client.new(

## Error handling

Non-2xx responses raise `Rail0::ApiError`:
Non-2xx responses raise `Rail0::ApiError`, carrying the gateway's code/title/detail
triple:

```ruby
begin
client.payments.capture(rail0_id, { signed_transaction: raw })
rescue Rail0::ApiError => e
e.status # 422
e.error # "not_capturable" (machine-readable code)
e.message # human-readable description
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
end
```

**`error` 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.

## Configuration

```ruby
Expand All @@ -334,7 +387,8 @@ lib/rail0/
http_client.rb thin per-verb facade (get/post/put/patch/delete) over Request
request.rb Rail0::Request — one HTTP call: retry, pagination, error mapping, logging
default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:`
api_error.rb Rail0::ApiError
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)
Expand All @@ -348,6 +402,7 @@ lib/rail0/
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
```

Expand Down
1 change: 1 addition & 0 deletions lib/rail0.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
end

require "rail0/version"
require "rail0/error_hints"
require "rail0/api_error"
require "rail0/default_logger"
require "rail0/request"
Expand Down
27 changes: 22 additions & 5 deletions lib/rail0/api_error.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
# frozen_string_literal: true

module Rail0
# Raised for non-2xx responses from the RAIL0 API.
# Raised for non-2xx responses from the RAIL0 gateway, mirroring the code/title/detail
# triple the gateway answers with. `detail` is written to be shown to a user; `hint` is
# this SDK's own advice, present only for codes worth adding a next step to.
class ApiError < StandardError
# @!attribute [r] status
# @return [Integer] HTTP status code (e.g. 404, 409, 422).
# @!attribute [r] error
# @return [String] Machine-readable error identifier (e.g. "PaymentNotFound").
attr_reader :status, :error
# @return [String] The specific condition, and the only field to branch on — e.g.
# "not_capturable", "insufficient_token_balance", "insufficient_gas_funds".
# @!attribute [r] title
# @return [String, nil] Short label for the failure, e.g. "Not enough balance".
# @!attribute [r] detail
# @return [String, nil] One or two sentences fit to show a user verbatim. Also this
# exception's message.
attr_reader :status, :error, :title, :detail

# @param status [Integer]
# @param error [String]
# @param message [String]
def initialize(status, error, message)
# @param message [String] The detail; kept positional for compatibility.
# @param title [String, nil]
def initialize(status, error, message, title: nil)
super(message)
@status = status
@error = error
@title = title
@detail = message
freeze
end

# This SDK's actionable next step for the error, or nil when it has none.
# @return [String, nil]
def hint
Rail0.describe_error(error)
end
end
end
7 changes: 6 additions & 1 deletion lib/rail0/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
require "resources/payments"
require "resources/disputes"
require "resources/webhooks"
require "resources/analytics"

module Rail0
# Entry point for the RAIL0 SDK.
Expand Down Expand Up @@ -40,8 +41,11 @@ class Client
# @return [Resources::Disputes] Account-level dispute list (JWT).
# @!attribute [r] webhooks
# @return [Resources::Webhooks] Webhook subscription management (JWT).
# @!attribute [r] analytics
# @return [Resources::Analytics] Account-scoped payment analytics (JWT).
attr_reader :auth, :chains, :tokens, :health, :payment_methods,
:wallets, :payments, :disputes, :webhooks
:wallets, :payments, :disputes, :webhooks, :analytics


# @param base_url [String] Base URL of the RAIL0 API, e.g. "https://api.rail0.xyz".
# @param headers [Hash] Default headers merged into every request (e.g. Authorization).
Expand All @@ -63,6 +67,7 @@ def initialize(base_url:, headers: {}, timeout: 30, logger: nil, max_retries: 0,
@payments = Resources::Payments.new(http)
@disputes = Resources::Disputes.new(http)
@webhooks = Resources::Webhooks.new(http)
@analytics = Resources::Analytics.new(http)
freeze
end
end
Expand Down
57 changes: 57 additions & 0 deletions lib/rail0/error_hints.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
module Rail0
# Actionable next steps per error code, supplementing the gateway's own `detail`.
# Shared source with the Go SDK (rail0.DescribeError), the TS SDK (describeError) and
# the CLI's hints — keep the four in step.
ERROR_HINTS = {
"amount_exceeds_capturable" => "amount is above the capturable balance — check capturableAmount on the payment",
"amount_exceeds_refundable" => "amount is above the refundable balance — check refundableAmount on the payment",
"not_capturable" => "the payment must be 'authorized' or 'partially_captured' to capture",
"not_voidable" => "void is only allowed while 'authorized' with nothing captured — use release for the remainder after a capture",
"not_releasable" => "release opens only after authorizationExpiry",
"not_refundable" => "nothing is refundable — the payment must be charged/captured and within the refund window",
"not_signable" => "the payment must be 'unsigned' to sign",
"already_signed" => "the payer signature is already stored — the payee can act now",
"no_signature" => "the payer has not signed yet",
"wrong_mode" => "this operation doesn't match the payment's mode (authorize vs charge)",
"already_disputed" => "a dispute is already open — close it first",
"not_disputed" => "there is no open dispute to close",
"nothing_to_dispute" => "a dispute needs a merchant-held (refundable) balance",
"transaction_not_overwritable" => "a transaction for this operation is already in flight — wait for it to settle",
"signer_mismatch" => "the signing key doesn't match the payment's payer/payee",
"config_hash_mismatch" => "the payment record and its on-chain deployment disagree — the payment cannot be operated as recorded",
"payment_not_on_chain" => "the contract has no record of this payment — its opening transaction may never have confirmed",
"unsupported_contract_version" => "the payment's RAIL0 deployment is newer or older than this gateway supports — upgrade the gateway",
"insufficient_token_balance" => "the paying wallet does not hold enough of the token — top it up and retry",
"invalid_token_signature" => "the EIP-3009 authorization did not recover to the paying wallet — wrong key, chain, token or amount",
"authorization_already_used" => "that EIP-3009 authorization was already spent or cancelled — each is single-use, create a fresh payment",
"authorization_not_yet_valid" => "the authorization's validAfter is still in the future",
"token_account_blocked" => "the token issuer has blocklisted one of the wallets in this transfer",
"token_paused" => "the token contract is paused by its issuer — no transfer can settle right now",
"insufficient_gas_funds" => "the sending wallet cannot cover gas — fund it with the chain's native token",
"nonce_too_low" => "a transaction with that nonce is already on-chain — re-prepare the operation",
"replacement_underpriced" => "another transaction with that nonce is pending and this one does not pay enough to replace it",
"gas_price_too_low" => "the fee is below what the node accepts — re-prepare to pick up current fees",
"already_known" => "the node already has this exact transaction — wait for it to confirm rather than resending",
"rpc_unavailable" => "no configured RPC endpoint answered — the transaction was not submitted",
"not_the_payee" => "only the payment's payee can do this — sign in with the merchant's wallet",
"not_the_payer" => "only the payment's payer can do this — sign in with the buyer's wallet",
"not_a_participant" => "only the payer and the payee can see or act on a payment",
"not_payee" => "only the merchant (payee) may do this",
"not_payer" => "only the buyer (payer) may do this",
"not_payer_or_payee" => "only the payer or the payee may do this",
"refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible",
"authorization_not_expired" => "release opens only after authorizationExpiry — wait until it passes",
"already_captured" => "already (partially) captured — use release for the remainder, not void",
"token_not_accepted" => "the token isn't in this deployment's allowlist",
"payment_already_exists" => "a payment with this id already exists on-chain"
}.freeze

# An actionable hint for a rail0 error code, or nil when the code is unknown.
# @param code [String, nil]
# @return [String, nil]
def self.describe_error(code)
return nil if code.nil? || code.to_s.empty?

ERROR_HINTS[code.to_s]
end
end
7 changes: 4 additions & 3 deletions lib/rail0/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ def call

unless response.is_a?(Net::HTTPSuccess)
error_body = parse_error_body(response)
api_error = ApiError.new(response.code.to_i, error_code(error_body), error_message(error_body, response))
api_error = ApiError.new(response.code.to_i, error_code(error_body),
error_message(error_body, response), title: error_body[:title])
logger.call(LogEntry.new(
method: method.to_s.upcase, url: url, duration_ms: duration_ms, attempt: attempt,
request_body: body, status: response.code.to_i, response_body: error_body, error: api_error
Expand Down Expand Up @@ -118,11 +119,11 @@ def parse_error_body(response)
end

def error_code(body)
body[:status] || body[:code] || body[:error]
body[:code] || body[:error] || body[:status]
end

def error_message(body, response = nil)
body[:message] || body[:error] || (response && "HTTP #{response.code}")
body[:detail] || body[:message] || body[:error] || (response && "HTTP #{response.code}")
end

def elapsed_ms(start)
Expand Down
61 changes: 61 additions & 0 deletions lib/rail0/resources/analytics.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

require "query"

module Rail0
module Resources
# Account-scoped payment analytics (requires JWT — a buyer's account-less token is
# refused with `account_required`). Every endpoint takes the same filters, so the
# three views answer one question at different resolutions: a total, a series over
# time, a split by dimension. Volume is reported only when a query pins both `token`
# and `chain_id`, since amounts in different tokens are different units.
class Analytics
include Query

FILTERS = %i[mode status token chain_id from to].freeze

attr_reader :http

def initialize(http)
@http = http
freeze
end

# Totals for the account's payments.
# @param mode [String, nil] "authorize" or "charge".
# @param status [String, nil] A payment status, e.g. "captured".
# @param token [String, nil] Token address (0x…); with chain_id, unlocks volume.
# @param chain_id [Integer, nil] Chain ID. Pass nil or 0 for all chains.
# @param from [String, nil] ISO-8601 — payments created at/after this time.
# @param to [String, nil] ISO-8601 — payments created at/before this time.
# @return [Hash] count, disputed_count and (with token+chain_id) volume.
def summary(**filters)
http.get("/analytics/summary#{build_query(**only_filters(filters))}")
end

# The account's payment count per time bucket, oldest first.
# @param interval [String, nil] "hour", "day", "week" or "month" (default "day").
# @return [Array<Hash>] bucket, count, and volume with token+chain_id.
def timeseries(interval: nil, **filters)
http.get("/analytics/timeseries#{build_query(**only_filters(filters).merge(interval: interval))}")
end

# The account's payments aggregated by one dimension.
# @param by [String] Required — "token", "chain", "mode" or "status".
# @return [Array<Hash>] One row per group; token and chain rows carry volume.
def breakdown(by:, **filters)
raise ArgumentError, "by is required (token, chain, mode or status)" if by.nil? || by.to_s.empty?

http.get("/analytics/breakdown#{build_query(**only_filters(filters).merge(by: by))}")
end

private

def only_filters(filters)
picked = filters.slice(*FILTERS)
picked[:chain_id] = nil if picked[:chain_id] == 0
picked
end
end
end
end
Loading