nakopay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7131b4098aa2562030d2fe2a9074d8216295f34a2d2dc358cd9458a6fabf117c
4
+ data.tar.gz: 4c7e39254fe0adeb19afa282c6ad07cb4afb55bfd554712a50128db47f34b368
5
+ SHA512:
6
+ metadata.gz: 2490d3896553cbec2c4306b3fc4cd4517beb339e9fbd92f2beb9c2a0acb20b00bc717221b63ef1e01a38f2ec40c71108158ae197f652293b50e61effdc32af7e
7
+ data.tar.gz: b94643da6e7608d20eafbc38a117aecc3cf400520e2498241942c798d2079732675f94149ad8fe98263fa2b2d63fda616ae65c42ad344d7e5e95dc2f2364086b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NakoPay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # nakopay (Ruby gem)
2
+
3
+ Official [NakoPay](https://nakopay.com) SDK for Ruby.
4
+
5
+ ```bash
6
+ gem install nakopay
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```ruby
12
+ require "nakopay"
13
+
14
+ NakoPay.api_key = ENV["NAKOPAY_SECRET_KEY"]
15
+
16
+ invoice = NakoPay::Invoice.create(
17
+ amount: "19.99",
18
+ currency: "USD",
19
+ coin: "BTC",
20
+ description: "Pro plan",
21
+ customer_email: "alex@acme.com",
22
+ idempotency_key: "ord_1042",
23
+ )
24
+
25
+ puts invoice.id, invoice.checkout_url
26
+ ```
27
+
28
+ ## Features
29
+
30
+ - Pinned to API version `2025-04-20`
31
+ - Auto-retry on `429` / `5xx` with exponential backoff + jitter
32
+ - Auto-generated `Idempotency-Key` for every POST
33
+ - Webhook signature verifier: `NakoPay::Webhook.construct_event(payload, sig_header, secret)`
34
+ - Typed errors: `NakoPay::APIError`, `NakoPay::SignatureVerificationError`
35
+
36
+ ## Webhooks
37
+
38
+ ```ruby
39
+ post "/webhook" do
40
+ payload = request.body.read
41
+ begin
42
+ event = NakoPay::Webhook.construct_event(
43
+ payload,
44
+ request.env["HTTP_X_NAKOPAY_SIGNATURE"],
45
+ ENV.fetch("NAKOPAY_WEBHOOK_SECRET"),
46
+ )
47
+ rescue NakoPay::SignatureVerificationError
48
+ halt 400
49
+ end
50
+
51
+ fulfill(event["data"]["object"]) if event["type"] == "invoice.paid"
52
+ status 200
53
+ end
54
+ ```
55
+
56
+ ## Links
57
+
58
+ - [NakoPay Website](https://nakopay.com)
59
+ - [Documentation](https://nakopay.com/docs)
60
+ - [Integration Guide](https://nakopay.com/docs/ruby)
61
+ - [API Reference](https://nakopay.com/docs/api-reference)
62
+
63
+ ## License
64
+
65
+ MIT - see [LICENSE](./LICENSE).
@@ -0,0 +1,167 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module NakoPay
7
+ # Low-level HTTP client. Most callers use the resource modules
8
+ # (NakoPay::Invoice, etc.) instead of touching this directly.
9
+ class Client
10
+ DEFAULT_BASE_URL = "https://api.nakopay.com/v1"
11
+ DEFAULT_API_VERSION = "2025-04-20"
12
+ DEFAULT_TIMEOUT = 30
13
+ DEFAULT_MAX_RETRIES = 3
14
+
15
+ attr_reader :api_key, :base_url, :api_version, :timeout, :max_retries
16
+
17
+ # @param faraday [Faraday::Connection, nil] optional Faraday connection for
18
+ # custom middleware stacks. When provided, Net::HTTP is not used.
19
+ def initialize(api_key: nil, base_url: nil, api_version: nil, timeout: nil, max_retries: nil, faraday: nil)
20
+ @api_key = api_key || NakoPay.api_key
21
+ @base_url = (base_url || NakoPay.base_url || DEFAULT_BASE_URL).chomp("/")
22
+ @api_version = api_version || NakoPay.api_version || DEFAULT_API_VERSION
23
+ @timeout = timeout || NakoPay.timeout || DEFAULT_TIMEOUT
24
+ @max_retries = max_retries || NakoPay.max_retries || DEFAULT_MAX_RETRIES
25
+ @faraday = faraday
26
+
27
+ raise ArgumentError, "NakoPay: api_key is required" if @api_key.nil? || @api_key.empty?
28
+ if @api_key.start_with?("pk_")
29
+ raise ArgumentError, "NakoPay: a publishable key (pk_*) was passed to the server SDK; use a secret key (sk_live_* or sk_test_*)"
30
+ end
31
+ end
32
+
33
+ def request(method, path, body: nil, query: nil, idempotency_key: nil, headers: {})
34
+ attempt = 0
35
+ loop do
36
+ begin
37
+ code, raw, resp_headers = execute_request(method, path, body: body, query: query, idempotency_key: idempotency_key, headers: headers)
38
+ rescue StandardError => e
39
+ if attempt < @max_retries
40
+ sleep_with_backoff(attempt, nil)
41
+ attempt += 1
42
+ next
43
+ end
44
+ raise NakoPay::ConnectionError, "network error: #{e.message}"
45
+ end
46
+
47
+ if code >= 200 && code < 300
48
+ return raw.empty? ? nil : JSON.parse(raw)
49
+ end
50
+
51
+ env = (JSON.parse(raw) rescue {})
52
+ api_err_payload = env.is_a?(Hash) ? env["error"] : nil
53
+ api_err_payload ||= { "code" => "http_#{code}", "message" => raw.empty? ? "HTTP #{code}" : raw }
54
+ if api_err_payload.is_a?(Hash) && api_err_payload["request_id"].nil?
55
+ api_err_payload["request_id"] = resp_headers && resp_headers["x-request-id"]
56
+ end
57
+
58
+ if (code == 429 || code >= 500) && attempt < @max_retries
59
+ sleep_with_backoff(attempt, nil)
60
+ attempt += 1
61
+ next
62
+ end
63
+
64
+ raise NakoPay.build_api_error(api_err_payload, status_code: code)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def execute_request(method, path, body:, query:, idempotency_key:, headers:)
71
+ if @faraday
72
+ execute_faraday(method, path, body: body, query: query, idempotency_key: idempotency_key, headers: headers)
73
+ else
74
+ execute_net_http(method, path, body: body, query: query, idempotency_key: idempotency_key, headers: headers)
75
+ end
76
+ end
77
+
78
+ def execute_net_http(method, path, body:, query:, idempotency_key:, headers:)
79
+ uri = URI(@base_url + path)
80
+ uri.query = URI.encode_www_form(query.compact) if query && !query.empty?
81
+
82
+ req = build_request(method, uri, body: body, idempotency_key: idempotency_key, headers: headers)
83
+ res = http_for(uri).request(req)
84
+ h = {}
85
+ res.each_header { |k, v| h[k.downcase] = v }
86
+ [res.code.to_i, res.body.to_s, h]
87
+ end
88
+
89
+ def execute_faraday(method, path, body:, query:, idempotency_key:, headers:)
90
+ url = @base_url + path
91
+ h = default_headers.merge(headers)
92
+ if %w[POST DELETE].include?(method.to_s.upcase)
93
+ h["Idempotency-Key"] = idempotency_key || "idem_#{SecureRandom.hex(16)}"
94
+ end
95
+
96
+ resp = @faraday.run_request(method.to_s.downcase.to_sym, url, body ? JSON.generate(body) : nil, h) do |req|
97
+ req.params.update(query.compact) if query && !query.empty?
98
+ req.headers["Content-Type"] = "application/json" if body
99
+ end
100
+ res_h = {}
101
+ resp.headers.each { |k, v| res_h[k.downcase] = v }
102
+ [resp.status, resp.body.to_s, res_h]
103
+ end
104
+
105
+ def default_headers
106
+ {
107
+ "Authorization" => "Bearer #{@api_key}",
108
+ "X-NakoPay-Version" => @api_version,
109
+ "User-Agent" => "nakopay-ruby/#{VERSION}",
110
+ "Accept" => "application/json",
111
+ }
112
+ end
113
+
114
+ def http_for(uri)
115
+ h = Net::HTTP.new(uri.host, uri.port)
116
+ h.use_ssl = uri.scheme == "https"
117
+ h.open_timeout = @timeout
118
+ h.read_timeout = @timeout
119
+ h
120
+ end
121
+
122
+ def build_request(method, uri, body:, idempotency_key:, headers:)
123
+ klass = case method.to_s.upcase
124
+ when "GET" then Net::HTTP::Get
125
+ when "POST" then Net::HTTP::Post
126
+ when "DELETE" then Net::HTTP::Delete
127
+ else raise ArgumentError, "unsupported method #{method}"
128
+ end
129
+ req = klass.new(uri.request_uri)
130
+ default_headers.each { |k, v| req[k] = v }
131
+ headers.each { |k, v| req[k] = v }
132
+
133
+ if %w[POST DELETE].include?(method.to_s.upcase)
134
+ req["Idempotency-Key"] = idempotency_key || "idem_#{SecureRandom.hex(16)}"
135
+ if body
136
+ req["Content-Type"] = "application/json"
137
+ req.body = JSON.generate(body)
138
+ end
139
+ end
140
+ req
141
+ end
142
+
143
+ def sleep_with_backoff(attempt, retry_after)
144
+ if retry_after
145
+ n = Integer(retry_after) rescue nil
146
+ return sleep([n, 30].min) if n && n >= 0
147
+ end
148
+ base = [250 * (2**attempt), 8_000].min / 1000.0
149
+ jitter = base * 0.25 * (rand * 2 - 1)
150
+ sleep [0.05, base + jitter].max
151
+ end
152
+ end
153
+
154
+ class << self
155
+ attr_accessor :api_key, :base_url, :api_version, :timeout, :max_retries
156
+
157
+ # Default singleton client. Resources call into this; tests may swap it.
158
+ def client
159
+ @client ||= Client.new
160
+ end
161
+
162
+ # Reset the singleton (used in tests).
163
+ def reset_client!
164
+ @client = nil
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,88 @@
1
+ module NakoPay
2
+ # Base error for everything raised by this SDK.
3
+ class Error < StandardError; end
4
+
5
+ # Raised for any non-2xx HTTP response.
6
+ class APIError < Error
7
+ attr_reader :code, :type, :param, :doc_url, :request_id, :status_code
8
+
9
+ def initialize(message:, code: nil, type: nil, param: nil, doc_url: nil, request_id: nil, status_code: nil)
10
+ super(message)
11
+ @code = code
12
+ @type = type
13
+ @param = param
14
+ @doc_url = doc_url
15
+ @request_id = request_id
16
+ @status_code = status_code
17
+ end
18
+
19
+ # True for 429/5xx errors that may succeed on retry.
20
+ def retryable?
21
+ @status_code == 429 || (@status_code && @status_code >= 500)
22
+ end
23
+ end
24
+
25
+ # Raised for 401 or authentication_error codes.
26
+ class AuthenticationError < APIError
27
+ def initialize(message: "Invalid API key", **kwargs)
28
+ kwargs[:code] ||= "authentication_error"
29
+ kwargs[:type] ||= "authentication_error"
30
+ super(message: message, **kwargs)
31
+ end
32
+ end
33
+
34
+ # Raised for 429 responses after all retries are exhausted.
35
+ class RateLimitError < APIError
36
+ def initialize(message: "Rate limit exceeded", **kwargs)
37
+ kwargs[:code] ||= "rate_limit_error"
38
+ kwargs[:type] ||= "rate_limit_error"
39
+ super(message: message, **kwargs)
40
+ end
41
+ end
42
+
43
+ # Raised when an idempotency key is reused with different parameters.
44
+ class IdempotencyError < APIError
45
+ def initialize(message: "Idempotency conflict", **kwargs)
46
+ kwargs[:code] ||= "idempotency_error"
47
+ kwargs[:type] ||= "idempotency_error"
48
+ super(message: message, **kwargs)
49
+ end
50
+ end
51
+
52
+ # Raised when transport fails (DNS, refused, timeout) after all retries.
53
+ class ConnectionError < Error; end
54
+
55
+ # Raised by Webhook.construct_event when verification fails.
56
+ class SignatureVerificationError < Error
57
+ attr_reader :code
58
+
59
+ def initialize(message, code: nil)
60
+ super(message)
61
+ @code = code
62
+ end
63
+ end
64
+
65
+ # Maps API error envelope to specialized subclass.
66
+ def self.build_api_error(payload, status_code:)
67
+ code = payload["code"]
68
+ message = payload["message"]
69
+ common = {
70
+ code: code,
71
+ type: payload["type"],
72
+ param: payload["param"],
73
+ doc_url: payload["doc_url"],
74
+ request_id: payload["request_id"],
75
+ status_code: status_code,
76
+ }
77
+
78
+ if status_code == 401 || code == "authentication_error"
79
+ AuthenticationError.new(message: message || "Invalid API key", **common)
80
+ elsif status_code == 429 || code == "rate_limit_error"
81
+ RateLimitError.new(message: message || "Rate limit exceeded", **common)
82
+ elsif code == "idempotency_error"
83
+ IdempotencyError.new(message: message || "Idempotency conflict", **common)
84
+ else
85
+ APIError.new(message: message || "Unknown error", **common)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,23 @@
1
+ module NakoPay
2
+ # Lightweight wrapper that lets callers do `inv.id` instead of `inv["id"]`.
3
+ class Resource
4
+ def initialize(attrs)
5
+ @attrs = attrs || {}
6
+ end
7
+
8
+ def [](key) = @attrs[key.to_s]
9
+ def to_h = @attrs.dup
10
+ def to_json(*) = @attrs.to_json
11
+
12
+ def respond_to_missing?(name, include_private = false)
13
+ @attrs.key?(name.to_s) || super
14
+ end
15
+
16
+ def method_missing(name, *args, &blk)
17
+ key = name.to_s
18
+ return @attrs[key] if @attrs.key?(key)
19
+
20
+ super
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,264 @@
1
+ module NakoPay
2
+ module Invoice
3
+ module_function
4
+
5
+ def create(idempotency_key: nil, **params)
6
+ Resource.new(NakoPay.client.request(:post, "/invoices-create", body: params, idempotency_key: idempotency_key))
7
+ end
8
+
9
+ def retrieve(id)
10
+ Resource.new(NakoPay.client.request(:get, "/invoices-get", query: { id: id }))
11
+ end
12
+
13
+ def list(limit: nil, starting_after: nil, status: nil)
14
+ page = NakoPay.client.request(:get, "/invoices-list", query: { limit: limit, starting_after: starting_after, status: status })
15
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
16
+ page
17
+ end
18
+
19
+ def cancel(id, idempotency_key: nil)
20
+ Resource.new(NakoPay.client.request(:post, "/invoices-cancel", body: { id: id }, idempotency_key: idempotency_key))
21
+ end
22
+
23
+ def auto_paging_each(limit: nil, status: nil)
24
+ return enum_for(:auto_paging_each, limit: limit, status: status) unless block_given?
25
+
26
+ cursor = nil
27
+ loop do
28
+ page = list(limit: limit, starting_after: cursor, status: status)
29
+ page["data"].each { |inv| yield inv }
30
+ break unless page["has_more"]
31
+
32
+ cursor = page["next_cursor"] || page["data"].last&.id
33
+ break unless cursor
34
+ end
35
+ end
36
+ end
37
+
38
+ module Customer
39
+ module_function
40
+
41
+ def create(idempotency_key: nil, **params)
42
+ Resource.new(NakoPay.client.request(:post, "/customers", body: params, idempotency_key: idempotency_key))
43
+ end
44
+
45
+ def retrieve(id)
46
+ Resource.new(NakoPay.client.request(:get, "/customers", query: { id: id }))
47
+ end
48
+
49
+ def list(limit: nil, starting_after: nil)
50
+ page = NakoPay.client.request(:get, "/customers", query: { limit: limit, starting_after: starting_after })
51
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
52
+ page
53
+ end
54
+ end
55
+
56
+ module PaymentLink
57
+ module_function
58
+
59
+ def create(idempotency_key: nil, **params)
60
+ Resource.new(NakoPay.client.request(:post, "/payment-links", body: params, idempotency_key: idempotency_key))
61
+ end
62
+
63
+ def retrieve(id)
64
+ Resource.new(NakoPay.client.request(:get, "/payment-links", query: { id: id }))
65
+ end
66
+ end
67
+
68
+ module WebhookEndpoint
69
+ module_function
70
+
71
+ def create(idempotency_key: nil, **params)
72
+ Resource.new(NakoPay.client.request(:post, "/webhooks-create", body: params, idempotency_key: idempotency_key))
73
+ end
74
+
75
+ def delete(id, idempotency_key: nil)
76
+ NakoPay.client.request(:post, "/webhooks-delete", body: { id: id }, idempotency_key: idempotency_key)
77
+ end
78
+
79
+ def test(id, idempotency_key: nil)
80
+ NakoPay.client.request(:post, "/webhooks-test", body: { id: id }, idempotency_key: idempotency_key)
81
+ end
82
+
83
+ def replay(id, delivery_id: nil, idempotency_key: nil)
84
+ body = { id: id }
85
+ body[:delivery_id] = delivery_id if delivery_id
86
+ Resource.new(NakoPay.client.request(:post, "/webhooks-replay", body: body, idempotency_key: idempotency_key))
87
+ end
88
+ end
89
+
90
+ module Logs
91
+ module_function
92
+
93
+ def list(limit: nil, starting_after: nil, **params)
94
+ page = NakoPay.client.request(:get, "/logs-list", query: { limit: limit, starting_after: starting_after, **params })
95
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
96
+ page
97
+ end
98
+ end
99
+
100
+ module Sandbox
101
+ module_function
102
+
103
+ # Seed the sandbox with demo customers + invoices. Test-mode key only.
104
+ def seed(idempotency_key: nil, **params)
105
+ Resource.new(NakoPay.client.request(:post, "/sandbox-seed", body: params, idempotency_key: idempotency_key))
106
+ end
107
+ end
108
+
109
+ module Event
110
+ module_function
111
+
112
+ def list(limit: nil, starting_after: nil, type: nil)
113
+ page = NakoPay.client.request(:get, "/events-list", query: { limit: limit, starting_after: starting_after, type: type })
114
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
115
+ page
116
+ end
117
+
118
+ def auto_paging_each(limit: nil, type: nil)
119
+ return enum_for(:auto_paging_each, limit: limit, type: type) unless block_given?
120
+
121
+ cursor = nil
122
+ loop do
123
+ page = list(limit: limit, starting_after: cursor, type: type)
124
+ page["data"].each { |e| yield e }
125
+ break unless page["has_more"]
126
+
127
+ cursor = page["next_cursor"] || page["data"].last&.id
128
+ break unless cursor
129
+ end
130
+ end
131
+ end
132
+
133
+ module Rate
134
+ module_function
135
+
136
+ def retrieve(base: nil, quotes: nil)
137
+ q = { base: base }
138
+ q[:quotes] = quotes.join(",") if quotes && !quotes.empty?
139
+ Resource.new(NakoPay.client.request(:get, "/rates-get", query: q))
140
+ end
141
+ end
142
+
143
+ module Credit
144
+ module_function
145
+
146
+ def balance
147
+ Resource.new(NakoPay.client.request(:get, "/credits-balance"))
148
+ end
149
+
150
+ module Topup
151
+ module_function
152
+
153
+ def create(amount_sats:, idempotency_key: nil)
154
+ Resource.new(NakoPay.client.request(:post, "/credits-topup-create", body: { amount_sats: amount_sats }, idempotency_key: idempotency_key))
155
+ end
156
+
157
+ def retrieve(id)
158
+ Resource.new(NakoPay.client.request(:get, "/credits-topup-status", query: { id: id }))
159
+ end
160
+ end
161
+ end
162
+
163
+ module Subscription
164
+ module_function
165
+
166
+ def retrieve(id)
167
+ Resource.new(NakoPay.client.request(:get, "/subscriptions-list", query: { id: id }))
168
+ end
169
+
170
+ def list(limit: nil, starting_after: nil, status: nil)
171
+ page = NakoPay.client.request(:get, "/subscriptions-list", query: { limit: limit, starting_after: starting_after, status: status })
172
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
173
+ page
174
+ end
175
+
176
+ def cancel(id, at_period_end: true, idempotency_key: nil)
177
+ Resource.new(NakoPay.client.request(:post, "/subscriptions-cancel", body: { subscription_id: id, at_period_end: at_period_end }, idempotency_key: idempotency_key))
178
+ end
179
+
180
+ def pause(id, token: nil, idempotency_key: nil)
181
+ body = { subscription_id: id }
182
+ body[:token] = token if token
183
+ Resource.new(NakoPay.client.request(:post, "/subscriptions-pause", body: body, idempotency_key: idempotency_key))
184
+ end
185
+
186
+ def resume(id, token: nil, idempotency_key: nil)
187
+ body = { subscription_id: id }
188
+ body[:token] = token if token
189
+ Resource.new(NakoPay.client.request(:post, "/subscriptions-resume", body: body, idempotency_key: idempotency_key))
190
+ end
191
+
192
+ def portal(id, idempotency_key: nil)
193
+ Resource.new(NakoPay.client.request(:post, "/subscriptions-portal", body: { subscription_id: id }, idempotency_key: idempotency_key))
194
+ end
195
+
196
+ def auto_paging_each(limit: nil, status: nil)
197
+ return enum_for(:auto_paging_each, limit: limit, status: status) unless block_given?
198
+
199
+ cursor = nil
200
+ loop do
201
+ page = list(limit: limit, starting_after: cursor, status: status)
202
+ page["data"].each { |s| yield s }
203
+ break unless page["has_more"]
204
+
205
+ cursor = page["next_cursor"] || page["data"].last&.id
206
+ break unless cursor
207
+ end
208
+ end
209
+ end
210
+
211
+ module SubscriptionPlan
212
+ module_function
213
+
214
+ def list(limit: nil, starting_after: nil)
215
+ page = NakoPay.client.request(:get, "/subscription-plans-list", query: { limit: limit, starting_after: starting_after })
216
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
217
+ page
218
+ end
219
+ end
220
+
221
+ module Refund
222
+ module_function
223
+
224
+ def create(invoice_id:, idempotency_key: nil, **params)
225
+ Resource.new(NakoPay.client.request(:post, "/refunds-create", body: { invoice_id: invoice_id, **params }, idempotency_key: idempotency_key))
226
+ end
227
+
228
+ def retrieve(id)
229
+ Resource.new(NakoPay.client.request(:get, "/refunds-get", query: { id: id }))
230
+ end
231
+
232
+ def list(limit: nil, starting_after: nil, invoice_id: nil, status: nil)
233
+ page = NakoPay.client.request(:get, "/refunds-list", query: { limit: limit, starting_after: starting_after, invoice_id: invoice_id, status: status })
234
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
235
+ page
236
+ end
237
+
238
+ def cancel(id, idempotency_key: nil)
239
+ Resource.new(NakoPay.client.request(:post, "/refunds-cancel", body: { id: id }, idempotency_key: idempotency_key))
240
+ end
241
+ end
242
+
243
+ module Key
244
+ module_function
245
+
246
+ def create(idempotency_key: nil, **params)
247
+ Resource.new(NakoPay.client.request(:post, "/keys-create", body: params, idempotency_key: idempotency_key))
248
+ end
249
+
250
+ def list
251
+ page = NakoPay.client.request(:get, "/keys-list")
252
+ page["data"] = (page["data"] || []).map { |r| Resource.new(r) }
253
+ page
254
+ end
255
+
256
+ def revoke(id, idempotency_key: nil)
257
+ NakoPay.client.request(:post, "/keys-revoke", body: { id: id }, idempotency_key: idempotency_key)
258
+ end
259
+
260
+ def rotate(id, idempotency_key: nil)
261
+ Resource.new(NakoPay.client.request(:post, "/keys-rotate", body: { id: id }, idempotency_key: idempotency_key))
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,3 @@
1
+ module NakoPay
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,55 @@
1
+ require "openssl"
2
+ require "json"
3
+
4
+ module NakoPay
5
+ # Webhook signature verifier.
6
+ #
7
+ # NakoPay::Webhook.construct_event(raw_body, sig_header, secret)
8
+ #
9
+ # Header format: t=<unix>,v1=<hex_hmac>
10
+ # Signed payload: <t>.<raw_body>
11
+ module Webhook
12
+ DEFAULT_TOLERANCE = 300 # seconds
13
+
14
+ module_function
15
+
16
+ def construct_event(payload, sig_header, secret, tolerance: DEFAULT_TOLERANCE)
17
+ raise SignatureVerificationError.new("missing X-NakoPay-Signature header", code: "signature_missing") if sig_header.nil? || sig_header.empty?
18
+ raise SignatureVerificationError.new("webhook secret is required", code: "secret_missing") if secret.nil? || secret.empty?
19
+
20
+ parts = sig_header.split(",").each_with_object({}) do |kv, h|
21
+ k, v = kv.split("=", 2)
22
+ h[k.strip] = v.to_s.strip if k && v
23
+ end
24
+
25
+ ts = Integer(parts["t"]) rescue nil
26
+ v1 = parts["v1"]
27
+ raise SignatureVerificationError.new("malformed signature header", code: "signature_invalid") if ts.nil? || v1.nil? || v1.empty?
28
+
29
+ now = Time.now.to_i
30
+ if (now - ts).abs > tolerance
31
+ raise SignatureVerificationError.new("timestamp outside tolerance window", code: "signature_timestamp_outside_tolerance")
32
+ end
33
+
34
+ expected = OpenSSL::HMAC.hexdigest("sha256", secret, "#{ts}.#{payload}")
35
+ unless secure_compare(expected, v1)
36
+ raise SignatureVerificationError.new("signature does not match expected value", code: "signature_mismatch")
37
+ end
38
+
39
+ begin
40
+ JSON.parse(payload)
41
+ rescue JSON::ParserError
42
+ raise SignatureVerificationError.new("webhook payload is not valid JSON", code: "payload_invalid_json")
43
+ end
44
+ end
45
+
46
+ def secure_compare(a, b)
47
+ return false unless a.bytesize == b.bytesize
48
+
49
+ l = a.unpack("C*")
50
+ res = 0
51
+ b.each_byte { |byte| res |= byte ^ l.shift }
52
+ res.zero?
53
+ end
54
+ end
55
+ end
data/lib/nakopay.rb ADDED
@@ -0,0 +1,7 @@
1
+ # Official NakoPay SDK for Ruby. See README for usage.
2
+ require_relative "nakopay/version"
3
+ require_relative "nakopay/errors"
4
+ require_relative "nakopay/resource"
5
+ require_relative "nakopay/client"
6
+ require_relative "nakopay/webhook"
7
+ require_relative "nakopay/resources"
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nakopay
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - NakoPay
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby client for the NakoPay crypto-payments API. Pinned to API version
14
+ 2025-04-20.
15
+ email:
16
+ - sdk@nakopay.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - lib/nakopay.rb
24
+ - lib/nakopay/client.rb
25
+ - lib/nakopay/errors.rb
26
+ - lib/nakopay/resource.rb
27
+ - lib/nakopay/resources.rb
28
+ - lib/nakopay/version.rb
29
+ - lib/nakopay/webhook.rb
30
+ homepage: https://github.com/NakoPayHQ/sdk-ruby
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ homepage_uri: https://nakopay.com
35
+ source_code_uri: https://github.com/NakoPayHQ/sdk-ruby
36
+ documentation_uri: https://nakopay.com/docs/sdk/ruby
37
+ changelog_uri: https://github.com/NakoPayHQ/sdk-ruby/blob/main/CHANGELOG.md
38
+ bug_tracker_uri: https://github.com/NakoPayHQ/sdk-ruby/issues
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.4.19
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: Official NakoPay SDK for Ruby.
58
+ test_files: []