invoq 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aace348d9c04cd5a80e760fe140730067aa3202152e203ae5541549ced1b73b4
4
- data.tar.gz: ff2d61b5b9522f6f9ec2dcc194b89367773db4973aa9b094981a3d276ef4015d
3
+ metadata.gz: 11bbb19ba09943897da6097ebf8d655bf8a658d4b3cf081eb3c5ccd177b2f6b3
4
+ data.tar.gz: 91fb9ffc921bdb61f622d615a05a0d87efb6cb853db8b6e60eb3fc9007f7669f
5
5
  SHA512:
6
- metadata.gz: 8b7465558d3d32639a49cdfb1fa5a2a043ad154b1138d487afdee592706d8d4d8d84725354308397ff519724b961bd4f06e5c408f249486c5ac95ebda6de99d5
7
- data.tar.gz: 8c6e47ed6497d8ebef8a9713a9bd58dc2673368eec45a1e0c9de103790366ed8c1e33ebd80c58f8ae6543dab0ffe48d005c3f27e2889a00a27fd1b9cccd8b935
6
+ metadata.gz: 066dd20b88afcdc0e1f5af02d3cf35a2bcbb1b2af19b96ea7cd87e2d8c2d88cb66582d5c4357853fc95543aa598ded42f3265561c057e2e997b2c390300c2ba6
7
+ data.tar.gz: 850c9c77ca95c4b1df54a5c90fcc17cfd130f04c899562445852fdeb9a526d8ce5b7a2d444fc3bcc770c32827dac9c808314556c4fac3a9f394481f721638bd6
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
- MIT License
1
+ MIT license
2
2
 
3
- Copyright (c) 2026 Invoq
3
+ Copyright (c) 2026 invoq
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # Invoq Ruby SDK
1
+ # invoq Ruby SDK
2
2
 
3
- Invoq is a stablecoin payment acceptance platform.
3
+ Accept stablecoin payments with invoq from Ruby server code. This SDK wraps
4
+ invoq server APIs and verifies signed webhooks.
4
5
 
5
- This gem is an early placeholder release for the future Invoq Ruby SDK.
6
+ Use this gem only on your server. It handles secret keys and should not be
7
+ bundled into browser code.
6
8
 
7
9
  ## Installation
8
10
 
@@ -18,12 +20,276 @@ Or add it to a Gemfile:
18
20
  gem "invoq"
19
21
  ```
20
22
 
21
- ## Usage
23
+ Requires Ruby 2.6 or newer.
24
+
25
+ ## Get your keys
26
+
27
+ 1. Sign in to the [invoq dashboard](https://app.invoq.money) and create a
28
+ project.
29
+ 2. On the **API keys** page, create a secret key. Test keys start with
30
+ `sk_test_`, live keys with `sk_live_`. The key mode determines whether
31
+ invoices are test or live.
32
+ 3. In your project's **webhooks** settings, save your webhook URL. The webhook
33
+ secret (`whsec_...`) for that mode is shown once, when you first enable the
34
+ webhook. Store it right away. Webhook URLs must be public HTTPS URLs.
35
+
36
+ Add both to your server environment:
37
+
38
+ ```sh
39
+ INVOQ_SECRET_KEY=sk_test_...
40
+ INVOQ_WEBHOOK_SECRET=whsec_...
41
+ ```
42
+
43
+ Start with test keys. Switch to the live key and live webhook secret when you
44
+ go to production.
45
+
46
+ ## Create a client
22
47
 
23
48
  ```ruby
24
49
  require "invoq"
25
50
 
