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 +7 -0
- data/Gemfile +12 -0
- data/README.md +267 -0
- data/billingio.gemspec +32 -0
- data/lib/billingio/client.rb +44 -0
- data/lib/billingio/errors.rb +43 -0
- data/lib/billingio/http_client.rb +96 -0
- data/lib/billingio/models/checkout.rb +57 -0
- data/lib/billingio/models/checkout_status.rb +48 -0
- data/lib/billingio/models/event.rb +48 -0
- data/lib/billingio/models/health_response.rb +38 -0
- data/lib/billingio/models/paginated_list.rb +57 -0
- data/lib/billingio/models/webhook_endpoint.rb +46 -0
- data/lib/billingio/resources/checkouts.rb +78 -0
- data/lib/billingio/resources/events.rb +42 -0
- data/lib/billingio/resources/health.rb +22 -0
- data/lib/billingio/resources/webhooks.rb +68 -0
- data/lib/billingio/version.rb +5 -0
- data/lib/billingio/webhook.rb +29 -0
- data/lib/billingio/webhook_signature.rb +109 -0
- data/lib/billingio.rb +25 -0
- metadata +70 -0
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
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,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: []
|