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 +7 -0
- data/LICENSE +21 -0
- data/README.md +65 -0
- data/lib/nakopay/client.rb +167 -0
- data/lib/nakopay/errors.rb +88 -0
- data/lib/nakopay/resource.rb +23 -0
- data/lib/nakopay/resources.rb +264 -0
- data/lib/nakopay/version.rb +3 -0
- data/lib/nakopay/webhook.rb +55 -0
- data/lib/nakopay.rb +7 -0
- metadata +58 -0
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,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: []
|