26
- Invoq::VERSION
51
+ invoq = Invoq.new(ENV.fetch("INVOQ_SECRET_KEY"))
52
+ ```
53
+
54
+ Default production API origin:
55
+
56
+ ```txt
57
+ https://api.invoq.money
58
+ ```
59
+
60
+ Override the API origin and request timeout during development:
61
+
62
+ ```ruby
63
+ invoq = Invoq.new(
64
+ ENV.fetch("INVOQ_SECRET_KEY"),
65
+ api_origin: "http://localhost:8787",
66
+ timeout_ms: 10_000
67
+ )
68
+ ```
69
+
70
+ `api_origin` must be an absolute `http` or `https` origin with no path, query,
71
+ hash, username, or password. The SDK appends `/v1/...` resource paths.
72
+
73
+ Requests time out after 10 seconds by default. Pass `timeout_ms` to change the
74
+ timeout. `timeout_ms` must be a positive integer in milliseconds.
75
+
76
+ ## Invoices
77
+
78
+ Create an invoice:
79
+
80
+ ```ruby
81
+ invoice = invoq.invoices.create(
82
+ amount: "129",
83
+ currency: "USD",
84
+ description: "SaaS boilerplate",
85
+ reference_id: "order_1234",
86
+ return_url: "https://merchant.example/thanks"
87
+ )
88
+
89
+ invoice_id = invoice.fetch("id")
90
+ checkout_url = "https://pay.invoq.money/#{invoice_id}"
91
+ ```
92
+
93
+ Notes:
94
+
95
+ - Use a server-side amount. Do not trust client-supplied amounts.
96
+ - `amount` is a decimal USD string from `"0.01"` to `"999.99"` with up
97
+ to 2 decimal places, such as `"129"` or `"129.99"`.
98
+ - `currency` is optional and defaults to `"USD"`.
99
+ - Use a stable, non-empty `reference_id` to map `invoice.paid` webhooks back to
100
+ your order. Creating again with the same `reference_id` and invoice terms
101
+ returns the existing invoice; different terms fail with a
102
+ `409 reference_id_conflict` API error.
103
+ - If you fulfill by invoice ID instead of `reference_id`, store `invoice_id`
104
+ with your order when you create the invoice.
105
+ - Omit `return_url` to use the project's default return URL. Pass `nil` to send
106
+ JSON `null` and create the invoice without a return URL. On `reference_id`
107
+ retries, pass `return_url` explicitly when you need to assert a specific
108
+ value.
109
+ - `description` and `reference_id` must be strings when present.
110
+
111
+ Get an invoice:
112
+
113
+ ```ruby
114
+ invoice = invoq.invoices.get("inv_123")
115
+ ```
116
+
117
+ `invoices.get` returns the public invoice shape used by hosted checkout. It
118
+ includes fields such as `amount_paid`, `amount_due`, `payment_status`,
119
+ `project`, `deposit_address`, `monitoring_ends_at`, and `direct_onchain_rails`,
120
+ but does not include `reference_id`. Use the create response or
121
+ `invoice.paid` webhook when you need your merchant `reference_id`.
122
+
123
+ Create a test payment:
124
+
125
+ ```ruby
126
+ paid_invoice = invoq.invoices.create_test_payment(
127
+ "inv_123",
128
+ amount: "129",
129
+ reference_id: "test_payment_001"
130
+ )
131
+ ```
132
+
133
+ Test invoices cannot receive real funds. Simulate payments from your server
134
+ instead.
135
+
136
+ `create_test_payment` only works for invoices created with a `sk_test_` key.
137
+ Partial amounts are allowed and produce `partially_paid`; when payments reach
138
+ the invoice amount, invoq sends a signed `invoice.paid` webhook to your test
139
+ webhook URL.
140
+
141
+ `reference_id` is optional for test payments. Omit it when unset; do not pass
142
+ `nil`.
143
+
144
+ To receive webhooks on your machine, expose your local server with an HTTPS
145
+ tunnel such as ngrok or cloudflared and save the tunnel URL as your test webhook
146
+ URL in the dashboard. The dashboard can also send a signed `webhook.ping` to
147
+ check connectivity.
148
+
149
+ Each invoice method returns the response `data` object directly as a Ruby hash.
150
+
151
+ ## Hosted checkout page
152
+
153
+ Every invoice also has a hosted checkout page at:
154
+
155
+ ```txt
156
+ https://pay.invoq.money/<invoice id>
157
+ ```
158
+
159
+ Share the link or redirect to it when an in-page checkout modal is not a fit.
160
+
161
+ ## Inputs and responses
162
+
163
+ The SDK checks that `amount` values and `invoice_id` arguments are non-empty
164
+ strings before sending requests. The invoq API validates the amount format,
165
+ range, and currency.
166
+
167
+ Leave unset optional fields out of the request hash. When you include
168
+ `description` or `reference_id`, pass a string. `return_url` can be a string or
169
+ `nil`.
170
+
171
+ Amounts in responses are normalized to 4 decimal places: create with `"129"`
172
+ and the invoice returns `amount: "129.0000"`. Compare amounts numerically, not
173
+ as strings. `amount_due` is derived as `max(amount - amount_paid, 0)` and uses
174
+ the same 18-decimal scale as `amount_paid`.
175
+
176
+ ## Webhooks
177
+
178
+ Pass the raw request body to `verify_webhook`. Do not parse JSON and encode it
179
+ again before verification.
180
+
181
+ This Rack example returns `[status, headers, body]`. In Rails, use
182
+ `request.raw_post` and `request.get_header("HTTP_INVOQ_SIGNATURE")`; in Sinatra
183
+ or another Ruby framework, use the framework's raw request body and exposed
184
+ `invoq-signature` or `HTTP_INVOQ_SIGNATURE` header.
185
+
186
+ ```ruby
187
+ def handle_invoq_webhook(env)
188
+ raw_body = env.fetch("rack.input").read
189
+
190
+ begin
191
+ event = Invoq.verify_webhook(
192
+ raw_body,
193
+ { "invoq-signature" => env["HTTP_INVOQ_SIGNATURE"] },
194
+ ENV.fetch("INVOQ_WEBHOOK_SECRET")
195
+ )
196
+ rescue Invoq::SignatureVerificationError
197
+ return [
198
+ 400,
199
+ { "content-type" => "application/json" },
200
+ ['{"error":"invalid signature"}']
201
+ ]
202
+ end
203
+
204
+ if Invoq.invoice_paid?(event)
205
+ invoice = event.fetch("data").fetch("invoice")
206
+ fulfillment_key = invoice["reference_id"] || invoice.fetch("id")
207
+
208
+ # Fulfill the order for fulfillment_key idempotently.
209
+ end
210
+
211
+ [
212
+ 200,
213
+ { "content-type" => "application/json" },
214
+ ['{"received":true}']
215
+ ]
216
+ end
217
+ ```
218
+
219
+ Use `invoice.paid` webhooks to fulfill orders on your server. Browser checkout
220
+ results are only for updating the customer experience; do not fulfill orders
221
+ from browser results.
222
+
223
+ When `Invoq.invoice_paid?(event)` is true, the invoice is ready for automatic
224
+ fulfillment; use the invoice `reference_id` or a stored invoice `id` to find
225
+ and fulfill your order. A `review_required` invoice does not emit an
226
+ `invoice.paid` webhook yet. If checkout reports `review_required`, show a
227
+ pending-review state and wait for a later `invoice.paid` webhook after review
228
+ is approved.
229
+
230
+ Important:
231
+
232
+ - Pass the exact raw request body string received by your Ruby framework.
233
+ - Pass the `invoq-signature` header.
234
+ - `verify_webhook` does not require `Invoq.new(...)` or your invoq API secret
235
+ key.
236
+ - Use your webhook secret (`whsec_...`), not `INVOQ_SECRET_KEY`.
237
+ - Make fulfillment idempotent. Retried webhook deliveries can send the same
238
+ event more than once.
239
+ - Respond with a 2xx quickly. Any other status counts as a failed delivery.
240
+ Transient failures such as timeouts, `429`, and `5xx` responses are retried;
241
+ other `4xx` responses are not.
242
+
243
+ `Invoq.invoice_paid?` accepts fulfillable `invoice.paid` events whose invoice
244
+ status is `paid`, `settling`, or `settled`; it rejects `review_required`.
245
+
246
+ Webhook verification failures raise `Invoq::SignatureVerificationError`. The
247
+ SDK allows a 5-minute timestamp tolerance. The signature header is:
248
+
249
+ ```txt
250
+ invoq-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
251
+ ```
252
+
253
+ ## Errors
254
+
255
+ ```ruby
256
+ begin
257
+ invoq.invoices.create(amount: "0.001", currency: "USD")
258
+ rescue Invoq::ApiError => error
259
+ warn error.status
260
+ warn error.code
261
+ warn error.fields
262
+ warn error.meta
263
+ warn error.payload
264
+ rescue Invoq::Error
265
+ raise
266
+ end
27
267
  ```
28
268
 
29
- The public SDK API is not available yet.
269
+ Non-2xx API responses raise `Invoq::ApiError` with `status`, `code`, `fields`,
270
+ `meta`, and the raw `payload`.
271
+
272
+ Connection failures, timeouts, invalid input, and response parse failures raise
273
+ `Invoq::Error`. Timed-out invoice creation is safe to retry with the same
274
+ `reference_id`.
275
+
276
+ Webhook verification failures raise `Invoq::SignatureVerificationError` with one
277
+ of these codes:
278
+
279
+ ```txt
280
+ missing_signature
281
+ invalid_signature_header
282
+ timestamp_outside_tolerance
283
+ signature_mismatch
284
+ invalid_payload
285
+ ```
286
+
287
+ ## Development
288
+
289
+ ```sh
290
+ bundle exec rake test
291
+ ```
292
+
293
+ ## License
294
+
295
+ Licensed under the MIT license.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "errors"
6
+ require_relative "invoices_resource"
7
+
8
+ module Invoq
9
+ DEFAULT_API_ORIGIN = "https://api.invoq.money"
10
+ DEFAULT_TIMEOUT_MS = 10_000
11
+ MAX_TIMEOUT_MS = 4_294_967_295
12
+
13
+ class Client
14
+ attr_reader :invoices
15
+
16
+ def initialize(api_key, api_origin: DEFAULT_API_ORIGIN, timeout_ms: DEFAULT_TIMEOUT_MS)
17
+ unless api_key.is_a?(String) && !api_key.strip.empty?
18
+ raise Error, "invoq API key must be a non-empty string."
19
+ end
20
+
21
+ @api_key = api_key
22
+ @api_origin = Invoq.normalize_api_origin(api_origin)
23
+ @timeout_ms = Invoq.normalize_timeout_ms(timeout_ms)
24
+ @invoices = InvoicesResource.new(
25
+ api_key: @api_key,
26
+ api_origin: @api_origin,
27
+ timeout_ms: @timeout_ms
28
+ )
29
+ end
30
+
31
+ def inspect
32
+ "#<#{self.class} api_origin=#{@api_origin.inspect} timeout_ms=#{@timeout_ms.inspect}>"
33
+ end
34
+ end
35
+
36
+ def self.normalize_api_origin(value)
37
+ unless value.is_a?(String)
38
+ raise Error, "api_origin must be an absolute http or https origin."
39
+ end
40
+
41
+ uri = URI.parse(value)
42
+
43
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
44
+ raise Error, "api_origin must be an absolute http or https origin."
45
+ end
46
+
47
+ unless uri.scheme == "http" || uri.scheme == "https"
48
+ raise Error, "api_origin must be an absolute http or https origin."
49
+ end
50
+
51
+ if uri.user || uri.password
52
+ raise Error, "api_origin must be an absolute http or https origin."
53
+ end
54
+
55
+ if uri.port < 1 || uri.port > 65_535
56
+ raise Error, "api_origin must be an absolute http or https origin."
57
+ end
58
+
59
+ if uri.query || uri.fragment
60
+ raise Error, "api_origin must not include query or hash parts."
61
+ end
62
+
63
+ pathname = uri.path.to_s.sub(%r{/+\z}, "")
64
+ pathname = "/" if pathname.empty?
65
+
66
+ unless pathname == "/"
67
+ raise Error, "api_origin must not include a path."
68
+ end
69
+
70
+ uri.path = "/"
71
+ uri.query = nil
72
+ uri.fragment = nil
73
+ uri.to_s
74
+ rescue URI::InvalidURIError
75
+ raise Error, "api_origin must be an absolute http or https origin."
76
+ end
77
+
78
+ def self.normalize_timeout_ms(value)
79
+ unless value.is_a?(Integer) && value.positive? && value <= MAX_TIMEOUT_MS
80
+ raise Error, "timeout_ms must be a positive integer of at most 4294967295."
81
+ end
82
+
83
+ value
84
+ end
85
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoq
4
+ class Error < StandardError
5
+ attr_reader :payload
6
+
7
+ def initialize(message = nil, payload: nil)
8
+ super(message)
9
+ @payload = payload
10
+ end
11
+ end
12
+
13
+ class ApiError < Error
14
+ attr_reader :status, :code, :fields, :meta
15
+
16
+ def initialize(message, status:, code: nil, fields: nil, meta: nil, payload: nil)
17
+ super(message, payload: payload)
18
+ @status = status
19
+ @code = code
20
+ @fields = fields
21
+ @meta = meta
22
+ end
23
+ end
24
+
25
+ class SignatureVerificationError < Error
26
+ attr_reader :code
27
+
28
+ def initialize(code, message)
29
+ super(message)
30
+ @code = code
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ require_relative "../errors"
9
+ require_relative "../version"
10
+
11
+ module Invoq
12
+ module Internal
13
+ class Request
14
+ NO_BODY = Object.new
15
+ USER_AGENT = "invoq-ruby/#{Invoq::VERSION}"
16
+
17
+ def self.json(api_key:, api_origin:, timeout_ms:, path:, method: "POST", body: NO_BODY)
18
+ url = URI.parse(api_origin + path.sub(%r{\A/+}, ""))
19
+ request = build_request(url, method)
20
+ request["Accept"] = "application/json"
21
+ request["Authorization"] = "Bearer #{api_key}"
22
+ request["User-Agent"] = USER_AGENT
23
+
24
+ unless body.equal?(NO_BODY)
25
+ request.body = JSON.generate(body)
26
+ request["Content-Type"] = "application/json"
27
+ end
28
+
29
+ response = perform_request(url, request, timeout_ms)
30
+ status = response.code.to_i
31
+ response_text = response.body.to_s
32
+
33
+ begin
34
+ payload = JSON.parse(response_text)
35
+ rescue JSON::ParserError
36
+ if status < 200 || status >= 300
37
+ raise api_error_from_response(status, response_text)
38
+ end
39
+
40
+ raise Error, "Failed to parse invoq API response."
41
+ end
42
+
43
+ if status < 200 || status >= 300
44
+ raise api_error_from_response(status, payload)
45
+ end
46
+
47
+ unless payload.is_a?(Hash) && payload.key?("data")
48
+ raise Error.new(
49
+ "invoq API response did not include a data envelope.",
50
+ payload: payload
51
+ )
52
+ end
53
+
54
+ payload["data"]
55
+ rescue JSON::GeneratorError
56
+ raise Error, "Failed to encode invoq API request."
57
+ end
58
+
59
+ def self.build_request(url, method)
60
+ case method
61
+ when "GET"
62
+ Net::HTTP::Get.new(url)
63
+ when "POST"
64
+ Net::HTTP::Post.new(url)
65
+ else
66
+ raise Error, "Unsupported invoq API request method."
67
+ end
68
+ end
69
+ private_class_method :build_request
70
+
71
+ def self.perform_request(url, request, timeout_ms)
72
+ timeout_seconds = timeout_ms / 1000.0
73
+
74
+ Timeout.timeout(timeout_seconds) do
75
+ Net::HTTP.start(
76
+ url.host,
77
+ url.port,
78
+ use_ssl: url.scheme == "https",
79
+ open_timeout: timeout_seconds,
80
+ read_timeout: timeout_seconds,
81
+ write_timeout: timeout_seconds
82
+ ) do |http|
83
+ http.request(request)
84
+ end
85
+ end
86
+ rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
87
+ raise Error, "invoq API request timed out."
88
+ rescue Error
89
+ raise
90
+ rescue StandardError
91
+ raise Error, "Failed to connect to invoq API."
92
+ end
93
+ private_class_method :perform_request
94
+
95
+ def self.api_error_from_response(status, payload)
96
+ error = payload.is_a?(Hash) ? payload : nil
97
+ code = error && error["code"].is_a?(String) ? error["code"] : nil
98
+ message = error && error["message"].is_a?(String) ? error["message"] : "invoq API request failed."
99
+ fields = parse_fields(error && error["fields"])
100
+ meta = error && error["meta"].is_a?(Hash) ? error["meta"] : nil
101
+
102
+ ApiError.new(
103
+ message,
104
+ status: status,
105
+ code: code,
106
+ fields: fields,
107
+ meta: meta,
108
+ payload: payload
109
+ )
110
+ end
111
+ private_class_method :api_error_from_response
112
+
113
+ def self.parse_fields(value)
114
+ return nil unless value.is_a?(Array)
115
+
116
+ fields = value.each_with_object([]) do |field, result|
117
+ next unless field.is_a?(Hash)
118
+
119
+ location = field["location"]
120
+ next unless %w[query path body header].include?(location)
121
+ next unless field["field"].is_a?(String)
122
+ next unless field["code"].is_a?(String)
123
+ next unless field["message"].is_a?(String)
124
+
125
+ result << {
126
+ "field" => field["field"],
127
+ "location" => location,
128
+ "code" => field["code"],
129
+ "message" => field["message"]
130
+ }
131
+ end
132
+
133
+ fields
134
+ end
135
+ private_class_method :parse_fields
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "internal/request"
5
+
6
+ module Invoq
7
+ class InvoicesResource
8
+ MISSING = Object.new
9
+
10
+ def initialize(api_key:, api_origin:, timeout_ms:)
11
+ @api_key = api_key
12
+ @api_origin = api_origin
13
+ @timeout_ms = timeout_ms
14
+ end
15
+
16
+ def create(input)
17
+ Internal::Request.json(
18
+ api_key: @api_key,
19
+ api_origin: @api_origin,
20
+ timeout_ms: @timeout_ms,
21
+ path: "/v1/invoices",
22
+ body: create_invoice_request_body(input)
23
+ )
24
+ end
25
+
26
+ def get(invoice_id)
27
+ id = encode_path_segment(required_request_string(invoice_id, "invoice_id"))
28
+
29
+ Internal::Request.json(
30
+ api_key: @api_key,
31
+ api_origin: @api_origin,
32
+ timeout_ms: @timeout_ms,
33
+ method: "GET",
34
+ path: "/v1/invoices/#{id}"
35
+ )
36
+ end
37
+
38
+ def create_test_payment(invoice_id, input)
39
+ id = encode_path_segment(required_request_string(invoice_id, "invoice_id"))
40
+
41
+ Internal::Request.json(
42
+ api_key: @api_key,
43
+ api_origin: @api_origin,
44
+ timeout_ms: @timeout_ms,
45
+ path: "/v1/invoices/#{id}/test-payments",
46
+ body: create_test_payment_request_body(input)
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def create_invoice_request_body(input)
53
+ unless input.is_a?(Hash)
54
+ raise Error, "request body must be a hash."
55
+ end
56
+
57
+ body = {
58
+ "amount" => required_request_string(input_value(input, "amount"), "amount")
59
+ }
60
+ currency = optional_request_value(input, "currency")
61
+ description = optional_request_string(input, "description")
62
+ reference_id = optional_request_string(input, "reference_id")
63
+ return_url = optional_nullable_request_string(input, "return_url")
64
+
65
+ body["currency"] = currency unless currency.equal?(MISSING)
66
+ body["description"] = description unless description.equal?(MISSING)
67
+ body["reference_id"] = reference_id unless reference_id.equal?(MISSING)
68
+ body["return_url"] = return_url unless return_url.equal?(MISSING)
69
+
70
+ body
71
+ end
72
+
73
+ def create_test_payment_request_body(input)
74
+ unless input.is_a?(Hash)
75
+ raise Error, "request body must be a hash."
76
+ end
77
+
78
+ body = {
79
+ "amount" => required_request_string(input_value(input, "amount"), "amount")
80
+ }
81
+ reference_id = optional_request_string(input, "reference_id")
82
+
83
+ body["reference_id"] = reference_id unless reference_id.equal?(MISSING)
84
+
85
+ body
86
+ end
87
+
88
+ def optional_request_string(input, field)
89
+ value = optional_request_value(input, field)
90
+ return MISSING if value.equal?(MISSING)
91
+
92
+ unless value.is_a?(String)
93
+ raise Error, "#{field} must be a string when provided."
94
+ end
95
+
96
+ value
97
+ end
98
+
99
+ def optional_nullable_request_string(input, field)
100
+ value = optional_request_value(input, field)
101
+ return MISSING if value.equal?(MISSING)
102
+
103
+ unless value.nil? || value.is_a?(String)
104
+ raise Error, "#{field} must be a string or nil when provided."
105
+ end
106
+
107
+ value
108
+ end
109
+
110
+ def required_request_string(value, field)
111
+ unless value.is_a?(String) && !value.strip.empty?
112
+ raise Error, "#{field} must be a non-empty string."
113
+ end
114
+
115
+ value
116
+ end
117
+
118
+ def optional_request_value(input, field)
119
+ if input.key?(field)
120
+ input[field]
121
+ elsif input.key?(field.to_sym)
122
+ input[field.to_sym]
123
+ else
124
+ MISSING
125
+ end
126
+ end
127
+
128
+ def input_value(input, field)
129
+ return input[field] if input.key?(field)
130
+
131
+ input[field.to_sym]
132
+ end
133
+
134
+ def encode_path_segment(value)
135
+ value.encode("UTF-8").bytes.map do |byte|
136
+ if unescaped_path_byte?(byte)
137
+ byte.chr
138
+ else
139
+ format("%%%02X", byte)
140
+ end
141
+ end.join
142
+ end
143
+
144
+ def unescaped_path_byte?(byte)
145
+ case byte
146
+ when 65..90, 97..122, 48..57, 45, 95, 46, 33, 126, 42, 39, 40, 41
147
+ true
148
+ else
149
+ false
150
+ end
151
+ end
152
+ end
153
+ end
data/lib/invoq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invoq
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ require_relative "errors"
7
+
8
+ module Invoq
9
+ module Webhooks
10
+ DEFAULT_TOLERANCE_SECONDS = 300
11
+ SIGNATURE_PATTERN = /\A[a-f0-9]{64}\z/i
12
+ MISSING = Object.new
13
+
14
+ def self.verify_webhook(raw_body, headers, webhook_secret)
15
+ signature_header = get_signature_header(headers)
16
+
17
+ if signature_header.nil? || signature_header.empty?
18
+ raise signature_error("missing_signature", "Missing invoq-signature header.")
19
+ end
20
+
21
+ unless webhook_secret.is_a?(String) && !webhook_secret.empty?
22
+ raise signature_error(
23
+ "invalid_signature_header",
24
+ "Webhook secret must be a non-empty string."
25
+ )
26
+ end
27
+
28
+ timestamp, timestamp_seconds, signature = parse_signature_header(signature_header)
29
+ now_seconds = Time.now.to_i
30
+
31
+ if (now_seconds - timestamp_seconds).abs > DEFAULT_TOLERANCE_SECONDS
32
+ raise signature_error(
33
+ "timestamp_outside_tolerance",
34
+ "Webhook timestamp is outside the allowed tolerance."
35
+ )
36
+ end
37
+
38
+ expected_signature = hmac_sha256_hex(webhook_secret, timestamp, raw_body)
39
+
40
+ unless constant_time_equal?(expected_signature, signature)
41
+ raise signature_error("signature_mismatch", "Webhook signature mismatch.")
42
+ end
43
+
44
+ payload = JSON.parse(body_text(raw_body))
45
+
46
+ unless payload.is_a?(Hash) && payload["type"].is_a?(String)
47
+ raise signature_error(
48
+ "invalid_payload",
49
+ "Webhook payload must be an object with a string type."
50
+ )
51
+ end
52
+
53
+ payload
54
+ rescue JSON::ParserError
55
+ raise signature_error("invalid_payload", "Webhook payload is not valid JSON.")
56
+ end
57
+
58
+ def self.invoice_paid?(event)
59
+ return false unless event.is_a?(Hash)
60
+ return false unless event["type"] == "invoice.paid"
61
+ return false unless event["id"].is_a?(String)
62
+ return false unless invoice_mode?(event["mode"])
63
+ return false unless event["created_at"].is_a?(String)
64
+
65
+ data = event["data"]
66
+ return false unless data.is_a?(Hash)
67
+
68
+ invoice = data["invoice"]
69
+ return false unless invoice.is_a?(Hash)
70
+
71
+ reference_id = invoice.key?("reference_id") ? invoice["reference_id"] : MISSING
72
+ fully_paid_at = invoice.key?("fully_paid_at") ? invoice["fully_paid_at"] : MISSING
73
+
74
+ invoice["id"].is_a?(String) &&
75
+ invoice_mode?(invoice["mode"]) &&
76
+ invoice_paid_status?(invoice["status"]) &&
77
+ invoice["amount"].is_a?(String) &&
78
+ invoice["currency"] == "USD" &&
79
+ invoice["amount_paid"].is_a?(String) &&
80
+ (reference_id.is_a?(String) || reference_id.nil?) &&
81
+ (fully_paid_at.is_a?(String) || fully_paid_at.nil?)
82
+ end
83
+
84
+ def self.is_invoice_paid(event)
85
+ invoice_paid?(event)
86
+ end
87
+
88
+ def self.parse_signature_header(signature_header)
89
+ parts = {}
90
+
91
+ signature_header.split(",").each do |part|
92
+ separator_index = part.index("=")
93
+
94
+ unless separator_index
95
+ raise signature_error(
96
+ "invalid_signature_header",
97
+ "Invalid invoq-signature header."
98
+ )
99
+ end
100
+
101
+ key = part[0...separator_index].strip
102
+ value = part[(separator_index + 1)..-1].to_s.strip
103
+ next if key.empty? || value.empty?
104
+
105
+ parts[key] = value
106
+ end
107
+
108
+ timestamp = parts["t"]
109
+ signature = parts["v1"]
110
+
111
+ unless timestamp && signature && timestamp.match?(/\A\d+\z/)
112
+ raise signature_error(
113
+ "invalid_signature_header",
114
+ "Invalid invoq-signature header."
115
+ )
116
+ end
117
+
118
+ unless signature.match?(SIGNATURE_PATTERN)
119
+ raise signature_error(
120
+ "invalid_signature_header",
121
+ "Invalid invoq-signature signature."
122
+ )
123
+ end
124
+
125
+ [timestamp, timestamp.to_i, signature.downcase]
126
+ end
127
+ private_class_method :parse_signature_header
128
+
129
+ def self.get_signature_header(headers)
130
+ return nil if headers.nil?
131
+ return nil unless headers.respond_to?(:each)
132
+
133
+ headers.each do |key, value|
134
+ next unless key.to_s.downcase == "invoq-signature"
135
+
136
+ return nil if value.nil?
137
+ return value.map(&:to_s).join(",") if value.is_a?(Array)
138
+
139
+ return value.to_s
140
+ end
141
+
142
+ nil
143
+ end
144
+ private_class_method :get_signature_header
145
+
146
+ def self.hmac_sha256_hex(secret, timestamp, raw_body)
147
+ OpenSSL::HMAC.hexdigest(
148
+ "SHA256",
149
+ secret.encode("UTF-8"),
150
+ "#{timestamp}.".b + body_bytes(raw_body)
151
+ )
152
+ end
153
+ private_class_method :hmac_sha256_hex
154
+
155
+ def self.body_text(raw_body)
156
+ body_bytes(raw_body).force_encoding("UTF-8")
157
+ end
158
+ private_class_method :body_text
159
+
160
+ def self.body_bytes(raw_body)
161
+ unless raw_body.is_a?(String)
162
+ raise signature_error("invalid_payload", "Webhook payload is not valid JSON.")
163
+ end
164
+
165
+ raw_body.b
166
+ end
167
+ private_class_method :body_bytes
168
+
169
+ def self.constant_time_equal?(left, right)
170
+ left_bytes = left.b.bytes
171
+ right_bytes = right.b.bytes
172
+ max_length = [left_bytes.length, right_bytes.length, 1].max
173
+ result = left_bytes.length ^ right_bytes.length
174
+
175
+ max_length.times do |index|
176
+ result |= (left_bytes[index] || 0) ^ (right_bytes[index] || 0)
177
+ end
178
+
179
+ result.zero?
180
+ end
181
+ private_class_method :constant_time_equal?
182
+
183
+ def self.invoice_mode?(value)
184
+ value == "test" || value == "live"
185
+ end
186
+ private_class_method :invoice_mode?
187
+
188
+ def self.invoice_paid_status?(value)
189
+ value == "paid" || value == "settling" || value == "settled"
190
+ end
191
+ private_class_method :invoice_paid_status?
192
+
193
+ def self.signature_error(code, message)
194
+ SignatureVerificationError.new(code, message)
195
+ end
196
+ private_class_method :signature_error
197
+ end
198
+ end
data/lib/invoq.rb CHANGED
@@ -1,7 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "invoq/version"
4
+ require_relative "invoq/errors"
5
+ require_relative "invoq/client"
6
+ require_relative "invoq/webhooks"
4
7
 
5
8
  module Invoq
6
- class Error < StandardError; end
9
+ def self.new(api_key, api_origin: DEFAULT_API_ORIGIN, timeout_ms: DEFAULT_TIMEOUT_MS)
10
+ Client.new(api_key, api_origin: api_origin, timeout_ms: timeout_ms)
11
+ end
12
+
13
+ def self.verify_webhook(raw_body, headers, webhook_secret)
14
+ Webhooks.verify_webhook(raw_body, headers, webhook_secret)
15
+ end
16
+
17
+ def self.invoice_paid?(event)
18
+ Webhooks.invoice_paid?(event)
19
+ end
20
+
21
+ def self.is_invoice_paid(event)
22
+ invoice_paid?(event)
23
+ end
7
24
  end
metadata CHANGED
@@ -1,16 +1,50 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: invoq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - Invoq
7
+ - invoq
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-07 00:00:00.000000000 Z
12
- dependencies: []
13
- description: An early placeholder release for the future Invoq Ruby SDK.
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '14.0'
37
+ type: :development
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '10.0'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '14.0'
47
+ description: Ruby SDK for invoq server APIs and webhook verification.
14
48
  email:
15
49
  executables: []
16
50
  extensions: []
@@ -19,7 +53,12 @@ files:
19
53
  - LICENSE.txt
20
54
  - README.md
21
55
  - lib/invoq.rb
56
+ - lib/invoq/client.rb
57
+ - lib/invoq/errors.rb
58
+ - lib/invoq/internal/request.rb
59
+ - lib/invoq/invoices_resource.rb
22
60
  - lib/invoq/version.rb
61
+ - lib/invoq/webhooks.rb
23
62
  homepage: https://rubygems.org/gems/invoq
24
63
  licenses:
25
64
  - MIT
@@ -43,5 +82,5 @@ requirements: []
43
82
  rubygems_version: 3.0.3.1
44
83
  signing_key:
45
84
  specification_version: 4
46
- summary: Ruby SDK for Invoq stablecoin payment acceptance.
85
+ summary: Ruby SDK for invoq stablecoin payment acceptance.
47
86
  test_files: []