invoq 0.1.0 → 0.2.1

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: e4394aa795d6674da3055ce51a03de79b19602bc50139dc19bd00488b0bcc267
4
+ data.tar.gz: 4bba9c3d77bf88951e393097b7b9e47301f42d668acdae4b93ac24adbcf9254b
5
5
  SHA512:
6
- metadata.gz: 8b7465558d3d32639a49cdfb1fa5a2a043ad154b1138d487afdee592706d8d4d8d84725354308397ff519724b961bd4f06e5c408f249486c5ac95ebda6de99d5
7
- data.tar.gz: 8c6e47ed6497d8ebef8a9713a9bd58dc2673368eec45a1e0c9de103790366ed8c1e33ebd80c58f8ae6543dab0ffe48d005c3f27e2889a00a27fd1b9cccd8b935
6
+ metadata.gz: e6606f9c4a3f0043e1353f007da072d9cae06edf823e3f695e9aac80938010baa38bd98bbbeb24d12adb164bbfea2f25f38db62f2a8e14267282d3876bb9fd21
7
+ data.tar.gz: 6beee35201584d88936d0d289301436eac266f87e0ef0a03889207fce892e72f36a50885314a365f63d93ca8ca714acf0889046150da1069f138469f63ccc48d
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,27 @@
1
- # Invoq Ruby SDK
1
+ # invoq Ruby SDK
2
2
 
3
- Invoq is a stablecoin payment acceptance platform.
3
+ **English** · [Bahasa Indonesia](./docs/README.id.md) · [Español](./docs/README.es-419.md) · [Français](./docs/README.fr.md) · [Português](./docs/README.pt-BR.md) · [Tiếng Việt](./docs/README.vi.md) · [Türkçe](./docs/README.tr.md) · [ไทย](./docs/README.th.md) · [简体中文](./docs/README.zh-Hans.md) · [繁體中文](./docs/README.zh-Hant.md)
4
4
 
