arionpay 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/CHANGELOG.md +6 -0
- data/LICENSE +21 -0
- data/README.md +174 -0
- data/lib/arionpay/client.rb +256 -0
- data/lib/arionpay/errors.rb +26 -0
- data/lib/arionpay/version.rb +5 -0
- data/lib/arionpay/webhook.rb +114 -0
- data/lib/arionpay.rb +8 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6e3d6f2c62cdee103707f3836b49b1a9983bda9b56ac692c6f678f8853b1349b
|
|
4
|
+
data.tar.gz: ebe0440db59cf2e684bac0aa8b9f1bafd02553be28376bcb0a39935ca8f5f02d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 587bb93c96ffae870470d68747dba5fba3f3095fde736eaf76b744f1d8950937e07e305813fda51bad49b88784c84e8f9e29774538a56953b4507e9dd4d84dcc
|
|
7
|
+
data.tar.gz: 5daa050c630734dc0cdd6a4386004de2dfb52783a9ea80daa66ddf3df4f03f13ade3d82aa27aab2451cd669b9a4596a3f777c4a3388fca4d3c00238e882fabfa
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ArionPay
|
|
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,174 @@
|
|
|
1
|
+
# ArionPay for Ruby
|
|
2
|
+
|
|
3
|
+
Official Ruby client for [ArionPay](https://arionpay.com). Create crypto invoices, send customers to the payment page, and check webhooks. The money goes straight to your own wallet: ArionPay never holds it.
|
|
4
|
+
|
|
5
|
+
- No dependencies (standard library only). Ruby 3.0 or newer.
|
|
6
|
+
- Works with Rails, Sinatra, Hanami, any Rack app or plain scripts.
|
|
7
|
+
- Safe retries: every invoice request carries an idempotency key, so a network error can never create two invoices.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install arionpay # or in your Gemfile: gem "arionpay"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Keys
|
|
14
|
+
|
|
15
|
+
In your ArionPay dashboard, open your store:
|
|
16
|
+
|
|
17
|
+
| Value | Where | Starts with |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| API key | Store page, **API key** | `pk_` |
|
|
20
|
+
| API secret | Store page, **API secret** (asks for your authenticator code) | `sk_` |
|
|
21
|
+
| Webhook secret | **Webhooks** page, **signing secret** | `whsec_` |
|
|
22
|
+
|
|
23
|
+
Keep them on your server, for example in environment variables or Rails credentials.
|
|
24
|
+
|
|
25
|
+
## Take a payment
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "arionpay"
|
|
29
|
+
|
|
30
|
+
ARIONPAY = ArionPay::Client.new(
|
|
31
|
+
api_key: ENV.fetch("ARIONPAY_API_KEY"),
|
|
32
|
+
api_secret: ENV.fetch("ARIONPAY_API_SECRET"),
|
|
33
|
+
webhook_secret: ENV.fetch("ARIONPAY_WEBHOOK_SECRET")
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
invoice = ARIONPAY.create_invoice(
|
|
37
|
+
amount: "49.90",
|
|
38
|
+
currency: "USD",
|
|
39
|
+
order_id: "1001",
|
|
40
|
+
success_url: "https://shop.example.com/thanks/1001",
|
|
41
|
+
cancel_url: "https://shop.example.com/cart",
|
|
42
|
+
metadata: { "user_id" => 42 }
|
|
43
|
+
)
|
|
44
|
+
# send the customer to invoice["invoiceUrl"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The customer coming back to `success_url` does **not** prove they paid. Deliver the order when the `invoice.paid` webhook arrives (or when `get_invoice` says `paid`).
|
|
48
|
+
|
|
49
|
+
Invoices are Hashes with string keys and the same field names as the webhook's `data.invoice` (`"id"`, `"status"`, `"orderId"`, `"amountFiat"`, `"currency"`, `"asset"`, `"paidCrypto"`, `"transactions"`, `"test"`, `"metadata"`, ...), so one method can handle a webhook and a read.
|
|
50
|
+
|
|
51
|
+
## Webhooks
|
|
52
|
+
|
|
53
|
+
Set your webhook address on the **Webhooks** page of the dashboard (a public `https://` address), or per invoice with `callback_url:`. Check the signature against the **raw** body, before you parse it.
|
|
54
|
+
|
|
55
|
+
### Rails
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# config/routes.rb: post "/arionpay/webhook", to: "arionpay_webhooks#create"
|
|
59
|
+
class ArionpayWebhooksController < ActionController::API # or ApplicationController with skip_forgery_protection
|
|
60
|
+
def create
|
|
61
|
+
event = ARIONPAY.verify_webhook(request.raw_post, request.headers["ArionPay-Signature"])
|
|
62
|
+
handle_event(event) unless ArionPay.test_event?(event) # "Send test webhook": not a real order
|
|
63
|
+
head :ok
|
|
64
|
+
rescue ArionPay::SignatureError
|
|
65
|
+
head :unauthorized
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Sinatra
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
post "/arionpay/webhook" do
|
|
74
|
+
request.body.rewind if request.body.respond_to?(:rewind) # Rack 3 bodies may not rewind
|
|
75
|
+
begin
|
|
76
|
+
event = ARIONPAY.verify_webhook(request.body.read, request.env["HTTP_ARIONPAY_SIGNATURE"])
|
|
77
|
+
rescue ArionPay::SignatureError
|
|
78
|
+
halt 401
|
|
79
|
+
end
|
|
80
|
+
handle_event(event) unless ArionPay.test_event?(event)
|
|
81
|
+
status 200
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
On Rack 3, `request.body.rewind` without the `respond_to?` check fails with `NoMethodError` on some servers (WEBrick, for one), and every webhook then gets a 500.
|
|
86
|
+
|
|
87
|
+
### Plain Rack
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
raw = env["rack.input"].read
|
|
91
|
+
event = ARIONPAY.verify_webhook(raw, env["HTTP_ARIONPAY_SIGNATURE"])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
No client at hand? `ArionPay.verify_webhook(raw, header, webhook_secret)` does the same.
|
|
95
|
+
|
|
96
|
+
### Handling an event
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
def handle_event(event)
|
|
100
|
+
return if WebhookEvent.exists?(event_id: event["id"]) # the same event can arrive more than once
|
|
101
|
+
|
|
102
|
+
invoice = event["data"]["invoice"]
|
|
103
|
+
order = Order.find_by(number: invoice["orderId"])
|
|
104
|
+
if event["type"] == "invoice.paid" && order && !order.paid? &&
|
|
105
|
+
ArionPay.order_matches?(invoice, order.total, order.currency) && invoice["test"] == order.test?
|
|
106
|
+
order.mark_paid!(invoice) # deliver here
|
|
107
|
+
end
|
|
108
|
+
WebhookEvent.create!(event_id: event["id"])
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`ArionPay.order_matches?(invoice, "49.90", "USD")` is true when the invoice price is that total, to the cent, in that currency.
|
|
113
|
+
|
|
114
|
+
| Event | Meaning | What to do |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `invoice.pending` | A payment was seen on the network and waits for confirmations. | Show "payment on its way". Do not deliver yet. |
|
|
117
|
+
| `invoice.partial` | Less than the full amount arrived. The invoice stays open for the rest. | Do not deliver. |
|
|
118
|
+
| `invoice.paid` | Fully paid and confirmed. Also sent when you accept a payment by hand in the dashboard. | Deliver the order. |
|
|
119
|
+
| `invoice.expired` | Time ran out without full payment. A late payment can still make it `paid`. | Keep the order unpaid. |
|
|
120
|
+
| `invoice.needs_review` | Something needs a person, for example more money arrived after the invoice was paid. | Tell a human. Do not change the order. |
|
|
121
|
+
|
|
122
|
+
ArionPay sends again when your server does not answer `2xx` (for about 3 days).
|
|
123
|
+
|
|
124
|
+
### Rules for a safe integration
|
|
125
|
+
|
|
126
|
+
1. Check the signature on the raw body. Never trust a webhook you could not verify.
|
|
127
|
+
2. Deliver only on `invoice.paid` (or when `get_invoice(id)["status"] == "paid"`).
|
|
128
|
+
3. Match the invoice to your order: your `order_id` or `metadata`, and the same amount and currency as the order's current total (`ArionPay.order_matches?`).
|
|
129
|
+
4. Ignore test events (`ArionPay.test_event?(event)`), and in your live shop ignore invoices with `"test" => true`.
|
|
130
|
+
5. Handle each `event["id"]` once, and answer `2xx` quickly (send emails from a background job).
|
|
131
|
+
6. Do not compare crypto amounts yourself. ArionPay decides when an invoice is paid, including payments inside your store's payment tolerance and payments you accept in Payment Radar.
|
|
132
|
+
|
|
133
|
+
## Test without real money
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
invoice = ARIONPAY.create_invoice(amount: "10", order_id: "test-1", asset: "USDT_TRC20", test: true)
|
|
137
|
+
ARIONPAY.simulate_payment(invoice["id"], "exact") # your webhook gets invoice.paid, like a real payment
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Scenarios: `exact`, `detected`, `confirm`, `underpaid_1pct`, `underpaid_5pct`, `overpaid`, `partial_half`, `late`.
|
|
141
|
+
|
|
142
|
+
`ARIONPAY.send_test_webhook("invoice.paid")` sends a signed sample event to your store's webhook address. It describes a made-up invoice (`inv_test_...`) and must never change an order.
|
|
143
|
+
|
|
144
|
+
Complete small shops: [`examples/config.ru`](examples/config.ru) (plain Rack) and [`examples/sinatra.rb`](examples/sinatra.rb).
|
|
145
|
+
|
|
146
|
+
## Reference
|
|
147
|
+
|
|
148
|
+
| Method | Returns |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `create_invoice(amount:, currency: "USD", order_id: nil, ...)` | `{"id", "invoiceUrl", "status", "amountFiat", "currency", "asset", "address", "amountCrypto", "expiresAt", "orderId", "raw"}` |
|
|
151
|
+
| `get_invoice(invoice_id)` | The invoice, with the webhook field names (`"raw"` has every field ArionPay sent). |
|
|
152
|
+
| `simulate_payment(invoice_id, scenario = "exact")` | Sandbox only (test invoices). |
|
|
153
|
+
| `send_test_webhook(event_type = "invoice.paid")` | `{"delivered", "httpStatus", "eventId"}` |
|
|
154
|
+
| `currencies` | Coins you can pass as `asset:` / `allowed_assets:`. |
|
|
155
|
+
| `rates(currency = "USD")` | Current coin prices. |
|
|
156
|
+
| `verify_webhook(raw_body, signature_header)` | The event Hash, or raises `ArionPay::SignatureError`. Also a module method taking the secret as third argument. |
|
|
157
|
+
|
|
158
|
+
`create_invoice` keyword arguments: `amount:` (required), `currency:`, `order_id:`, `description:`, `customer_email:`, `success_url:`, `cancel_url:`, `callback_url:`, `asset:`, `allowed_assets:`, `expires_min:` (5 to 1440), `metadata:`, `idempotency_key:`, `test:`. `nil` values are not sent.
|
|
159
|
+
|
|
160
|
+
Errors from ArionPay are `ArionPay::APIError` with `status` (HTTP status, `0` when ArionPay could not be reached), `code` (for example `idempotency_key_reused`, `rate_limited`, `unknown_asset`) and `message`. Messages never contain your secret. Bad arguments raise `ArgumentError` before anything is sent.
|
|
161
|
+
|
|
162
|
+
`ArionPay::Client.new` options: `api_key:`, `api_secret:`, `webhook_secret:`, `base_url:` (default `https://merchant.arionpay.com`), `timeout:` (seconds, default 20), `max_attempts:` (default 3), `app_info:` (added to the User-Agent). A client is safe to share between threads (Puma, Sidekiq).
|
|
163
|
+
|
|
164
|
+
## Tests
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
rake test # unit tests; the live test is skipped
|
|
168
|
+
ARIONPAY_IT_BASE_URL=... ARIONPAY_IT_API_KEY=pk_... ARIONPAY_IT_API_SECRET=sk_... \
|
|
169
|
+
ARIONPAY_IT_WEBHOOK_SECRET=whsec_... ruby -Ilib -Itest test/test_integration.rb
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module ArionPay
|
|
10
|
+
DEFAULT_BASE_URL = "https://merchant.arionpay.com"
|
|
11
|
+
|
|
12
|
+
# Scenarios for Client#simulate_payment (test invoices only).
|
|
13
|
+
SCENARIOS = %w[detected confirm exact underpaid_1pct underpaid_5pct overpaid partial_half late].freeze
|
|
14
|
+
|
|
15
|
+
# ArionPay merchant API client. Safe to share between threads.
|
|
16
|
+
#
|
|
17
|
+
# client = ArionPay::Client.new(api_key: ENV["ARIONPAY_API_KEY"], api_secret: ENV["ARIONPAY_API_SECRET"])
|
|
18
|
+
# invoice = client.create_invoice(amount: "49.90", currency: "USD", order_id: "1001")
|
|
19
|
+
# # send the customer to invoice["invoiceUrl"]
|
|
20
|
+
#
|
|
21
|
+
# Invoices are Hashes with string keys and the same field names as the webhook's data.invoice ("id", "status",
|
|
22
|
+
# "orderId", "amountFiat", ...), so one method can handle a webhook and a read.
|
|
23
|
+
class Client
|
|
24
|
+
PK_PATTERN = /\Apk_[A-Za-z0-9_-]{8,64}\z/.freeze
|
|
25
|
+
ID_PATTERN = /\A[0-9a-fA-F]{24}\z/.freeze
|
|
26
|
+
NETWORK_ERRORS = [SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError].freeze
|
|
27
|
+
|
|
28
|
+
# transport (for tests): ->(method, url, headers, body, timeout) { [status, headers_hash, body_string] }
|
|
29
|
+
def initialize(api_key:, api_secret:, webhook_secret: "", base_url: DEFAULT_BASE_URL, timeout: 20, max_attempts: 3,
|
|
30
|
+
app_info: nil, transport: nil, sleeper: nil, clock: nil)
|
|
31
|
+
raise ArgumentError, "api_key must be your store API key (it starts with pk_)." unless api_key.is_a?(String) && api_key.match?(PK_PATTERN)
|
|
32
|
+
raise ArgumentError, "api_secret is required (your store API secret, it starts with sk_)." if api_secret.nil? || api_secret.to_s.empty?
|
|
33
|
+
|
|
34
|
+
uri = begin
|
|
35
|
+
URI.parse(base_url.to_s)
|
|
36
|
+
rescue URI::InvalidURIError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
40
|
+
raise ArgumentError, "base_url must be an http(s) address."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@api_key = api_key
|
|
44
|
+
@api_secret = api_secret.to_s
|
|
45
|
+
@webhook_secret = webhook_secret.to_s
|
|
46
|
+
@scheme = uri.scheme
|
|
47
|
+
@host = uri.host
|
|
48
|
+
@port = uri.port
|
|
49
|
+
@base_path = uri.path.to_s.sub(%r{/+\z}, "")
|
|
50
|
+
@timeout = timeout.to_f
|
|
51
|
+
@max_attempts = max_attempts.to_i.clamp(1, 5)
|
|
52
|
+
@transport = transport || method(:net_http_transport)
|
|
53
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
54
|
+
@clock = clock || -> { Time.now.to_i }
|
|
55
|
+
@user_agent = "arionpay-ruby/#{VERSION} ruby/#{RUBY_VERSION}#{app_info ? " #{app_info}" : ""}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
attr_reader :user_agent
|
|
59
|
+
|
|
60
|
+
# Creates an invoice. Returns {"id", "invoiceUrl", "status", "amountFiat", "currency", "asset", "address",
|
|
61
|
+
# "amountCrypto", "expiresAt", "orderId", "raw"}. Send the customer to "invoiceUrl".
|
|
62
|
+
#
|
|
63
|
+
# Safe to retry: each call carries an idempotency key (a random one when you pass none), so a repeat after a
|
|
64
|
+
# network error returns the same invoice. Pass your own key (e.g. "order-1001-49.90USD") to be safe across
|
|
65
|
+
# restarts. The customer coming back to success_url does NOT prove payment: wait for the invoice.paid webhook.
|
|
66
|
+
def create_invoice(amount:, currency: "USD", order_id: nil, description: nil, customer_email: nil, success_url: nil,
|
|
67
|
+
cancel_url: nil, callback_url: nil, asset: nil, allowed_assets: nil, expires_min: nil,
|
|
68
|
+
metadata: nil, idempotency_key: nil, test: nil)
|
|
69
|
+
raise ArgumentError, "amount is required." if amount.nil? || amount == true || amount == false || amount.to_s.strip.empty?
|
|
70
|
+
|
|
71
|
+
fields = {
|
|
72
|
+
"amount" => amount.to_s,
|
|
73
|
+
"currency" => currency,
|
|
74
|
+
"orderId" => order_id&.to_s,
|
|
75
|
+
"description" => description,
|
|
76
|
+
"customerEmail" => customer_email,
|
|
77
|
+
"successUrl" => success_url,
|
|
78
|
+
"cancelUrl" => cancel_url,
|
|
79
|
+
"callbackUrl" => callback_url,
|
|
80
|
+
"asset" => asset,
|
|
81
|
+
"allowedAssets" => allowed_assets,
|
|
82
|
+
"expiresMin" => expires_min,
|
|
83
|
+
"metadata" => metadata,
|
|
84
|
+
"idempotencyKey" => idempotency_key || "sdk_#{SecureRandom.uuid}",
|
|
85
|
+
"test" => test
|
|
86
|
+
}
|
|
87
|
+
r = request("POST", "/api/v1/invoices", fields.compact, retry_safe: true)
|
|
88
|
+
d = r["data"].is_a?(Hash) ? r["data"] : r
|
|
89
|
+
{
|
|
90
|
+
"id" => (d["id"] || r["id"]).to_s,
|
|
91
|
+
"invoiceUrl" => (d["invoice_url"] || r["invoice_url"]).to_s,
|
|
92
|
+
"status" => (d["status"] || "new").to_s,
|
|
93
|
+
"amountFiat" => d["amount_fiat"],
|
|
94
|
+
"currency" => (d["currency"] || currency).to_s,
|
|
95
|
+
"asset" => d["asset"],
|
|
96
|
+
"address" => d["address"],
|
|
97
|
+
"amountCrypto" => d["amount_crypto"],
|
|
98
|
+
"expiresAt" => d["expires_at"],
|
|
99
|
+
"orderId" => d.key?("order_id") ? d["order_id"] : order_id,
|
|
100
|
+
"raw" => r
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Reads an invoice, signed, straight from ArionPay (webhook field names). Use it to double-check, or to poll.
|
|
105
|
+
def get_invoice(invoice_id)
|
|
106
|
+
check_id(invoice_id)
|
|
107
|
+
ArionPay.normalize_invoice_read(request("GET", "/api/v1/invoices/#{invoice_id}", nil, retry_safe: true))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Sandbox: moves a TEST invoice through a payment scenario (see SCENARIOS); webhooks then fire as for a real
|
|
111
|
+
# payment. Create the test invoice with asset: (e.g. "USDT_TRC20") so a coin is chosen.
|
|
112
|
+
def simulate_payment(invoice_id, scenario = "exact")
|
|
113
|
+
check_id(invoice_id)
|
|
114
|
+
raise ArgumentError, "scenario must be one of: #{SCENARIOS.join(", ")}" unless SCENARIOS.include?(scenario)
|
|
115
|
+
|
|
116
|
+
request("POST", "/api/v1/invoices/#{invoice_id}/simulate", { "scenario" => scenario }, retry_safe: false)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Asks ArionPay to send a signed sample event to your store's webhook address. It never describes a real order.
|
|
120
|
+
def send_test_webhook(event_type = "invoice.paid")
|
|
121
|
+
request("POST", "/api/v1/webhooks/test", { "eventType" => event_type }, retry_safe: false)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Coins you can pass as asset: / allowed_assets: ("USDT_TRC20", "BTC", "LTC", ...), with USD rates.
|
|
125
|
+
def currencies
|
|
126
|
+
r = request("GET", "/api/v1/currencies", nil, retry_safe: true, signed: false)
|
|
127
|
+
r["currencies"].is_a?(Array) ? r["currencies"] : []
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Current coin prices in one fiat currency.
|
|
131
|
+
def rates(currency = "USD")
|
|
132
|
+
request("GET", "/api/v1/rates?currency=#{URI.encode_www_form_component(currency)}", nil, retry_safe: true, signed: false)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Checks a webhook with the webhook_secret given to the constructor. See ArionPay.verify_webhook.
|
|
136
|
+
def verify_webhook(raw_body, signature_header, tolerance_sec: DEFAULT_TOLERANCE_SEC)
|
|
137
|
+
ArionPay.verify_webhook(raw_body, signature_header, @webhook_secret, tolerance_sec: tolerance_sec)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def inspect
|
|
141
|
+
"#<ArionPay::Client #{@scheme}://#{@host}#{@base_path} key=#{@api_key}>" # never shows the secrets
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def check_id(invoice_id)
|
|
147
|
+
return if invoice_id.is_a?(String) && invoice_id.match?(ID_PATTERN)
|
|
148
|
+
|
|
149
|
+
raise ArgumentError, "Invoice id must be the 24-character id ArionPay returned."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def request(method, path, body, retry_safe:, signed: true)
|
|
153
|
+
raw = body.nil? ? nil : JSON.generate(body)
|
|
154
|
+
replay_retry_used = false
|
|
155
|
+
attempt = 0
|
|
156
|
+
begin
|
|
157
|
+
attempt += 1
|
|
158
|
+
send_request(method, path, raw, signed)
|
|
159
|
+
rescue APIError => e
|
|
160
|
+
# Two identical signed requests in the same second have the same signature; only the first is accepted.
|
|
161
|
+
if e.status == 401 && e.message.downcase.include?("already used") && !replay_retry_used
|
|
162
|
+
replay_retry_used = true
|
|
163
|
+
@sleeper.call(1.1)
|
|
164
|
+
retry
|
|
165
|
+
end
|
|
166
|
+
retry_after = Integer(e.body["retryAfterSec"] || 0, exception: false) || 0
|
|
167
|
+
temporary = [0, 502, 503, 504].include?(e.status) || (e.status == 429 && retry_after <= 5)
|
|
168
|
+
raise if !retry_safe || !temporary || attempt >= @max_attempts
|
|
169
|
+
|
|
170
|
+
@sleeper.call(e.status == 429 ? [1, retry_after].max : 0.5 * (3**(attempt - 1)))
|
|
171
|
+
retry
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def send_request(method, path, raw, signed)
|
|
176
|
+
path_only, query = path.split("?", 2)
|
|
177
|
+
full_path = @base_path + path_only + (query ? "?#{query}" : "")
|
|
178
|
+
headers = { "Accept" => "application/json", "User-Agent" => @user_agent }
|
|
179
|
+
if signed
|
|
180
|
+
ts = @clock.call
|
|
181
|
+
headers["x-api-key"] = @api_key
|
|
182
|
+
headers["x-timestamp"] = ts.to_s
|
|
183
|
+
headers["x-signature"] = ArionPay.sign_request(@api_secret, ts, method, full_path, raw || "")
|
|
184
|
+
end
|
|
185
|
+
headers["Content-Type"] = "application/json" if raw
|
|
186
|
+
begin
|
|
187
|
+
status, resp_headers, data = @transport.call(method, "#{@scheme}://#{@host}:#{@port}#{full_path}", headers, raw, @timeout)
|
|
188
|
+
rescue *NETWORK_ERRORS => e
|
|
189
|
+
raise APIError.new("Could not reach ArionPay: #{e.message}", status: 0, code: "network_error", body: {})
|
|
190
|
+
end
|
|
191
|
+
obj = begin
|
|
192
|
+
decoded = data.nil? || data.empty? ? {} : JSON.parse(data.dup.force_encoding(Encoding::UTF_8))
|
|
193
|
+
decoded.is_a?(Hash) ? decoded : {}
|
|
194
|
+
rescue JSON::ParserError, EncodingError
|
|
195
|
+
{}
|
|
196
|
+
end
|
|
197
|
+
return obj if status >= 200 && status < 300
|
|
198
|
+
|
|
199
|
+
message = [obj["error"], obj["message"]].find { |m| m.is_a?(String) } || "HTTP #{status}"
|
|
200
|
+
err_body = obj.dup
|
|
201
|
+
lower = (resp_headers || {}).each_with_object({}) { |(k, v), h| h[k.to_s.downcase] = Array(v).first }
|
|
202
|
+
retry_after = Integer(lower["retry-after"].to_s, exception: false)
|
|
203
|
+
err_body["retryAfterSec"] = retry_after if retry_after&.positive?
|
|
204
|
+
raise APIError.new("ArionPay API error (#{status}): #{message}", status: status, code: obj["code"].is_a?(String) ? obj["code"] : "", body: err_body)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def net_http_transport(method, url, headers, body, timeout)
|
|
208
|
+
uri = URI.parse(url)
|
|
209
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
210
|
+
http.use_ssl = uri.scheme == "https"
|
|
211
|
+
http.open_timeout = timeout
|
|
212
|
+
http.read_timeout = timeout
|
|
213
|
+
http.write_timeout = timeout
|
|
214
|
+
http.max_retries = 0 # retries are decided above (a POST must never be repeated blindly)
|
|
215
|
+
req = method == "GET" ? Net::HTTP::Get.new(uri.request_uri) : Net::HTTP::Post.new(uri.request_uri)
|
|
216
|
+
headers.each { |k, v| req[k] = v }
|
|
217
|
+
req.body = body if body
|
|
218
|
+
res = http.start { |h| h.request(req) } # Net::HTTP never follows redirects
|
|
219
|
+
[res.code.to_i, res.each_header.to_h, res.body.to_s]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
module_function
|
|
224
|
+
|
|
225
|
+
# Turns the signed read (GET /api/v1/invoices/<id>) into the webhook invoice field names.
|
|
226
|
+
def normalize_invoice_read(r)
|
|
227
|
+
out = {
|
|
228
|
+
"id" => (r["invoiceId"] || r["id"]).to_s,
|
|
229
|
+
"status" => r["status"].to_s.downcase,
|
|
230
|
+
"orderId" => r["orderId"],
|
|
231
|
+
"amountFiat" => r["amountFiat"],
|
|
232
|
+
"currency" => r["currency"].to_s,
|
|
233
|
+
"asset" => r["settledCurrency"].to_s,
|
|
234
|
+
"network" => r["network"].to_s,
|
|
235
|
+
"amountCrypto" => r["amountExpected"].to_s,
|
|
236
|
+
"paidCrypto" => (r["amountReceived"] || "0").to_s,
|
|
237
|
+
"confirmedCrypto" => (r["amountConfirmed"] || "0").to_s,
|
|
238
|
+
"overpaid" => r["overpaid"] == true,
|
|
239
|
+
"underpaidWithinTolerance" => r["underpaidWithinTolerance"] == true,
|
|
240
|
+
"late" => r["late"] == true,
|
|
241
|
+
"needsReview" => r["needsReview"] == true,
|
|
242
|
+
"reviewReason" => r["reviewReason"],
|
|
243
|
+
"manual" => r["manual"] == true,
|
|
244
|
+
"test" => r["test"] == true,
|
|
245
|
+
"transactions" => r["transactions"].is_a?(Array) ? r["transactions"] : [],
|
|
246
|
+
"metadata" => r["metadata"].is_a?(Hash) ? r["metadata"] : {},
|
|
247
|
+
"paidAt" => r["settledAt"],
|
|
248
|
+
"txHash" => r["txHash"],
|
|
249
|
+
"confirmations" => r["confirmations"] || 0,
|
|
250
|
+
"requiredConfirmations" => r["requiredConfirmations"] || 0,
|
|
251
|
+
"raw" => r
|
|
252
|
+
}
|
|
253
|
+
out["paidVia"] = r["paidVia"] if r.key?("paidVia")
|
|
254
|
+
out
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArionPay
|
|
4
|
+
# Base class of every error this library raises (bad arguments raise ArgumentError).
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# ArionPay answered with an error, or could not be reached (status 0).
|
|
8
|
+
class APIError < Error
|
|
9
|
+
# HTTP status, 0 when ArionPay could not be reached
|
|
10
|
+
attr_reader :status
|
|
11
|
+
# ArionPay error code, e.g. "idempotency_key_reused", "rate_limited", "unknown_asset" ("" when none)
|
|
12
|
+
attr_reader :code
|
|
13
|
+
# decoded error body ("retryAfterSec" added from a Retry-After header)
|
|
14
|
+
attr_reader :body
|
|
15
|
+
|
|
16
|
+
def initialize(message, status: 0, code: "", body: {})
|
|
17
|
+
super(message)
|
|
18
|
+
@status = status
|
|
19
|
+
@code = code
|
|
20
|
+
@body = body
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A webhook must not be trusted: bad or stale signature, wrong secret, or not an ArionPay event.
|
|
25
|
+
class SignatureError < Error; end
|
|
26
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module ArionPay
|
|
7
|
+
# Webhooks signed more than this many seconds ago (or ahead) are refused. ArionPay signs again on every retry.
|
|
8
|
+
DEFAULT_TOLERANCE_SEC = 300
|
|
9
|
+
|
|
10
|
+
EVENT_TYPES = %w[invoice.pending invoice.partial invoice.paid invoice.expired invoice.needs_review].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The x-signature of one API request:
|
|
15
|
+
# POST: hex HMAC-SHA256(secret, "<ts>.<raw body>"), GET: hex HMAC-SHA256(secret, "<ts>.GET <path and query>").
|
|
16
|
+
def sign_request(secret_key, timestamp, method, path_and_query, raw_body = "")
|
|
17
|
+
data = if method.to_s.casecmp?("GET")
|
|
18
|
+
"#{timestamp.to_i}.GET #{path_and_query}"
|
|
19
|
+
else
|
|
20
|
+
"#{timestamp.to_i}.".b + raw_body.to_s.b
|
|
21
|
+
end
|
|
22
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret_key.to_s, data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reads "t=<unix seconds>,v1=<hex>". Returns [t, v1] or nil when the header is malformed.
|
|
26
|
+
def parse_signature_header(header)
|
|
27
|
+
return nil unless header.is_a?(String)
|
|
28
|
+
|
|
29
|
+
values = {}
|
|
30
|
+
header.split(",").each do |part|
|
|
31
|
+
key, value = part.split("=", 2)
|
|
32
|
+
values[key.strip] = value.strip if key && value
|
|
33
|
+
end
|
|
34
|
+
t = values["t"]
|
|
35
|
+
v1 = values["v1"]
|
|
36
|
+
return nil unless t&.match?(/\A[0-9]{1,12}\z/) && v1&.match?(/\A[0-9a-fA-F]{64}\z/)
|
|
37
|
+
|
|
38
|
+
[t.to_i, v1.downcase]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# True when header is a valid, fresh signature of exactly raw_body. Never raises.
|
|
42
|
+
def valid_signature?(raw_body, header, webhook_secret, tolerance_sec: DEFAULT_TOLERANCE_SEC, now: Time.now.to_i)
|
|
43
|
+
return false unless raw_body.is_a?(String) && webhook_secret.is_a?(String) && !webhook_secret.empty?
|
|
44
|
+
|
|
45
|
+
parsed = parse_signature_header(header)
|
|
46
|
+
return false unless parsed
|
|
47
|
+
|
|
48
|
+
t, v1 = parsed
|
|
49
|
+
return false if (now.to_i - t).abs > tolerance_sec
|
|
50
|
+
|
|
51
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, "#{t}.".b + raw_body.b)
|
|
52
|
+
OpenSSL.fixed_length_secure_compare(expected, v1)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Checks a webhook and returns the event (a Hash with string keys, as ArionPay sent it), or raises SignatureError.
|
|
56
|
+
#
|
|
57
|
+
# raw_body must be the exact bytes ArionPay sent: request.raw_post (Rails), request.body.read (Sinatra/Rack),
|
|
58
|
+
# never parsed and re-encoded JSON.
|
|
59
|
+
def verify_webhook(raw_body, signature_header, webhook_secret, tolerance_sec: DEFAULT_TOLERANCE_SEC, now: Time.now.to_i)
|
|
60
|
+
if webhook_secret.nil? || webhook_secret.to_s.empty?
|
|
61
|
+
raise SignatureError, "The webhook secret is empty. Copy it from the Webhooks page of your ArionPay dashboard (it starts with whsec_)."
|
|
62
|
+
end
|
|
63
|
+
unless raw_body.is_a?(String)
|
|
64
|
+
raise SignatureError, "Pass the raw request body (a String: request.raw_post in Rails, request.body.read in Sinatra), not parsed JSON."
|
|
65
|
+
end
|
|
66
|
+
raise SignatureError, "Missing or malformed ArionPay-Signature header." unless parse_signature_header(signature_header)
|
|
67
|
+
unless valid_signature?(raw_body, signature_header, webhook_secret.to_s, tolerance_sec: tolerance_sec, now: now)
|
|
68
|
+
raise SignatureError, "Invalid webhook signature or stale timestamp. Check the webhook secret and your server clock."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
text = raw_body.dup.force_encoding(Encoding::UTF_8)
|
|
72
|
+
raise SignatureError, "The webhook body is not JSON." unless text.valid_encoding?
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
event = JSON.parse(text)
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
raise SignatureError, "The webhook body is not JSON."
|
|
78
|
+
end
|
|
79
|
+
valid = event.is_a?(Hash) && event["id"].is_a?(String) && !event["id"].empty? &&
|
|
80
|
+
event["type"].is_a?(String) && !event["type"].empty? &&
|
|
81
|
+
event["data"].is_a?(Hash) && event["data"]["invoice"].is_a?(Hash)
|
|
82
|
+
raise SignatureError, "The webhook body is not an ArionPay event." unless valid
|
|
83
|
+
|
|
84
|
+
event
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# True for events made by "Send test webhook": a made-up invoice that must never change an order.
|
|
88
|
+
def test_event?(event)
|
|
89
|
+
return false unless event.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
invoice = event.dig("data", "invoice")
|
|
92
|
+
event["test"] == true || event["id"].to_s.start_with?("evt_test_") ||
|
|
93
|
+
(invoice.is_a?(Hash) && invoice["id"].to_s.start_with?("inv_test_"))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True when the invoice price is exactly this order total (to the cent) in this currency: the check to make before
|
|
97
|
+
# you deliver. Crypto amounts never need comparing: ArionPay decides whether an invoice is paid.
|
|
98
|
+
def order_matches?(invoice, order_total, currency)
|
|
99
|
+
return false unless invoice.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
paid = fiat(invoice["amountFiat"])
|
|
102
|
+
!paid.nil? && paid == fiat(order_total) && invoice["currency"].to_s.casecmp?(currency.to_s)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# "49.90" for 49.9, "49.9" or "49.90"; nil when it is not a number.
|
|
106
|
+
def fiat(value)
|
|
107
|
+
return nil if value.nil? || value == true || value == false
|
|
108
|
+
|
|
109
|
+
f = Float(value.to_s)
|
|
110
|
+
f.finite? ? format("%.2f", f) : nil
|
|
111
|
+
rescue ArgumentError, TypeError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/arionpay.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ArionPay: create crypto invoices, read them, and verify webhooks. Payments go straight to the merchant's own wallet.
|
|
4
|
+
# Use it on your server only: it holds your API secret.
|
|
5
|
+
require_relative "arionpay/version"
|
|
6
|
+
require_relative "arionpay/errors"
|
|
7
|
+
require_relative "arionpay/webhook"
|
|
8
|
+
require_relative "arionpay/client"
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: arionpay
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ArionPay
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Create crypto invoices (Bitcoin, USDT, Litecoin, Ethereum, TRON and more),
|
|
14
|
+
send customers to the ArionPay payment page, and verify signed webhooks. Payments
|
|
15
|
+
go straight to your own wallet. Standard library only; works with Rails, Sinatra,
|
|
16
|
+
Hanami and any Rack app.
|
|
17
|
+
email:
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/arionpay.rb
|
|
26
|
+
- lib/arionpay/client.rb
|
|
27
|
+
- lib/arionpay/errors.rb
|
|
28
|
+
- lib/arionpay/version.rb
|
|
29
|
+
- lib/arionpay/webhook.rb
|
|
30
|
+
homepage: https://arionpay.com
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
source_code_uri: https://github.com/ArionPay-Dev/arionpay-ruby
|
|
35
|
+
changelog_uri: https://github.com/ArionPay-Dev/arionpay-ruby/blob/main/CHANGELOG.md
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
post_install_message:
|
|
38
|
+
rdoc_options: []
|
|
39
|
+
require_paths:
|
|
40
|
+
- lib
|
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.0'
|
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '0'
|
|
51
|
+
requirements: []
|
|
52
|
+
rubygems_version: 3.5.22
|
|
53
|
+
signing_key:
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: 'Official Ruby client for ArionPay crypto payments: invoices, signed webhooks,
|
|
56
|
+
sandbox.'
|
|
57
|
+
test_files: []
|