billingio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: be246a686bc1dac9b2c0bf75a6f66c5f77b58ba205ad72628a09d72a57d4a9e0
4
+ data.tar.gz: 610719813172e602cc3b78c4f5b21120d91320a34945fc483c6ecc9743a4f8af
5
+ SHA512:
6
+ metadata.gz: dfce4a65d73b3c03f3a968fe376c4b6ca47d22fa5f693c8134672b94c09972872109b00b5abd3047d22a84f27b7acbb57fbc26860d1f100411cc03cd37b241a3
7
+ data.tar.gz: a47bdd1a11a97832bb68bb3d0c17baa6f45b6259430106a3e859b7266b39bf75238541c61995f01c6f700b1ddc25ca9cdb1c7e880dcf407d93d39a403cc3c6f5
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem "minitest", "~> 5.0"
9
+ gem "rake", "~> 13.0"
10
+ gem "rubocop", "~> 1.0", require: false
11
+ gem "webmock", "~> 3.0"
12
+ end
data/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # billingio
2
+
3
+ Official Ruby SDK for the [billing.io](https://billing.io) crypto checkout API.
4
+
5
+ - Create payment checkouts settled in USDT / USDC on Tron or Arbitrum
6
+ - Manage webhook endpoints and verify signatures
7
+ - Query event history with cursor-based pagination
8
+ - Zero runtime dependencies (stdlib only)
9
+
10
+ ## Requirements
11
+
12
+ - Ruby >= 3.0
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "billingio"
20
+ ```
21
+
22
+ Then run:
23
+
24
+ ```
25
+ bundle install
26
+ ```
27
+
28
+ Or install directly:
29
+
30
+ ```
31
+ gem install billingio
32
+ ```
33
+
34
+ ## Quickstart
35
+
36
+ ```ruby
37
+ require "billingio"
38
+
39
+ client = BillingIO::Client.new(api_key: "sk_live_...")
40
+
41
+ # Create a checkout
42
+ checkout = client.checkouts.create(
43
+ amount_usd: 49.99,
44
+ chain: "tron",
45
+ token: "USDT",
46
+ metadata: { "order_id" => "ord_12345" }
47
+ )
48
+
49
+ puts checkout.checkout_id # => "co_..."
50
+ puts checkout.deposit_address # => "T..."
51
+ puts checkout.status # => "pending"
52
+
53
+ # Poll for status updates
54
+ status = client.checkouts.get_status(checkout.checkout_id)
55
+ puts status.confirmations # => 0
56
+ puts status.required_confirmations # => 19
57
+ puts status.polling_interval_ms # => 2000
58
+ ```
59
+
60
+ ## Checkouts
61
+
62
+ ```ruby
63
+ # Create with idempotency key
64
+ checkout = client.checkouts.create(
65
+ amount_usd: 100.00,
66
+ chain: "arbitrum",
67
+ token: "USDC",
68
+ idempotency_key: SecureRandom.uuid
69
+ )
70
+
71
+ # Retrieve a checkout
72
+ checkout = client.checkouts.get("co_abc123")
73
+
74
+ # Get lightweight status for polling
75
+ status = client.checkouts.get_status("co_abc123")
76
+
77
+ # List checkouts with optional status filter
78
+ list = client.checkouts.list(status: "confirmed", limit: 10)
79
+ list.each { |c| puts c.checkout_id }
80
+ ```
81
+
82
+ ## Webhook Endpoints
83
+
84
+ ```ruby
85
+ # Create a webhook endpoint
86
+ endpoint = client.webhooks.create(
87
+ url: "https://example.com/webhooks/billing",
88
+ events: ["checkout.completed", "checkout.expired"],
89
+ description: "Production webhook"
90
+ )
91
+
92
+ # IMPORTANT: Store the secret securely -- it is only returned on creation.
93
+ puts endpoint.secret # => "whsec_..."
94
+
95
+ # List all endpoints
96
+ endpoints = client.webhooks.list
97
+ endpoints.each { |ep| puts "#{ep.webhook_id}: #{ep.url}" }
98
+
99
+ # Retrieve a single endpoint
100
+ endpoint = client.webhooks.get("we_abc123")
101
+
102
+ # Delete an endpoint
103
+ client.webhooks.delete("we_abc123")
104
+ ```
105
+
106
+ ## Webhook Signature Verification
107
+
108
+ When receiving webhook events, always verify the signature before processing.
109
+
110
+ ### Rails
111
+
112
+ ```ruby
113
+ class WebhooksController < ApplicationController
114
+ skip_before_action :verify_authenticity_token
115
+
116
+ def create
117
+ payload = request.body.read
118
+ header = request.headers["X-Billing-Signature"]
119
+ secret = ENV.fetch("BILLING_WEBHOOK_SECRET")
120
+
121
+ begin
122
+ event = BillingIO::WebhookSignature.verify(
123
+ payload: payload,
124
+ header: header,
125
+ secret: secret,
126
+ tolerance: 300
127
+ )
128
+ rescue BillingIO::WebhookVerificationError => e
129
+ return head :bad_request
130
+ end
131
+
132
+ case event["type"]
133
+ when "checkout.completed"
134
+ # Fulfill the order
135
+ order = Order.find_by!(billing_checkout_id: event["checkout_id"])
136
+ order.fulfill!
137
+ when "checkout.expired"
138
+ # Handle expiration
139
+ end
140
+
141
+ head :ok
142
+ end
143
+ end
144
+ ```
145
+
146
+ ### Sinatra
147
+
148
+ ```ruby
149
+ require "sinatra"
150
+ require "billingio"
151
+
152
+ post "/webhooks/billing" do
153
+ payload = request.body.read
154
+ header = request.env["HTTP_X_BILLING_SIGNATURE"]
155
+ secret = ENV.fetch("BILLING_WEBHOOK_SECRET")
156
+
157
+ begin
158
+ event = BillingIO::WebhookSignature.verify(
159
+ payload: payload,
160
+ header: header,
161
+ secret: secret
162
+ )
163
+ rescue BillingIO::WebhookVerificationError
164
+ halt 400, "Invalid signature"
165
+ end
166
+
167
+ case event["type"]
168
+ when "checkout.completed"
169
+ # Handle successful payment
170
+ end
171
+
172
+ status 200
173
+ body "ok"
174
+ end
175
+ ```
176
+
177
+ ## Events
178
+
179
+ ```ruby
180
+ # List events with filters
181
+ events = client.events.list(
182
+ type: "checkout.completed",
183
+ checkout_id: "co_abc123",
184
+ limit: 50
185
+ )
186
+
187
+ events.each do |event|
188
+ puts "#{event.event_id}: #{event.type} at #{event.created_at}"
189
+ puts " checkout: #{event.data.checkout_id}" # nested Checkout model
190
+ end
191
+
192
+ # Retrieve a single event
193
+ event = client.events.get("evt_abc123")
194
+ ```
195
+
196
+ ## Pagination
197
+
198
+ All list endpoints return a `BillingIO::PaginatedList` with cursor-based
199
+ pagination. Use `has_more?` and `next_cursor` to page through results.
200
+
201
+ ```ruby
202
+ # Manual pagination
203
+ cursor = nil
204
+
205
+ loop do
206
+ page = client.checkouts.list(cursor: cursor, limit: 100)
207
+
208
+ page.each do |checkout|
209
+ puts checkout.checkout_id
210
+ end
211
+
212
+ break unless page.has_more?
213
+ cursor = page.next_cursor
214
+ end
215
+ ```
216
+
217
+ `PaginatedList` includes `Enumerable`, so you can use `map`, `select`,
218
+ `first`, and other enumeration methods on each page.
219
+
220
+ ## Health Check
221
+
222
+ ```ruby
223
+ health = client.health.get
224
+ puts health.status # => "healthy"
225
+ puts health.version # => "1.0.0"
226
+ ```
227
+
228
+ ## Error Handling
229
+
230
+ All API errors raise `BillingIO::Error` with structured details.
231
+
232
+ ```ruby
233
+ begin
234
+ client.checkouts.get("co_nonexistent")
235
+ rescue BillingIO::Error => e
236
+ puts e.message # => "No checkout found with ID co_nonexistent."
237
+ puts e.type # => "not_found"
238
+ puts e.code # => "checkout_not_found"
239
+ puts e.status_code # => 404
240
+ puts e.param # => "checkout_id"
241
+ end
242
+ ```
243
+
244
+ Error types (from the API):
245
+
246
+ | Type | Description |
247
+ |-------------------------|----------------------------------------|
248
+ | `invalid_request` | Missing or invalid parameters |
249
+ | `authentication_error` | Invalid or missing API key |
250
+ | `not_found` | Resource does not exist |
251
+ | `idempotency_conflict` | Idempotency key reused with diff params|
252
+ | `rate_limited` | Too many requests |
253
+ | `internal_error` | Server-side error |
254
+
255
+ ## Configuration
256
+
257
+ ```ruby
258
+ # Override the base URL (e.g. for local development)
259
+ client = BillingIO::Client.new(
260
+ api_key: "sk_test_...",
261
+ base_url: "http://localhost:8080/v1"
262
+ )
263
+ ```
264
+
265
+ ## License
266
+
267
+ MIT
data/billingio.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/billingio/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "billingio"
7
+ spec.version = BillingIO::VERSION
8
+ spec.authors = ["billing.io"]
9
+ spec.email = ["support@billing.io"]
10
+
11
+ spec.summary = "Ruby SDK for the billing.io crypto checkout API"
12
+ spec.description = "Official Ruby client for billing.io -- non-custodial crypto " \
13
+ "payment checkouts with stablecoin settlement. Create checkouts, " \
14
+ "manage webhooks, verify signatures, and query event history."
15
+ spec.homepage = "https://github.com/billingio/billingio-ruby"
16
+ spec.license = "MIT"
17
+
18
+ spec.required_ruby_version = ">= 3.0"
19
+
20
+ spec.metadata = {
21
+ "homepage_uri" => spec.homepage,
22
+ "source_code_uri" => "https://github.com/billingio/billingio-ruby",
23
+ "changelog_uri" => "https://github.com/billingio/billingio-ruby/blob/main/CHANGELOG.md",
24
+ "documentation_uri" => "https://docs.billing.io",
25
+ "bug_tracker_uri" => "https://github.com/billingio/billingio-ruby/issues"
26
+ }
27
+
28
+ spec.files = Dir["lib/**/*.rb"] + ["billingio.gemspec", "Gemfile", "README.md"]
29
+ spec.require_paths = ["lib"]
30
+
31
+ # Zero runtime dependencies -- stdlib only (net/http, openssl, json, cgi).
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Main entry point for the billing.io API.
5
+ #
6
+ # @example
7
+ # client = BillingIO::Client.new(api_key: "sk_live_...")
8
+ # checkout = client.checkouts.create(
9
+ # amount_usd: 49.99,
10
+ # chain: "tron",
11
+ # token: "USDT"
12
+ # )
13
+ class Client
14
+ DEFAULT_BASE_URL = "https://api.billing.io/v1"
15
+
16
+ # @param api_key [String] your secret API key (sk_live_... or sk_test_...)
17
+ # @param base_url [String] API root URL (override for testing or local dev)
18
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL)
19
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
20
+
21
+ @http = HttpClient.new(api_key: api_key, base_url: base_url)
22
+ end
23
+
24
+ # @return [BillingIO::Checkouts]
25
+ def checkouts
26
+ @checkouts ||= Checkouts.new(@http)
27
+ end
28
+
29
+ # @return [BillingIO::Webhooks]
30
+ def webhooks
31
+ @webhooks ||= Webhooks.new(@http)
32
+ end
33
+
34
+ # @return [BillingIO::Events]
35
+ def events
36
+ @events ||= Events.new(@http)
37
+ end
38
+
39
+ # @return [BillingIO::Health]
40
+ def health
41
+ @health ||= Health.new(@http)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Raised when the billing.io API returns a non-2xx response.
5
+ #
6
+ # Attributes correspond to the error envelope documented in the API spec:
7
+ # { "error": { "type", "code", "message", "param" } }
8
+ class Error < StandardError
9
+ attr_reader :type, :code, :status_code, :param
10
+
11
+ # @param message [String] human-readable error description
12
+ # @param type [String, nil] error category (e.g. "invalid_request")
13
+ # @param code [String, nil] machine-readable code (e.g. "missing_required_field")
14
+ # @param status_code [Integer, nil] HTTP status code
15
+ # @param param [String, nil] request parameter that triggered the error
16
+ def initialize(message = nil, type: nil, code: nil, status_code: nil, param: nil)
17
+ @type = type
18
+ @code = code
19
+ @status_code = status_code
20
+ @param = param
21
+ super(message)
22
+ end
23
+
24
+ # Build an Error from the parsed JSON error envelope and HTTP status.
25
+ #
26
+ # @param body [Hash] parsed response body
27
+ # @param status_code [Integer] HTTP status code
28
+ # @return [BillingIO::Error]
29
+ def self.from_response(body, status_code)
30
+ err = body["error"] || {}
31
+ new(
32
+ err["message"] || "Unknown error (HTTP #{status_code})",
33
+ type: err["type"],
34
+ code: err["code"],
35
+ status_code: status_code,
36
+ param: err["param"]
37
+ )
38
+ end
39
+ end
40
+
41
+ # Raised when webhook signature verification fails.
42
+ class WebhookVerificationError < StandardError; end
43
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module BillingIO
8
+ # @api private
9
+ #
10
+ # Thin wrapper around Net::HTTP that handles authentication,
11
+ # JSON serialization, and error mapping. Every public resource
12
+ # class delegates its HTTP work here.
13
+ class HttpClient
14
+ # @param api_key [String] bearer token (sk_live_... / sk_test_...)
15
+ # @param base_url [String] API root including version path
16
+ def initialize(api_key:, base_url:)
17
+ @api_key = api_key
18
+ @base_url = base_url.chomp("/")
19
+ end
20
+
21
+ # ---- HTTP verbs -------------------------------------------------------
22
+
23
+ def get(path, params = {})
24
+ uri = build_uri(path, params)
25
+ request = Net::HTTP::Get.new(uri)
26
+ execute(uri, request)
27
+ end
28
+
29
+ def post(path, body = nil, headers = {})
30
+ uri = build_uri(path)
31
+ request = Net::HTTP::Post.new(uri)
32
+ if body
33
+ request.body = JSON.generate(body)
34
+ request["Content-Type"] = "application/json"
35
+ end
36
+ headers.each { |k, v| request[k] = v }
37
+ execute(uri, request)
38
+ end
39
+
40
+ def delete(path)
41
+ uri = build_uri(path)
42
+ request = Net::HTTP::Delete.new(uri)
43
+ execute(uri, request)
44
+ end
45
+
46
+ private
47
+
48
+ def build_uri(path, params = {})
49
+ uri = URI("#{@base_url}#{path}")
50
+ unless params.empty?
51
+ query = params.reject { |_, v| v.nil? }
52
+ .map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }
53
+ .join("&")
54
+ uri.query = query unless query.empty?
55
+ end
56
+ uri
57
+ end
58
+
59
+ def execute(uri, request)
60
+ request["Authorization"] = "Bearer #{@api_key}"
61
+ request["Accept"] = "application/json"
62
+ request["User-Agent"] = "billingio-ruby/#{BillingIO::VERSION}"
63
+
64
+ http = Net::HTTP.new(uri.host, uri.port)
65
+ http.use_ssl = (uri.scheme == "https")
66
+ http.open_timeout = 30
67
+ http.read_timeout = 60
68
+
69
+ response = http.request(request)
70
+ handle_response(response)
71
+ end
72
+
73
+ def handle_response(response)
74
+ status = response.code.to_i
75
+
76
+ # 204 No Content -- nothing to parse
77
+ return nil if status == 204
78
+
79
+ body = parse_body(response)
80
+
81
+ if status >= 200 && status < 300
82
+ body
83
+ else
84
+ raise Error.from_response(body.is_a?(Hash) ? body : {}, status)
85
+ end
86
+ end
87
+
88
+ def parse_body(response)
89
+ return {} if response.body.nil? || response.body.empty?
90
+
91
+ JSON.parse(response.body)
92
+ rescue JSON::ParserError
93
+ { "error" => { "message" => response.body } }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Represents a payment checkout.
5
+ #
6
+ # @attr_reader checkout_id [String] unique identifier (prefixed +co_+)
7
+ # @attr_reader deposit_address [String] blockchain address to send funds to
8
+ # @attr_reader chain [String] blockchain network ("tron" | "arbitrum")
9
+ # @attr_reader token [String] stablecoin token ("USDT" | "USDC")
10
+ # @attr_reader amount_usd [Float] original USD amount
11
+ # @attr_reader amount_atomic [String] token amount in smallest unit
12
+ # @attr_reader status [String] current checkout status
13
+ # @attr_reader tx_hash [String, nil] on-chain transaction hash
14
+ # @attr_reader confirmations [Integer] current confirmation count
15
+ # @attr_reader required_confirmations [Integer] confirmations needed
16
+ # @attr_reader expires_at [String] ISO-8601 expiry timestamp
17
+ # @attr_reader detected_at [String, nil] ISO-8601 detection timestamp
18
+ # @attr_reader confirmed_at [String, nil] ISO-8601 confirmation timestamp
19
+ # @attr_reader created_at [String] ISO-8601 creation timestamp
20
+ # @attr_reader metadata [Hash, nil] arbitrary key-value pairs
21
+ class Checkout
22
+ ATTRS = %i[
23
+ checkout_id deposit_address chain token
24
+ amount_usd amount_atomic status tx_hash
25
+ confirmations required_confirmations
26
+ expires_at detected_at confirmed_at created_at
27
+ metadata
28
+ ].freeze
29
+
30
+ attr_reader(*ATTRS)
31
+
32
+ # @param attrs [Hash{String,Symbol => Object}]
33
+ def initialize(attrs = {})
34
+ ATTRS.each do |attr|
35
+ value = attrs[attr.to_s] || attrs[attr]
36
+ instance_variable_set(:"@#{attr}", value)
37
+ end
38
+ end
39
+
40
+ # @param hash [Hash]
41
+ # @return [BillingIO::Checkout]
42
+ def self.from_hash(hash)
43
+ new(hash)
44
+ end
45
+
46
+ # @return [Hash{String => Object}]
47
+ def to_h
48
+ ATTRS.each_with_object({}) do |attr, h|
49
+ h[attr.to_s] = instance_variable_get(:"@#{attr}")
50
+ end
51
+ end
52
+
53
+ def inspect
54
+ "#<BillingIO::Checkout checkout_id=#{@checkout_id.inspect} status=#{@status.inspect} amount_usd=#{@amount_usd.inspect}>"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Lightweight status response returned by the polling endpoint.
5
+ #
6
+ # @attr_reader checkout_id [String] checkout identifier
7
+ # @attr_reader status [String] current status
8
+ # @attr_reader tx_hash [String, nil] on-chain transaction hash
9
+ # @attr_reader confirmations [Integer] current confirmation count
10
+ # @attr_reader required_confirmations [Integer] confirmations needed
11
+ # @attr_reader detected_at [String, nil] ISO-8601 detection timestamp
12
+ # @attr_reader confirmed_at [String, nil] ISO-8601 confirmation timestamp
13
+ # @attr_reader polling_interval_ms [Integer] suggested polling interval
14
+ class CheckoutStatus
15
+ ATTRS = %i[
16
+ checkout_id status tx_hash
17
+ confirmations required_confirmations
18
+ detected_at confirmed_at polling_interval_ms
19
+ ].freeze
20
+
21
+ attr_reader(*ATTRS)
22
+
23
+ # @param attrs [Hash{String,Symbol => Object}]
24
+ def initialize(attrs = {})
25
+ ATTRS.each do |attr|
26
+ value = attrs[attr.to_s] || attrs[attr]
27
+ instance_variable_set(:"@#{attr}", value)
28
+ end
29
+ end
30
+
31
+ # @param hash [Hash]
32
+ # @return [BillingIO::CheckoutStatus]
33
+ def self.from_hash(hash)
34
+ new(hash)
35
+ end
36
+
37
+ # @return [Hash{String => Object}]
38
+ def to_h
39
+ ATTRS.each_with_object({}) do |attr, h|
40
+ h[attr.to_s] = instance_variable_get(:"@#{attr}")
41
+ end
42
+ end
43
+
44
+ def inspect
45
+ "#<BillingIO::CheckoutStatus checkout_id=#{@checkout_id.inspect} status=#{@status.inspect} confirmations=#{@confirmations.inspect}/#{@required_confirmations.inspect}>"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Represents a webhook event.
5
+ #
6
+ # @attr_reader event_id [String] unique identifier (prefixed +evt_+)
7
+ # @attr_reader type [String] event type (e.g. "checkout.completed")
8
+ # @attr_reader checkout_id [String] related checkout identifier
9
+ # @attr_reader data [BillingIO::Checkout] checkout snapshot at time of event
10
+ # @attr_reader created_at [String] ISO-8601 creation timestamp
11
+ class Event
12
+ ATTRS = %i[event_id type checkout_id data created_at].freeze
13
+
14
+ attr_reader(*ATTRS)
15
+
16
+ # @param attrs [Hash{String,Symbol => Object}]
17
+ def initialize(attrs = {})
18
+ ATTRS.each do |attr|
19
+ value = attrs[attr.to_s] || attrs[attr]
20
+
21
+ # Wrap the nested checkout data in a Checkout model
22
+ if attr == :data && value.is_a?(Hash)
23
+ value = Checkout.from_hash(value)
24
+ end
25
+
26
+ instance_variable_set(:"@#{attr}", value)
27
+ end
28
+ end
29
+
30
+ # @param hash [Hash]
31
+ # @return [BillingIO::Event]
32
+ def self.from_hash(hash)
33
+ new(hash)
34
+ end
35
+
36
+ # @return [Hash{String => Object}]
37
+ def to_h
38
+ ATTRS.each_with_object({}) do |attr, h|
39
+ val = instance_variable_get(:"@#{attr}")
40
+ h[attr.to_s] = val.respond_to?(:to_h) && val.is_a?(Checkout) ? val.to_h : val
41
+ end
42
+ end
43
+
44
+ def inspect
45
+ "#<BillingIO::Event event_id=#{@event_id.inspect} type=#{@type.inspect} checkout_id=#{@checkout_id.inspect}>"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Represents the API health check response.
5
+ #
6
+ # @attr_reader status [String] service health ("healthy")
7
+ # @attr_reader version [String] API version (e.g. "1.0.0")
8
+ class HealthResponse
9
+ ATTRS = %i[status version].freeze
10
+
11
+ attr_reader(*ATTRS)
12
+
13
+ # @param attrs [Hash{String,Symbol => Object}]
14
+ def initialize(attrs = {})
15
+ ATTRS.each do |attr|
16
+ value = attrs[attr.to_s] || attrs[attr]
17
+ instance_variable_set(:"@#{attr}", value)
18
+ end
19
+ end
20
+
21
+ # @param hash [Hash]
22
+ # @return [BillingIO::HealthResponse]
23
+ def self.from_hash(hash)
24
+ new(hash)
25
+ end
26
+
27
+ # @return [Hash{String => Object}]
28
+ def to_h
29
+ ATTRS.each_with_object({}) do |attr, h|
30
+ h[attr.to_s] = instance_variable_get(:"@#{attr}")
31
+ end
32
+ end
33
+
34
+ def inspect
35
+ "#<BillingIO::HealthResponse status=#{@status.inspect} version=#{@version.inspect}>"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Generic cursor-paginated list returned by all list endpoints.
5
+ #
6
+ # @attr_reader data [Array] items on the current page
7
+ # @attr_reader has_more [Boolean] whether more pages exist
8
+ # @attr_reader next_cursor [String, nil] opaque cursor for the next page
9
+ class PaginatedList
10
+ include Enumerable
11
+
12
+ attr_reader :data, :has_more, :next_cursor
13
+
14
+ # @param data [Array] deserialized model instances
15
+ # @param has_more [Boolean] pagination flag
16
+ # @param next_cursor [String, nil] cursor for fetching the next page
17
+ def initialize(data:, has_more:, next_cursor:)
18
+ @data = data
19
+ @has_more = has_more
20
+ @next_cursor = next_cursor
21
+ end
22
+
23
+ # Iterate over items on the current page.
24
+ def each(&block)
25
+ @data.each(&block)
26
+ end
27
+
28
+ # Number of items on the current page.
29
+ def size
30
+ @data.size
31
+ end
32
+ alias_method :length, :size
33
+
34
+ # @return [Boolean]
35
+ def has_more?
36
+ @has_more
37
+ end
38
+
39
+ # Build a PaginatedList from a raw API response hash.
40
+ #
41
+ # @param hash [Hash] raw response body with "data", "has_more", "next_cursor"
42
+ # @param model_cls [Class] model class that responds to +.from_hash+
43
+ # @return [BillingIO::PaginatedList]
44
+ def self.from_hash(hash, model_cls)
45
+ items = (hash["data"] || []).map { |item| model_cls.from_hash(item) }
46
+ new(
47
+ data: items,
48
+ has_more: hash["has_more"] || false,
49
+ next_cursor: hash["next_cursor"]
50
+ )
51
+ end
52
+
53
+ def inspect
54
+ "#<BillingIO::PaginatedList size=#{size} has_more=#{@has_more.inspect}>"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Represents a registered webhook endpoint.
5
+ #
6
+ # @attr_reader webhook_id [String] unique identifier (prefixed +we_+)
7
+ # @attr_reader url [String] HTTPS endpoint receiving events
8
+ # @attr_reader events [Array<String>] subscribed event types
9
+ # @attr_reader secret [String, nil] HMAC signing secret (only on creation)
10
+ # @attr_reader description [String, nil] human-readable label
11
+ # @attr_reader status [String] "active" or "disabled"
12
+ # @attr_reader created_at [String] ISO-8601 creation timestamp
13
+ class WebhookEndpoint
14
+ ATTRS = %i[
15
+ webhook_id url events secret
16
+ description status created_at
17
+ ].freeze
18
+
19
+ attr_reader(*ATTRS)
20
+
21
+ # @param attrs [Hash{String,Symbol => Object}]
22
+ def initialize(attrs = {})
23
+ ATTRS.each do |attr|
24
+ value = attrs[attr.to_s] || attrs[attr]
25
+ instance_variable_set(:"@#{attr}", value)
26
+ end
27
+ end
28
+
29
+ # @param hash [Hash]
30
+ # @return [BillingIO::WebhookEndpoint]
31
+ def self.from_hash(hash)
32
+ new(hash)
33
+ end
34
+
35
+ # @return [Hash{String => Object}]
36
+ def to_h
37
+ ATTRS.each_with_object({}) do |attr, h|
38
+ h[attr.to_s] = instance_variable_get(:"@#{attr}")
39
+ end
40
+ end
41
+
42
+ def inspect
43
+ "#<BillingIO::WebhookEndpoint webhook_id=#{@webhook_id.inspect} url=#{@url.inspect} status=#{@status.inspect}>"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Provides access to checkout-related API endpoints.
5
+ #
6
+ # client.checkouts.create(amount_usd: 49.99, chain: "tron", token: "USDT")
7
+ # client.checkouts.list(status: "pending")
8
+ # client.checkouts.get("co_abc123")
9
+ # client.checkouts.get_status("co_abc123")
10
+ class Checkouts
11
+ # @api private
12
+ def initialize(http_client)
13
+ @http = http_client
14
+ end
15
+
16
+ # Create a new payment checkout.
17
+ #
18
+ # @param amount_usd [Numeric] amount in USD (>= 0.01)
19
+ # @param chain [String] blockchain network ("tron" | "arbitrum")
20
+ # @param token [String] stablecoin token ("USDT" | "USDC")
21
+ # @param expires_in_seconds [Integer] checkout TTL in seconds (300..86400, default 1800)
22
+ # @param metadata [Hash, nil] arbitrary key-value pairs (max 20 keys)
23
+ # @param idempotency_key [String, nil] UUID for idempotent requests
24
+ # @return [BillingIO::Checkout]
25
+ # @raise [BillingIO::Error]
26
+ def create(amount_usd:, chain:, token:, expires_in_seconds: 1800, metadata: nil, idempotency_key: nil)
27
+ body = {
28
+ "amount_usd" => amount_usd,
29
+ "chain" => chain,
30
+ "token" => token,
31
+ "expires_in_seconds" => expires_in_seconds
32
+ }
33
+ body["metadata"] = metadata if metadata
34
+
35
+ headers = {}
36
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
37
+
38
+ data = @http.post("/checkouts", body, headers)
39
+ Checkout.from_hash(data)
40
+ end
41
+
42
+ # List checkouts with cursor-based pagination.
43
+ #
44
+ # @param cursor [String, nil] opaque cursor for the next page
45
+ # @param limit [Integer] items per page (1..100, default 25)
46
+ # @param status [String, nil] filter by checkout status
47
+ # @return [BillingIO::PaginatedList<BillingIO::Checkout>]
48
+ # @raise [BillingIO::Error]
49
+ def list(cursor: nil, limit: 25, status: nil)
50
+ params = { limit: limit }
51
+ params[:cursor] = cursor if cursor
52
+ params[:status] = status if status
53
+
54
+ data = @http.get("/checkouts", params)
55
+ PaginatedList.from_hash(data, Checkout)
56
+ end
57
+
58
+ # Retrieve a single checkout by ID.
59
+ #
60
+ # @param checkout_id [String] checkout identifier (prefixed +co_+)
61
+ # @return [BillingIO::Checkout]
62
+ # @raise [BillingIO::Error]
63
+ def get(checkout_id)
64
+ data = @http.get("/checkouts/#{checkout_id}")
65
+ Checkout.from_hash(data)
66
+ end
67
+
68
+ # Retrieve lightweight status for polling.
69
+ #
70
+ # @param checkout_id [String] checkout identifier (prefixed +co_+)
71
+ # @return [BillingIO::CheckoutStatus]
72
+ # @raise [BillingIO::Error]
73
+ def get_status(checkout_id)
74
+ data = @http.get("/checkouts/#{checkout_id}/status")
75
+ CheckoutStatus.from_hash(data)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Provides access to the event history API.
5
+ #
6
+ # client.events.list(type: "checkout.completed")
7
+ # client.events.get("evt_abc123")
8
+ class Events
9
+ # @api private
10
+ def initialize(http_client)
11
+ @http = http_client
12
+ end
13
+
14
+ # List events with cursor-based pagination.
15
+ #
16
+ # @param cursor [String, nil] opaque cursor for the next page
17
+ # @param limit [Integer] items per page (1..100, default 25)
18
+ # @param type [String, nil] filter by event type (e.g. "checkout.completed")
19
+ # @param checkout_id [String, nil] filter by related checkout
20
+ # @return [BillingIO::PaginatedList<BillingIO::Event>]
21
+ # @raise [BillingIO::Error]
22
+ def list(cursor: nil, limit: 25, type: nil, checkout_id: nil)
23
+ params = { limit: limit }
24
+ params[:cursor] = cursor if cursor
25
+ params[:type] = type if type
26
+ params[:checkout_id] = checkout_id if checkout_id
27
+
28
+ data = @http.get("/events", params)
29
+ PaginatedList.from_hash(data, Event)
30
+ end
31
+
32
+ # Retrieve a single event by ID.
33
+ #
34
+ # @param event_id [String] event identifier (prefixed +evt_+)
35
+ # @return [BillingIO::Event]
36
+ # @raise [BillingIO::Error]
37
+ def get(event_id)
38
+ data = @http.get("/events/#{event_id}")
39
+ Event.from_hash(data)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Provides access to the health check endpoint.
5
+ #
6
+ # client.health.get
7
+ class Health
8
+ # @api private
9
+ def initialize(http_client)
10
+ @http = http_client
11
+ end
12
+
13
+ # Check API health.
14
+ #
15
+ # @return [BillingIO::HealthResponse]
16
+ # @raise [BillingIO::Error]
17
+ def get
18
+ data = @http.get("/health")
19
+ HealthResponse.from_hash(data)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Provides access to webhook endpoint management.
5
+ #
6
+ # client.webhooks.create(url: "https://example.com/hook", events: ["checkout.completed"])
7
+ # client.webhooks.list
8
+ # client.webhooks.get("we_abc123")
9
+ # client.webhooks.delete("we_abc123")
10
+ class Webhooks
11
+ # @api private
12
+ def initialize(http_client)
13
+ @http = http_client
14
+ end
15
+
16
+ # Register a new webhook endpoint.
17
+ #
18
+ # @param url [String] HTTPS URL to receive events
19
+ # @param events [Array<String>] event types to subscribe to
20
+ # @param description [String, nil] human-readable label (max 256 chars)
21
+ # @return [BillingIO::WebhookEndpoint] includes the +secret+ field (store it securely)
22
+ # @raise [BillingIO::Error]
23
+ def create(url:, events:, description: nil)
24
+ body = {
25
+ "url" => url,
26
+ "events" => events
27
+ }
28
+ body["description"] = description if description
29
+
30
+ data = @http.post("/webhooks", body)
31
+ WebhookEndpoint.from_hash(data)
32
+ end
33
+
34
+ # List webhook endpoints with cursor-based pagination.
35
+ #
36
+ # @param cursor [String, nil] opaque cursor for the next page
37
+ # @param limit [Integer] items per page (1..100, default 25)
38
+ # @return [BillingIO::PaginatedList<BillingIO::WebhookEndpoint>]
39
+ # @raise [BillingIO::Error]
40
+ def list(cursor: nil, limit: 25)
41
+ params = { limit: limit }
42
+ params[:cursor] = cursor if cursor
43
+
44
+ data = @http.get("/webhooks", params)
45
+ PaginatedList.from_hash(data, WebhookEndpoint)
46
+ end
47
+
48
+ # Retrieve a single webhook endpoint by ID.
49
+ #
50
+ # @param webhook_id [String] webhook endpoint identifier (prefixed +we_+)
51
+ # @return [BillingIO::WebhookEndpoint]
52
+ # @raise [BillingIO::Error]
53
+ def get(webhook_id)
54
+ data = @http.get("/webhooks/#{webhook_id}")
55
+ WebhookEndpoint.from_hash(data)
56
+ end
57
+
58
+ # Delete a webhook endpoint.
59
+ #
60
+ # @param webhook_id [String] webhook endpoint identifier (prefixed +we_+)
61
+ # @return [nil]
62
+ # @raise [BillingIO::Error]
63
+ def delete(webhook_id)
64
+ @http.delete("/webhooks/#{webhook_id}")
65
+ nil
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BillingIO
4
+ # Convenience module matching the public API contract:
5
+ #
6
+ # BillingIO::Webhook.verify_signature(payload:, header:, secret:)
7
+ #
8
+ # Delegates to {BillingIO::WebhookSignature.verify}.
9
+ module Webhook
10
+ module_function
11
+
12
+ # Verify a webhook payload and return the parsed event Hash.
13
+ #
14
+ # @param payload [String] raw request body (unparsed JSON)
15
+ # @param header [String] value of the X-Billing-Signature header
16
+ # @param secret [String] webhook endpoint secret (whsec_...)
17
+ # @param tolerance [Integer] max age of the event in seconds (default 300)
18
+ # @return [Hash] the parsed webhook event
19
+ # @raise [BillingIO::WebhookVerificationError] on any verification failure
20
+ def verify_signature(payload:, header:, secret:, tolerance: WebhookSignature::DEFAULT_TOLERANCE)
21
+ WebhookSignature.verify(
22
+ payload: payload,
23
+ header: header,
24
+ secret: secret,
25
+ tolerance: tolerance
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module BillingIO
7
+ # Verifies webhook signatures sent by billing.io.
8
+ #
9
+ # The +X-Billing-Signature+ header has the format:
10
+ # t={unix_timestamp},v1={hex_hmac_sha256}
11
+ #
12
+ # The signed payload is: "{timestamp}.{raw_body}"
13
+ module WebhookSignature
14
+ # Default tolerance window in seconds (5 minutes).
15
+ DEFAULT_TOLERANCE = 300
16
+
17
+ # Header name used by billing.io for the signature.
18
+ SIGNATURE_HEADER = "X-Billing-Signature"
19
+
20
+ module_function
21
+
22
+ # Verify a webhook payload and return the parsed event Hash.
23
+ #
24
+ # @param payload [String] raw request body (unparsed JSON)
25
+ # @param header [String] value of the X-Billing-Signature header
26
+ # @param secret [String] webhook endpoint secret (whsec_...)
27
+ # @param tolerance [Integer] max age of the event in seconds (default 300)
28
+ # @return [Hash] the parsed webhook event
29
+ # @raise [BillingIO::WebhookVerificationError] on any verification failure
30
+ def verify(payload:, header:, secret:, tolerance: DEFAULT_TOLERANCE)
31
+ raise WebhookVerificationError, "Missing signature header" if header.nil? || header.empty?
32
+ raise WebhookVerificationError, "Missing webhook secret" if secret.nil? || secret.empty?
33
+
34
+ timestamp, signature = parse_header(header)
35
+
36
+ # Timestamp tolerance check
37
+ now = Time.now.to_i
38
+ if (now - timestamp).abs > tolerance
39
+ raise WebhookVerificationError,
40
+ "Timestamp outside tolerance. Event: #{timestamp}, now: #{now}, tolerance: #{tolerance}s"
41
+ end
42
+
43
+ # Compute expected HMAC-SHA256
44
+ signed_payload = "#{timestamp}.#{payload}"
45
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
46
+
47
+ unless secure_compare(expected, signature)
48
+ raise WebhookVerificationError, "Signature mismatch"
49
+ end
50
+
51
+ # Parse and return the event
52
+ begin
53
+ JSON.parse(payload)
54
+ rescue JSON::ParserError
55
+ raise WebhookVerificationError, "Invalid JSON in webhook body"
56
+ end
57
+ end
58
+
59
+ # Parse the "t=...,v1=..." header into [timestamp, signature].
60
+ #
61
+ # @param header [String]
62
+ # @return [Array(Integer, String)]
63
+ # @raise [BillingIO::WebhookVerificationError]
64
+ def parse_header(header)
65
+ parts = {}
66
+ header.split(",").each do |segment|
67
+ key, *rest = segment.split("=")
68
+ parts[key.strip] = rest.join("=").strip
69
+ end
70
+
71
+ timestamp = Integer(parts["t"], 10) rescue nil
72
+ signature = parts["v1"]
73
+
74
+ if timestamp.nil? || signature.nil? || signature.empty?
75
+ raise WebhookVerificationError,
76
+ "Invalid signature header format. Expected: t={timestamp},v1={signature}"
77
+ end
78
+
79
+ [timestamp, signature]
80
+ end
81
+
82
+ # Constant-time string comparison to prevent timing attacks.
83
+ # Uses OpenSSL.fixed_length_secure_compare (available Ruby 2.7+)
84
+ # with a fallback to a manual XOR-based comparison.
85
+ #
86
+ # @param a [String]
87
+ # @param b [String]
88
+ # @return [Boolean]
89
+ def secure_compare(a, b)
90
+ return false unless a.bytesize == b.bytesize
91
+
92
+ if OpenSSL.respond_to?(:fixed_length_secure_compare)
93
+ OpenSSL.fixed_length_secure_compare(a, b)
94
+ else
95
+ a_bytes = a.unpack("C*")
96
+ b_bytes = b.unpack("C*")
97
+
98
+ result = 0
99
+ a_bytes.each_with_index do |byte, i|
100
+ result |= byte ^ b_bytes[i]
101
+ end
102
+
103
+ result.zero?
104
+ end
105
+ end
106
+
107
+ private_class_method :parse_header, :secure_compare
108
+ end
109
+ end
data/lib/billingio.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ require_relative "billingio/version"
6
+ require_relative "billingio/errors"
7
+ require_relative "billingio/http_client"
8
+ require_relative "billingio/webhook_signature"
9
+ require_relative "billingio/webhook"
10
+
11
+ # Models
12
+ require_relative "billingio/models/checkout"
13
+ require_relative "billingio/models/checkout_status"
14
+ require_relative "billingio/models/webhook_endpoint"
15
+ require_relative "billingio/models/event"
16
+ require_relative "billingio/models/health_response"
17
+ require_relative "billingio/models/paginated_list"
18
+
19
+ # Resources
20
+ require_relative "billingio/resources/checkouts"
21
+ require_relative "billingio/resources/webhooks"
22
+ require_relative "billingio/resources/events"
23
+ require_relative "billingio/resources/health"
24
+
25
+ require_relative "billingio/client"
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: billingio
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - billing.io
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-01-30 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Official Ruby client for billing.io -- non-custodial crypto payment checkouts
14
+ with stablecoin settlement. Create checkouts, manage webhooks, verify signatures,
15
+ and query event history.
16
+ email:
17
+ - support@billing.io
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - Gemfile
23
+ - README.md
24
+ - billingio.gemspec
25
+ - lib/billingio.rb
26
+ - lib/billingio/client.rb
27
+ - lib/billingio/errors.rb
28
+ - lib/billingio/http_client.rb
29
+ - lib/billingio/models/checkout.rb
30
+ - lib/billingio/models/checkout_status.rb
31
+ - lib/billingio/models/event.rb
32
+ - lib/billingio/models/health_response.rb
33
+ - lib/billingio/models/paginated_list.rb
34
+ - lib/billingio/models/webhook_endpoint.rb
35
+ - lib/billingio/resources/checkouts.rb
36
+ - lib/billingio/resources/events.rb
37
+ - lib/billingio/resources/health.rb
38
+ - lib/billingio/resources/webhooks.rb
39
+ - lib/billingio/version.rb
40
+ - lib/billingio/webhook.rb
41
+ - lib/billingio/webhook_signature.rb
42
+ homepage: https://github.com/billingio/billingio-ruby
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ homepage_uri: https://github.com/billingio/billingio-ruby
47
+ source_code_uri: https://github.com/billingio/billingio-ruby
48
+ changelog_uri: https://github.com/billingio/billingio-ruby/blob/main/CHANGELOG.md
49
+ documentation_uri: https://docs.billing.io
50
+ bug_tracker_uri: https://github.com/billingio/billingio-ruby/issues
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.0.3.1
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: Ruby SDK for the billing.io crypto checkout API
70
+ test_files: []