5
- This gem is an early placeholder release for the future Invoq Ruby SDK.
5
+ Accept stablecoin payments with invoq from Ruby server code. This SDK wraps
6
+ invoq server APIs and verifies signed webhooks.
7
+
8
+ Use this gem only on your server. It handles secret keys and should not be
9
+ bundled into browser code.
10
+
11
+ ## Server SDKs
12
+
13
+ Create invoices and verify webhooks from your backend in any of these languages — same REST API, same webhook signature. This repository is the Ruby SDK.
14
+
15
+ | Language | Repository |
16
+ | --- | --- |
17
+ | Node.js | [github.com/invoqmoney/sdk-js](https://github.com/invoqmoney/sdk-js) (`@invoq/server`) |
18
+ | Python | [github.com/invoqmoney/sdk-python](https://github.com/invoqmoney/sdk-python) |
19
+ | PHP | [github.com/invoqmoney/sdk-php](https://github.com/invoqmoney/sdk-php) |
20
+ | Go | [github.com/invoqmoney/sdk-go](https://github.com/invoqmoney/sdk-go) |
21
+ | Rust | [github.com/invoqmoney/sdk-rust](https://github.com/invoqmoney/sdk-rust) |
22
+ | Ruby | **this repo** |
23
+
24
+ The browser side is the same for every backend: **`@invoq/checkout`** (JavaScript, in [github.com/invoqmoney/sdk-js](https://github.com/invoqmoney/sdk-js)) opens the in-page checkout modal for any frontend.
6
25
 
7
26
  ## Installation
8
27
 
@@ -18,12 +37,282 @@ Or add it to a Gemfile:
18
37
  gem "invoq"
19
38
  ```
20
39
 
21
- ## Usage
40
+ Requires Ruby 2.6 or newer.
41
+
42
+ ## Get your keys
43
+
44
+ 1. Sign in to the [invoq dashboard](https://app.invoq.money) and create a
45
+ project.
46
+ 2. On the **API keys** page, create a secret key. Test keys start with
47
+ `sk_test_`, live keys with `sk_live_`. The key mode determines whether
48
+ invoices are test or live.
49
+ 3. In your project's **webhooks** settings, save your webhook URL. The webhook
50
+ secret (`whsec_...`) for that mode is shown once, when you first enable the
51
+ webhook. Store it right away. Webhook URLs must be public HTTPS URLs.
52
+
53
+ Add both to your server environment:
54
+
55
+ ```sh
56
+ INVOQ_SECRET_KEY=sk_test_...
57
+ INVOQ_WEBHOOK_SECRET=whsec_...
58
+ ```
59
+
60
+ Start with test keys. Switch to the live key and live webhook secret when you
61
+ go to production.
62
+
63
+ ## Create a client
22
64
 
23
65
  ```ruby
24
66
  require "invoq"
25
67
 
26
- Invoq::VERSION
68
+ invoq = Invoq.new(ENV.fetch("INVOQ_SECRET_KEY"))
69
+ ```
70
+
71
+ Default production API origin:
72
+
73
+ ```txt
74
+ https://api.invoq.money
27
75
  ```
28
76
 
29
- The public SDK API is not available yet.
77
+ Override the API origin and request timeout during development:
78
+
79
+ ```ruby
80
+ invoq = Invoq.new(
81
+ ENV.fetch("INVOQ_SECRET_KEY"),
82
+ api_origin: "http://localhost:8787",
83
+ timeout_ms: 10_000
84
+ )
85
+ ```
86
+
87
+ `api_origin` must be an absolute `http` or `https` origin with no path, query,
88
+ hash, username, or password. The SDK appends `/v1/...` resource paths.
89
+
90
+ Requests time out after 10 seconds by default. Pass `timeout_ms` to change the
91
+ timeout. `timeout_ms` must be a positive integer in milliseconds.
92
+
93
+ ## Invoices
94
+
95
+ Create an invoice:
96
+
97
+ ```ruby
98
+ invoice = invoq.invoices.create(
99
+ amount: "129",
100
+ currency: "USD",
101
+ description: "SaaS boilerplate",
102
+ reference_id: "order_1234",
103
+ return_url: "https://merchant.example/thanks"
104
+ )
105
+
106
+ invoice_id = invoice.fetch("id")
107
+ checkout_url = "https://pay.invoq.money/#{invoice_id}"
108
+ ```
109
+
110
+ Notes:
111
+
112
+ - Use a server-side amount. Do not trust client-supplied amounts.
113
+ - `amount` is a decimal USD string from `"0.01"` to `"999.99"` with up
114
+ to 2 decimal places, such as `"129"` or `"129.99"`.
115
+ - `currency` is optional and defaults to `"USD"`.
116
+ - Use a stable, non-empty `reference_id` to map `invoice.paid` webhooks back to
117
+ your order. Creating again with the same `reference_id` and invoice terms
118
+ returns the existing invoice; different terms fail with a
119
+ `409 reference_id_conflict` API error.
120
+ - If you fulfill by invoice ID instead of `reference_id`, store `invoice_id`
121
+ with your order when you create the invoice.
122
+ - Omit `return_url` to use the project's default return URL. Pass `nil` to send
123
+ JSON `null` and create the invoice without a return URL. On `reference_id`
124
+ retries, pass `return_url` explicitly when you need to assert a specific
125
+ value.
126
+ - `description` and `reference_id` must be strings when present.
127
+
128
+ Get an invoice:
129
+
130
+ ```ruby
131
+ invoice = invoq.invoices.get("inv_123")
132
+ ```
133
+
134
+ `invoices.get` returns the public invoice shape used by hosted checkout. It
135
+ includes fields such as `amount_paid`, `amount_due`, `amount_overpaid`,
136
+ `payment_status`, `project`, `deposit_address`, `monitoring_ends_at`,
137
+ `monitoring_status`, `transfers`, and `direct_onchain_rails`, but does not
138
+ include `reference_id`. Use the create response or `invoice.paid` webhook when
139
+ you need your merchant `reference_id`.
140
+
141
+ Create a test payment:
142
+
143
+ ```ruby
144
+ paid_invoice = invoq.invoices.create_test_payment(
145
+ "inv_123",
146
+ amount: "129",
147
+ reference_id: "test_payment_001"
148
+ )
149
+ ```
150
+
151
+ Test invoices cannot receive real funds. Simulate payments from your server
152
+ instead.
153
+
154
+ `create_test_payment` only works for invoices created with a `sk_test_` key.
155
+ Partial amounts are allowed and produce `partially_paid`; when payments reach
156
+ the invoice amount, invoq sends a signed `invoice.paid` webhook to your test
157
+ webhook URL.
158
+
159
+ `reference_id` is optional for test payments. Omit it when unset; do not pass
160
+ `nil`.
161
+
162
+ To receive webhooks on your machine, expose your local server with an HTTPS
163
+ tunnel such as ngrok or cloudflared and save the tunnel URL as your test webhook
164
+ URL in the dashboard. The dashboard can also send a signed `webhook.ping` to
165
+ check connectivity.
166
+
167
+ Each invoice method returns the response `data` object directly as a Ruby hash.
168
+
169
+ ## Hosted checkout page
170
+
171
+ Every invoice also has a hosted checkout page at:
172
+
173
+ ```txt
174
+ https://pay.invoq.money/<invoice id>
175
+ ```
176
+
177
+ Share the link or redirect to it when an in-page checkout modal is not a fit.
178
+
179
+ ## Inputs and responses
180
+
181
+ The SDK checks that `amount` values and `invoice_id` arguments are non-empty
182
+ strings before sending requests. The invoq API validates the amount format,
183
+ range, and currency.
184
+
185
+ Leave unset optional fields out of the request hash. When you include
186
+ `description` or `reference_id`, pass a string. `return_url` can be a string or
187
+ `nil`.
188
+
189
+ Amounts in responses are normalized to 4 decimal places: create with `"129"`
190
+ and the invoice returns `amount: "129.0000"`. Compare amounts numerically, not
191
+ as strings. `amount_due` is derived as `max(amount - amount_paid, 0)` and uses
192
+ the same 18-decimal scale as `amount_paid`; `amount_overpaid` is its mirror,
193
+ `max(amount_paid - amount, 0)`, so you never subtract money yourself.
194
+ `monitoring_status` is `"active"` or `"ended"` — once it is `"ended"`, the
195
+ deposit address is no longer watched — and `transfers` is the confirmed
196
+ on-chain receipt trail (each entry has `tx_hash`, `amount`, and
197
+ `explorer_tx_url`). Both are `nil` / `[]` for test invoices.
198
+
199
+ ## Webhooks
200
+
201
+ Pass the raw request body to `verify_webhook`. Do not parse JSON and encode it
202
+ again before verification.
203
+
204
+ This Rack example returns `[status, headers, body]`. In Rails, use
205
+ `request.raw_post` and `request.get_header("HTTP_INVOQ_SIGNATURE")`; in Sinatra
206
+ or another Ruby framework, use the framework's raw request body and exposed
207
+ `invoq-signature` or `HTTP_INVOQ_SIGNATURE` header.
208
+
209
+ ```ruby
210
+ def handle_invoq_webhook(env)
211
+ raw_body = env.fetch("rack.input").read
212
+
213
+ begin
214
+ event = Invoq.verify_webhook(
215
+ raw_body,
216
+ { "invoq-signature" => env["HTTP_INVOQ_SIGNATURE"] },
217
+ ENV.fetch("INVOQ_WEBHOOK_SECRET")
218
+ )
219
+ rescue Invoq::SignatureVerificationError
220
+ return [
221
+ 400,
222
+ { "content-type" => "application/json" },
223
+ ['{"error":"invalid signature"}']
224
+ ]
225
+ end
226
+
227
+ if Invoq.invoice_paid?(event)
228
+ invoice = event.fetch("data").fetch("invoice")
229
+ fulfillment_key = invoice["reference_id"] || invoice.fetch("id")
230
+
231
+ # Fulfill the order for fulfillment_key idempotently.
232
+ end
233
+
234
+ [
235
+ 200,
236
+ { "content-type" => "application/json" },
237
+ ['{"received":true}']
238
+ ]
239
+ end
240
+ ```
241
+
242
+ Use `invoice.paid` webhooks to fulfill orders on your server. Browser checkout
243
+ results are only for updating the customer experience; do not fulfill orders
244
+ from browser results.
245
+
246
+ When `Invoq.invoice_paid?(event)` is true, the invoice is ready for automatic
247
+ fulfillment; use the invoice `reference_id` or a stored invoice `id` to find
248
+ and fulfill your order. A `review_required` invoice does not emit an
249
+ `invoice.paid` webhook yet. If checkout reports `review_required`, show a
250
+ pending-review state and wait for a later `invoice.paid` webhook after review
251
+ is approved.
252
+
253
+ Important:
254
+
255
+ - Pass the exact raw request body string received by your Ruby framework.
256
+ - Pass the `invoq-signature` header.
257
+ - `verify_webhook` does not require `Invoq.new(...)` or your invoq API secret
258
+ key.
259
+ - Use your webhook secret (`whsec_...`), not `INVOQ_SECRET_KEY`.
260
+ - Make fulfillment idempotent. Retried webhook deliveries can send the same
261
+ event more than once.
262
+ - Respond with a 2xx quickly. Any other status counts as a failed delivery.
263
+ Transient failures such as timeouts, `429`, and `5xx` responses are retried;
264
+ other `4xx` responses are not.
265
+
266
+ `Invoq.invoice_paid?` accepts fulfillable `invoice.paid` events whose invoice
267
+ status is `paid`, `settling`, or `settled`; it rejects `review_required`.
268
+
269
+ Webhook verification failures raise `Invoq::SignatureVerificationError`. The
270
+ SDK allows a 5-minute timestamp tolerance. The signature header is:
271
+
272
+ ```txt
273
+ invoq-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
274
+ ```
275
+
276
+ ## Errors
277
+
278
+ ```ruby
279
+ begin
280
+ invoq.invoices.create(amount: "0.001", currency: "USD")
281
+ rescue Invoq::ApiError => error
282
+ warn error.status
283
+ warn error.code
284
+ warn error.fields
285
+ warn error.meta
286
+ warn error.payload
287
+ rescue Invoq::Error
288
+ raise
289
+ end
290
+ ```
291
+
292
+ Non-2xx API responses raise `Invoq::ApiError` with `status`, `code`, `fields`,
293
+ `meta`, and the raw `payload`.
294
+
295
+ Connection failures, timeouts, invalid input, and response parse failures raise
296
+ `Invoq::Error`. Timed-out invoice creation is safe to retry with the same
297
+ `reference_id`.
298
+
299
+ Webhook verification failures raise `Invoq::SignatureVerificationError` with one
300
+ of these codes:
301
+
302
+ ```txt
303
+ missing_signature
304
+ invalid_signature_header
305
+ timestamp_outside_tolerance
306
+ signature_mismatch
307
+ invalid_payload
308
+ ```
309
+
310
+ ## Development
311
+
312
+ ```sh
313
+ bundle exec rake test
314
+ ```
315
+
316
+ ## License
317
+
318
+ 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.1"
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,17 +1,49 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
- - Invoq
8
- autorequire:
7
+ - invoq
9
8
  bindir: bin
10
9
  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.
14
- email:
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '10.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '14.0'
36
+ type: :development
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '10.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '14.0'
46
+ description: Ruby SDK for invoq server APIs and webhook verification.
15
47
  executables: []
16
48
  extensions: []
17
49
  extra_rdoc_files: []
@@ -19,13 +51,17 @@ files:
19
51
  - LICENSE.txt
20
52
  - README.md
21
53
  - lib/invoq.rb
54
+ - lib/invoq/client.rb
55
+ - lib/invoq/errors.rb
56
+ - lib/invoq/internal/request.rb
57
+ - lib/invoq/invoices_resource.rb
22
58
  - lib/invoq/version.rb
59
+ - lib/invoq/webhooks.rb
23
60
  homepage: https://rubygems.org/gems/invoq
24
61
  licenses:
25
62
  - MIT
26
63
  metadata:
27
64
  allowed_push_host: https://rubygems.org
28
- post_install_message:
29
65
  rdoc_options: []
30
66
  require_paths:
31
67
  - lib
@@ -40,8 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
40
76
  - !ruby/object:Gem::Version
41
77
  version: '0'
42
78
  requirements: []
43
- rubygems_version: 3.0.3.1
44
- signing_key:
79
+ rubygems_version: 4.0.16
45
80
  specification_version: 4
46
- summary: Ruby SDK for Invoq stablecoin payment acceptance.
81
+ summary: Ruby SDK for invoq stablecoin payment acceptance.
47
82
  test_files: []