payitgo 0.1.0__tar.gz
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.
- payitgo-0.1.0/LICENSE +21 -0
- payitgo-0.1.0/PKG-INFO +196 -0
- payitgo-0.1.0/README.md +184 -0
- payitgo-0.1.0/payitgo/__init__.py +273 -0
- payitgo-0.1.0/payitgo.egg-info/PKG-INFO +196 -0
- payitgo-0.1.0/payitgo.egg-info/SOURCES.txt +9 -0
- payitgo-0.1.0/payitgo.egg-info/dependency_links.txt +1 -0
- payitgo-0.1.0/payitgo.egg-info/top_level.txt +1 -0
- payitgo-0.1.0/pyproject.toml +20 -0
- payitgo-0.1.0/setup.cfg +4 -0
- payitgo-0.1.0/tests/test_payitgo.py +273 -0
payitgo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 payitgo
|
|
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.
|
payitgo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: payitgo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the payitgo crypto payment gateway
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/thedivinez/payitgo-sdk/tree/main/python
|
|
7
|
+
Keywords: payitgo,payments,crypto,usdt,bitcoin
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# payitgo for Python
|
|
14
|
+
|
|
15
|
+
Accept crypto payments with [payitgo](https://api.payitgo.co/docs) from Python 3.8+. No dependencies.
|
|
16
|
+
|
|
17
|
+
Full reference, with every method, option, type and error: [api.payitgo.co/docs/sdk-python](https://api.payitgo.co/docs/sdk-python).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install payitgo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The package is `payitgo` on PyPI; you import it as `payitgo`.
|
|
24
|
+
|
|
25
|
+
## Accept a payment in three steps
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import os
|
|
29
|
+
import payitgo
|
|
30
|
+
from flask import Flask, abort, redirect, request
|
|
31
|
+
|
|
32
|
+
client = payitgo.Client(
|
|
33
|
+
os.environ["PAYITGO_KEY"],
|
|
34
|
+
base_url=os.environ["PAYITGO_BASE_URL"],
|
|
35
|
+
webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"], # "whsec_…": see Webhooks (IPN) below
|
|
36
|
+
)
|
|
37
|
+
app = Flask(__name__)
|
|
38
|
+
|
|
39
|
+
# 1. Create a payment and send the customer to the checkout page.
|
|
40
|
+
@app.post("/checkout")
|
|
41
|
+
def checkout():
|
|
42
|
+
payment = client.payments.create(
|
|
43
|
+
order_id=request.form["order_id"], # your order id: safe to retry
|
|
44
|
+
amount_usd=request.form["total"], # e.g. "19.99"
|
|
45
|
+
# token="USDT_TRC20", # optional: without it the customer picks the coin
|
|
46
|
+
success_url="https://shop.example/thanks",
|
|
47
|
+
)
|
|
48
|
+
return redirect(payment.checkout_url)
|
|
49
|
+
|
|
50
|
+
# 2. Receive the notification (the complete handler is under Webhooks (IPN) below).
|
|
51
|
+
# Use the raw body: the signature covers its exact bytes.
|
|
52
|
+
@app.post("/payitgo/ipn")
|
|
53
|
+
def ipn():
|
|
54
|
+
try:
|
|
55
|
+
event = client.webhooks.construct_event(request.get_data(), request.headers.get("X-Webhook-Signature"))
|
|
56
|
+
except payitgo.SignatureVerificationError:
|
|
57
|
+
abort(400)
|
|
58
|
+
# 3. Fulfil the order once the payment is final.
|
|
59
|
+
if event.type == "payment.confirmed":
|
|
60
|
+
mark_order_paid(event.data.order_id) # make this idempotent: events can arrive twice
|
|
61
|
+
return "", 200
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Start with your test key (`sk_test_…`) and testnet coins; switch to the live key
|
|
65
|
+
(`sk_live_…`) when the account owner has turned live mode on.
|
|
66
|
+
|
|
67
|
+
## Your own payment page
|
|
68
|
+
|
|
69
|
+
`checkout_url` is only for payitgo's hosted page. To show the payment on your own page,
|
|
70
|
+
render the payment your server gets back (`deposit_address`, `crypto_amount`,
|
|
71
|
+
`destination_tag` for XRP, a QR code of `qr_payload`, `expires_at`); no `checkout` calls
|
|
72
|
+
are needed.
|
|
73
|
+
|
|
74
|
+
- **You know the coin:** create the payment with `token` and render the payment it returns.
|
|
75
|
+
- **The customer chooses it:** create the payment without `token`, show
|
|
76
|
+
`payment.token_options` on your page, then call
|
|
77
|
+
`client.payments.select_token(payment.id, chosen)` from your server and render the
|
|
78
|
+
payment it returns: it now has its coin, address, amount, locked rate and a deadline 15
|
|
79
|
+
minutes away.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
payment = client.payments.create(order_id="ORD-1", amount_usd=10)
|
|
83
|
+
# your page shows payment.token_options; the customer picks one
|
|
84
|
+
chosen = client.payments.select_token(payment.id, "USDT_TRC20") # or your order id
|
|
85
|
+
# render chosen.deposit_address, chosen.crypto_amount, chosen.qr_payload, chosen.expires_at
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The coin is chosen once: choosing again is a `409` (`payments.retrieve` then returns the
|
|
89
|
+
payment with its coin). A coin outside `token_options`, or a payment that can no longer be
|
|
90
|
+
paid, is a `400`.
|
|
91
|
+
|
|
92
|
+
## Webhooks (IPN)
|
|
93
|
+
|
|
94
|
+
payitgo POSTs a signed notification for every change of a payment (`payment.confirmed`,
|
|
95
|
+
`payment.expired`, …), of a direct settlement and of a payout. Each mode has one webhook:
|
|
96
|
+
a test key manages the test one, a live key the live one.
|
|
97
|
+
|
|
98
|
+
**Where the notifications go.** Set the URL once per mode (https:// on a public host,
|
|
99
|
+
accepting POST requests), or give a payment its own when you create it:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Once per mode (safe to repeat):
|
|
103
|
+
client.webhooks.set("https://shop.example/payitgo/ipn")
|
|
104
|
+
|
|
105
|
+
# Or per payment: its events go there instead of the webhook's URL.
|
|
106
|
+
client.payments.create(order_id="ORD-1", amount_usd=10, webhook_url="https://shop.example/orders/ipn")
|
|
107
|
+
|
|
108
|
+
# Or as the client's default for every payment it creates (a payment's own webhook_url wins):
|
|
109
|
+
shop = payitgo.Client(os.environ["PAYITGO_KEY"], webhook_url="https://shop.example/orders/ipn")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A storefront's key cannot set the account's URL (`403`): pass `webhook_url` instead.
|
|
113
|
+
Payout events always go to the webhook's URL.
|
|
114
|
+
|
|
115
|
+
**The signing secret.** Every notification of a mode is signed with that mode's secret
|
|
116
|
+
(`whsec_…`), whichever URL it goes to. Copy it from the dashboard or read it with
|
|
117
|
+
`client.webhooks.get().secret`, and give it to the client once:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
client = payitgo.Client(os.environ["PAYITGO_KEY"], webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Receiving them** (Flask). Verify the raw body, handle each event once, and answer
|
|
124
|
+
`2xx` within 10 seconds; otherwise payitgo retries after 1 min, 5 min, 30 min, 2 h, 6 h and
|
|
125
|
+
24 h.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
@app.post("/payitgo/ipn")
|
|
129
|
+
def ipn():
|
|
130
|
+
try:
|
|
131
|
+
# Checks the signature with the client's webhook_secret and refuses anything older than 5 minutes.
|
|
132
|
+
event = client.webhooks.construct_event(request.get_data(), request.headers.get("X-Webhook-Signature"))
|
|
133
|
+
except payitgo.SignatureVerificationError:
|
|
134
|
+
abort(400)
|
|
135
|
+
# The same event can arrive more than once: handle each event id once,
|
|
136
|
+
# e.g. by inserting it into a table with a unique key.
|
|
137
|
+
if not record_event_once(event.id):
|
|
138
|
+
return "", 200
|
|
139
|
+
if event.type == "payment.confirmed":
|
|
140
|
+
jobs.enqueue("fulfil", event.data.order_id) # slow work off the request
|
|
141
|
+
elif event.type == "payment.expired":
|
|
142
|
+
jobs.enqueue("release_stock", event.data.order_id)
|
|
143
|
+
return "", 200
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Testing it.** `client.webhooks.test()` queues a signed `ping` event to the webhook's
|
|
147
|
+
URL and returns its delivery (`400` when no URL is set).
|
|
148
|
+
|
|
149
|
+
## API
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
client = payitgo.Client(api_key, base_url="https://api.example.com", # or PAYITGO_BASE_URL
|
|
153
|
+
timeout=30, max_retries=2, # network errors, 429, 5xx
|
|
154
|
+
webhook_secret="whsec_…", # for webhooks.construct_event
|
|
155
|
+
webhook_url="https://…") # default webhook_url of payments.create
|
|
156
|
+
|
|
157
|
+
client.livemode # False for sk_test_ keys
|
|
158
|
+
client.payments.create(order_id, amount_usd, token, webhook_url=None, description=None, metadata=None,
|
|
159
|
+
customer_email=None, success_url=None, cancel_url=None)
|
|
160
|
+
client.payments.select_token("pay_…", token) # or your order id: the payment with its coin
|
|
161
|
+
client.payments.retrieve("pay_…") # or your order id
|
|
162
|
+
client.payments.list(status="confirmed", store_id="sto_…", limit=50, offset=0) # every filter optional
|
|
163
|
+
client.tokens.list() # [{token, chain, min_usd, network_fee_usd, fee_note}]
|
|
164
|
+
client.tokens.list(amount_usd="49.90") # the coins a $49.90 payment offers, with amount_due_usd
|
|
165
|
+
client.checkout.retrieve(checkout_token) # public payer view
|
|
166
|
+
client.webhooks.get() # {livemode, url, secret, created_at, updated_at}
|
|
167
|
+
client.webhooks.set(url) # the webhook
|
|
168
|
+
client.webhooks.remove() # the webhook, url None (the secret stays)
|
|
169
|
+
client.webhooks.test() # the delivery of a "ping" event
|
|
170
|
+
client.webhooks.construct_event(raw_body, signature_header, secret=None, tolerance=300)
|
|
171
|
+
payitgo.Webhook.construct_event(raw_body, signature_header, secret, tolerance=300) # no client needed
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Responses are dicts that also allow attribute access: `payment["status"]` or
|
|
175
|
+
`payment.status`. Field names are the API's own (see the
|
|
176
|
+
[payment object](https://api.payitgo.co/docs/payments) and [webhooks](https://api.payitgo.co/docs/webhooks)).
|
|
177
|
+
`payitgo.WEBHOOK_EVENTS` lists the event types.
|
|
178
|
+
|
|
179
|
+
## Errors
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
try:
|
|
183
|
+
client.payments.create(order_id="ORD-1", amount_usd=1, token="BTC")
|
|
184
|
+
except payitgo.PayitgoError as e:
|
|
185
|
+
print(e.status, e.message) # 400 minimum payment in BTC is $10 right now (network fees)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`PayitgoError.status` is the HTTP status (0 for a network error).
|
|
189
|
+
`construct_event` raises `SignatureVerificationError` for a bad signature, a stale
|
|
190
|
+
timestamp, or when it has no secret (none passed and no `webhook_secret`).
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
python3 -m unittest discover -s tests
|
|
196
|
+
```
|
payitgo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# payitgo for Python
|
|
2
|
+
|
|
3
|
+
Accept crypto payments with [payitgo](https://api.payitgo.co/docs) from Python 3.8+. No dependencies.
|
|
4
|
+
|
|
5
|
+
Full reference, with every method, option, type and error: [api.payitgo.co/docs/sdk-python](https://api.payitgo.co/docs/sdk-python).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install payitgo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package is `payitgo` on PyPI; you import it as `payitgo`.
|
|
12
|
+
|
|
13
|
+
## Accept a payment in three steps
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
import payitgo
|
|
18
|
+
from flask import Flask, abort, redirect, request
|
|
19
|
+
|
|
20
|
+
client = payitgo.Client(
|
|
21
|
+
os.environ["PAYITGO_KEY"],
|
|
22
|
+
base_url=os.environ["PAYITGO_BASE_URL"],
|
|
23
|
+
webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"], # "whsec_…": see Webhooks (IPN) below
|
|
24
|
+
)
|
|
25
|
+
app = Flask(__name__)
|
|
26
|
+
|
|
27
|
+
# 1. Create a payment and send the customer to the checkout page.
|
|
28
|
+
@app.post("/checkout")
|
|
29
|
+
def checkout():
|
|
30
|
+
payment = client.payments.create(
|
|
31
|
+
order_id=request.form["order_id"], # your order id: safe to retry
|
|
32
|
+
amount_usd=request.form["total"], # e.g. "19.99"
|
|
33
|
+
# token="USDT_TRC20", # optional: without it the customer picks the coin
|
|
34
|
+
success_url="https://shop.example/thanks",
|
|
35
|
+
)
|
|
36
|
+
return redirect(payment.checkout_url)
|
|
37
|
+
|
|
38
|
+
# 2. Receive the notification (the complete handler is under Webhooks (IPN) below).
|
|
39
|
+
# Use the raw body: the signature covers its exact bytes.
|
|
40
|
+
@app.post("/payitgo/ipn")
|
|
41
|
+
def ipn():
|
|
42
|
+
try:
|
|
43
|
+
event = client.webhooks.construct_event(request.get_data(), request.headers.get("X-Webhook-Signature"))
|
|
44
|
+
except payitgo.SignatureVerificationError:
|
|
45
|
+
abort(400)
|
|
46
|
+
# 3. Fulfil the order once the payment is final.
|
|
47
|
+
if event.type == "payment.confirmed":
|
|
48
|
+
mark_order_paid(event.data.order_id) # make this idempotent: events can arrive twice
|
|
49
|
+
return "", 200
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Start with your test key (`sk_test_…`) and testnet coins; switch to the live key
|
|
53
|
+
(`sk_live_…`) when the account owner has turned live mode on.
|
|
54
|
+
|
|
55
|
+
## Your own payment page
|
|
56
|
+
|
|
57
|
+
`checkout_url` is only for payitgo's hosted page. To show the payment on your own page,
|
|
58
|
+
render the payment your server gets back (`deposit_address`, `crypto_amount`,
|
|
59
|
+
`destination_tag` for XRP, a QR code of `qr_payload`, `expires_at`); no `checkout` calls
|
|
60
|
+
are needed.
|
|
61
|
+
|
|
62
|
+
- **You know the coin:** create the payment with `token` and render the payment it returns.
|
|
63
|
+
- **The customer chooses it:** create the payment without `token`, show
|
|
64
|
+
`payment.token_options` on your page, then call
|
|
65
|
+
`client.payments.select_token(payment.id, chosen)` from your server and render the
|
|
66
|
+
payment it returns: it now has its coin, address, amount, locked rate and a deadline 15
|
|
67
|
+
minutes away.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
payment = client.payments.create(order_id="ORD-1", amount_usd=10)
|
|
71
|
+
# your page shows payment.token_options; the customer picks one
|
|
72
|
+
chosen = client.payments.select_token(payment.id, "USDT_TRC20") # or your order id
|
|
73
|
+
# render chosen.deposit_address, chosen.crypto_amount, chosen.qr_payload, chosen.expires_at
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The coin is chosen once: choosing again is a `409` (`payments.retrieve` then returns the
|
|
77
|
+
payment with its coin). A coin outside `token_options`, or a payment that can no longer be
|
|
78
|
+
paid, is a `400`.
|
|
79
|
+
|
|
80
|
+
## Webhooks (IPN)
|
|
81
|
+
|
|
82
|
+
payitgo POSTs a signed notification for every change of a payment (`payment.confirmed`,
|
|
83
|
+
`payment.expired`, …), of a direct settlement and of a payout. Each mode has one webhook:
|
|
84
|
+
a test key manages the test one, a live key the live one.
|
|
85
|
+
|
|
86
|
+
**Where the notifications go.** Set the URL once per mode (https:// on a public host,
|
|
87
|
+
accepting POST requests), or give a payment its own when you create it:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# Once per mode (safe to repeat):
|
|
91
|
+
client.webhooks.set("https://shop.example/payitgo/ipn")
|
|
92
|
+
|
|
93
|
+
# Or per payment: its events go there instead of the webhook's URL.
|
|
94
|
+
client.payments.create(order_id="ORD-1", amount_usd=10, webhook_url="https://shop.example/orders/ipn")
|
|
95
|
+
|
|
96
|
+
# Or as the client's default for every payment it creates (a payment's own webhook_url wins):
|
|
97
|
+
shop = payitgo.Client(os.environ["PAYITGO_KEY"], webhook_url="https://shop.example/orders/ipn")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A storefront's key cannot set the account's URL (`403`): pass `webhook_url` instead.
|
|
101
|
+
Payout events always go to the webhook's URL.
|
|
102
|
+
|
|
103
|
+
**The signing secret.** Every notification of a mode is signed with that mode's secret
|
|
104
|
+
(`whsec_…`), whichever URL it goes to. Copy it from the dashboard or read it with
|
|
105
|
+
`client.webhooks.get().secret`, and give it to the client once:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
client = payitgo.Client(os.environ["PAYITGO_KEY"], webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Receiving them** (Flask). Verify the raw body, handle each event once, and answer
|
|
112
|
+
`2xx` within 10 seconds; otherwise payitgo retries after 1 min, 5 min, 30 min, 2 h, 6 h and
|
|
113
|
+
24 h.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@app.post("/payitgo/ipn")
|
|
117
|
+
def ipn():
|
|
118
|
+
try:
|
|
119
|
+
# Checks the signature with the client's webhook_secret and refuses anything older than 5 minutes.
|
|
120
|
+
event = client.webhooks.construct_event(request.get_data(), request.headers.get("X-Webhook-Signature"))
|
|
121
|
+
except payitgo.SignatureVerificationError:
|
|
122
|
+
abort(400)
|
|
123
|
+
# The same event can arrive more than once: handle each event id once,
|
|
124
|
+
# e.g. by inserting it into a table with a unique key.
|
|
125
|
+
if not record_event_once(event.id):
|
|
126
|
+
return "", 200
|
|
127
|
+
if event.type == "payment.confirmed":
|
|
128
|
+
jobs.enqueue("fulfil", event.data.order_id) # slow work off the request
|
|
129
|
+
elif event.type == "payment.expired":
|
|
130
|
+
jobs.enqueue("release_stock", event.data.order_id)
|
|
131
|
+
return "", 200
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Testing it.** `client.webhooks.test()` queues a signed `ping` event to the webhook's
|
|
135
|
+
URL and returns its delivery (`400` when no URL is set).
|
|
136
|
+
|
|
137
|
+
## API
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
client = payitgo.Client(api_key, base_url="https://api.example.com", # or PAYITGO_BASE_URL
|
|
141
|
+
timeout=30, max_retries=2, # network errors, 429, 5xx
|
|
142
|
+
webhook_secret="whsec_…", # for webhooks.construct_event
|
|
143
|
+
webhook_url="https://…") # default webhook_url of payments.create
|
|
144
|
+
|
|
145
|
+
client.livemode # False for sk_test_ keys
|
|
146
|
+
client.payments.create(order_id, amount_usd, token, webhook_url=None, description=None, metadata=None,
|
|
147
|
+
customer_email=None, success_url=None, cancel_url=None)
|
|
148
|
+
client.payments.select_token("pay_…", token) # or your order id: the payment with its coin
|
|
149
|
+
client.payments.retrieve("pay_…") # or your order id
|
|
150
|
+
client.payments.list(status="confirmed", store_id="sto_…", limit=50, offset=0) # every filter optional
|
|
151
|
+
client.tokens.list() # [{token, chain, min_usd, network_fee_usd, fee_note}]
|
|
152
|
+
client.tokens.list(amount_usd="49.90") # the coins a $49.90 payment offers, with amount_due_usd
|
|
153
|
+
client.checkout.retrieve(checkout_token) # public payer view
|
|
154
|
+
client.webhooks.get() # {livemode, url, secret, created_at, updated_at}
|
|
155
|
+
client.webhooks.set(url) # the webhook
|
|
156
|
+
client.webhooks.remove() # the webhook, url None (the secret stays)
|
|
157
|
+
client.webhooks.test() # the delivery of a "ping" event
|
|
158
|
+
client.webhooks.construct_event(raw_body, signature_header, secret=None, tolerance=300)
|
|
159
|
+
payitgo.Webhook.construct_event(raw_body, signature_header, secret, tolerance=300) # no client needed
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Responses are dicts that also allow attribute access: `payment["status"]` or
|
|
163
|
+
`payment.status`. Field names are the API's own (see the
|
|
164
|
+
[payment object](https://api.payitgo.co/docs/payments) and [webhooks](https://api.payitgo.co/docs/webhooks)).
|
|
165
|
+
`payitgo.WEBHOOK_EVENTS` lists the event types.
|
|
166
|
+
|
|
167
|
+
## Errors
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
try:
|
|
171
|
+
client.payments.create(order_id="ORD-1", amount_usd=1, token="BTC")
|
|
172
|
+
except payitgo.PayitgoError as e:
|
|
173
|
+
print(e.status, e.message) # 400 minimum payment in BTC is $10 right now (network fees)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`PayitgoError.status` is the HTTP status (0 for a network error).
|
|
177
|
+
`construct_event` raises `SignatureVerificationError` for a bad signature, a stale
|
|
178
|
+
timestamp, or when it has no secret (none passed and no `webhook_secret`).
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
python3 -m unittest discover -s tests
|
|
184
|
+
```
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Python client for the payitgo crypto payment gateway.
|
|
2
|
+
|
|
3
|
+
import payitgo
|
|
4
|
+
|
|
5
|
+
client = payitgo.Client(os.environ["PAYITGO_KEY"], base_url="https://api.example.com")
|
|
6
|
+
payment = client.payments.create(order_id="ORD-1001", amount_usd=10, token="USDT_TRC20")
|
|
7
|
+
# send the customer to payment.checkout_url; fulfil on the payment.confirmed webhook
|
|
8
|
+
|
|
9
|
+
Use the client from your server only: the API key is a secret.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__all__ = ["Client", "Webhook", "PayitgoError", "SignatureVerificationError", "PayitgoObject", "WEBHOOK_EVENTS"]
|
|
23
|
+
|
|
24
|
+
# Used when neither base_url nor PAYITGO_BASE_URL is set.
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.payitgo.co"
|
|
26
|
+
|
|
27
|
+
#: The events payitgo sends. A test (``client.webhooks.test()``) sends "ping".
|
|
28
|
+
WEBHOOK_EVENTS = (
|
|
29
|
+
"payment.partially_paid", "payment.detecting", "payment.confirmed", "payment.underpaid",
|
|
30
|
+
"payment.expired", "payment.failed", "payment.wrong_asset", "payment.late_deposit",
|
|
31
|
+
"payout.approved", "payout.rejected", "payout.sent", "settlement.completed", "settlement.failed",
|
|
32
|
+
"ping",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PayitgoError(Exception):
|
|
37
|
+
"""The API refused the request. ``status`` is the HTTP status, or 0 for a network error."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message, status=0):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.message = message
|
|
42
|
+
self.status = status
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SignatureVerificationError(Exception):
|
|
46
|
+
"""A webhook request whose signature or timestamp did not check out."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PayitgoObject(dict):
|
|
50
|
+
"""A dict whose keys can also be read as attributes: ``payment.status``."""
|
|
51
|
+
|
|
52
|
+
def __getattr__(self, name):
|
|
53
|
+
try:
|
|
54
|
+
return self[name]
|
|
55
|
+
except KeyError:
|
|
56
|
+
raise AttributeError(name) from None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _wrap(value):
|
|
60
|
+
if isinstance(value, dict):
|
|
61
|
+
return PayitgoObject({k: _wrap(v) for k, v in value.items()})
|
|
62
|
+
if isinstance(value, list):
|
|
63
|
+
return [_wrap(v) for v in value]
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Client:
|
|
68
|
+
"""The payitgo client, for one secret key (``sk_test_…`` or ``sk_live_…``).
|
|
69
|
+
|
|
70
|
+
``webhook_secret`` is the signing secret of the key's mode (``whsec_…``), which
|
|
71
|
+
``webhooks.construct_event`` uses when given none; ``webhook_url`` is sent as the
|
|
72
|
+
``webhook_url`` of every payment this client creates without its own.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, api_key, base_url=None, timeout=30, max_retries=2, webhook_secret=None, webhook_url=None):
|
|
76
|
+
if not isinstance(api_key, str) or not api_key.startswith(("sk_test_", "sk_live_")):
|
|
77
|
+
raise ValueError("payitgo: the API key must start with sk_test_ or sk_live_")
|
|
78
|
+
base_url = base_url or os.environ.get("PAYITGO_BASE_URL") or DEFAULT_BASE_URL
|
|
79
|
+
if not base_url:
|
|
80
|
+
raise ValueError("payitgo: set base_url (or PAYITGO_BASE_URL) to your gateway's address")
|
|
81
|
+
self._api_key = api_key
|
|
82
|
+
self._base_url = base_url.rstrip("/")
|
|
83
|
+
self._timeout = timeout
|
|
84
|
+
self._max_retries = max_retries
|
|
85
|
+
self._webhook_secret = webhook_secret or None
|
|
86
|
+
self._webhook_url = webhook_url or None
|
|
87
|
+
#: False for a test key, True for a live key.
|
|
88
|
+
self.livemode = api_key.startswith("sk_live_")
|
|
89
|
+
self.payments = _Payments(self)
|
|
90
|
+
self.tokens = _Tokens(self)
|
|
91
|
+
self.checkout = _Checkout(self)
|
|
92
|
+
self.webhooks = _Webhooks(self)
|
|
93
|
+
|
|
94
|
+
def _request(self, method, path, body=None, authenticated=True):
|
|
95
|
+
headers = {"Accept": "application/json", "User-Agent": "payitgo-python/" + __version__}
|
|
96
|
+
data = None
|
|
97
|
+
if body is not None:
|
|
98
|
+
data = json.dumps(body).encode()
|
|
99
|
+
headers["Content-Type"] = "application/json"
|
|
100
|
+
if authenticated:
|
|
101
|
+
headers["Authorization"] = "Bearer " + self._api_key
|
|
102
|
+
|
|
103
|
+
attempt = 0
|
|
104
|
+
while True:
|
|
105
|
+
req = urllib.request.Request(self._base_url + path, data=data, method=method, headers=headers)
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
108
|
+
raw = resp.read()
|
|
109
|
+
return _wrap(json.loads(raw)) if raw else None
|
|
110
|
+
except urllib.error.HTTPError as e:
|
|
111
|
+
with e:
|
|
112
|
+
raw = e.read()
|
|
113
|
+
if (e.code == 429 or e.code >= 500) and attempt < self._max_retries:
|
|
114
|
+
attempt += 1
|
|
115
|
+
time.sleep(0.5 * 2 ** (attempt - 1))
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
message = json.loads(raw).get("error") or e.reason
|
|
119
|
+
except (ValueError, AttributeError):
|
|
120
|
+
message = e.reason or "HTTP %d" % e.code
|
|
121
|
+
raise PayitgoError(message, e.code) from None
|
|
122
|
+
except (urllib.error.URLError, OSError) as e:
|
|
123
|
+
if attempt < self._max_retries:
|
|
124
|
+
attempt += 1
|
|
125
|
+
time.sleep(0.5 * 2 ** (attempt - 1))
|
|
126
|
+
continue
|
|
127
|
+
raise PayitgoError("request failed: %s" % e, 0) from None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _Payments:
|
|
131
|
+
def __init__(self, client):
|
|
132
|
+
self._client = client
|
|
133
|
+
|
|
134
|
+
def create(self, order_id, amount_usd, token=None, webhook_url=None, **optional):
|
|
135
|
+
"""Creates a payment. Safe to retry: an order with an open payment returns that payment.
|
|
136
|
+
|
|
137
|
+
Leave token out to let the customer choose the coin on the checkout page.
|
|
138
|
+
webhook_url (https) gets the payment's events instead of the webhook's URL,
|
|
139
|
+
signed with the same secret (default: the client's webhook_url).
|
|
140
|
+
Optional fields: tokens (the coins to offer), description, metadata,
|
|
141
|
+
customer_email, success_url, cancel_url.
|
|
142
|
+
"""
|
|
143
|
+
body = {"order_id": order_id, "amount_usd": str(amount_usd)}
|
|
144
|
+
if token:
|
|
145
|
+
body["token"] = token
|
|
146
|
+
webhook_url = webhook_url if webhook_url is not None else self._client._webhook_url
|
|
147
|
+
if webhook_url:
|
|
148
|
+
body["webhook_url"] = webhook_url
|
|
149
|
+
body.update({k: v for k, v in optional.items() if v is not None})
|
|
150
|
+
return self._client._request("POST", "/v1/payments", body)
|
|
151
|
+
|
|
152
|
+
def retrieve(self, id):
|
|
153
|
+
"""A payment by its id (pay_…) or by your order id (the order's open payment)."""
|
|
154
|
+
return self._client._request("GET", "/v1/payments/" + urllib.parse.quote(id, safe=""))
|
|
155
|
+
|
|
156
|
+
def select_token(self, payment_id, token):
|
|
157
|
+
"""Sets the coin of a payment created without one, when the customer chose it on your
|
|
158
|
+
own page: one of its token_options. payment_id is the payment's id (pay_…) or your order id.
|
|
159
|
+
|
|
160
|
+
Returns the whole payment, now with its address, amount, locked rate and a deadline
|
|
161
|
+
15 minutes away. The coin is chosen once: choosing again is a 409.
|
|
162
|
+
"""
|
|
163
|
+
return self._client._request("POST", "/v1/payments/" + urllib.parse.quote(payment_id, safe="") + "/token",
|
|
164
|
+
{"token": token})
|
|
165
|
+
|
|
166
|
+
def list(self, status=None, chain=None, token=None, limit=None, offset=None, store_id=None):
|
|
167
|
+
"""Payments of the key's mode, newest first: {payments, total, limit, offset}.
|
|
168
|
+
|
|
169
|
+
store_id keeps the payments of one store (ignored for a storefront's key, which
|
|
170
|
+
sees its own).
|
|
171
|
+
"""
|
|
172
|
+
params = {k: v for k, v in {"status": status, "chain": chain, "token": token, "store_id": store_id,
|
|
173
|
+
"limit": limit, "offset": offset}.items() if v is not None}
|
|
174
|
+
query = "?" + urllib.parse.urlencode(params) if params else ""
|
|
175
|
+
return self._client._request("GET", "/v1/payments" + query)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _Tokens:
|
|
179
|
+
def __init__(self, client):
|
|
180
|
+
self._client = client
|
|
181
|
+
|
|
182
|
+
def list(self, amount_usd=None):
|
|
183
|
+
"""The tokens a customer can pay with in the key's mode. With amount_usd,
|
|
184
|
+
only the ones a payment of that amount offers (its token_options), each
|
|
185
|
+
with amount_due_usd when fees are passed on."""
|
|
186
|
+
query = "?" + urllib.parse.urlencode({"amount_usd": str(amount_usd)}) if amount_usd is not None else ""
|
|
187
|
+
return self._client._request("GET", "/v1/tokens" + query)["tokens"]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _Checkout:
|
|
191
|
+
def __init__(self, client):
|
|
192
|
+
self._client = client
|
|
193
|
+
|
|
194
|
+
def retrieve(self, checkout_token):
|
|
195
|
+
"""The payer's view of a payment, by its checkout token. Public: sent without the API key."""
|
|
196
|
+
return self._client._request("GET", "/checkout/" + urllib.parse.quote(checkout_token, safe=""),
|
|
197
|
+
authenticated=False)
|
|
198
|
+
|
|
199
|
+
def select_token(self, checkout_token, token):
|
|
200
|
+
"""The customer's choice of coin for a payment created without one. Public."""
|
|
201
|
+
return self._client._request("POST", "/checkout/" + urllib.parse.quote(checkout_token, safe="") + "/token",
|
|
202
|
+
{"token": token}, authenticated=False)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class _Webhooks:
|
|
206
|
+
"""The webhook (IPN) of the key's mode: a test key manages the test webhook, a live key
|
|
207
|
+
the live one. A storefront's key may read and test it, not change it."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, client):
|
|
210
|
+
self._client = client
|
|
211
|
+
|
|
212
|
+
def get(self):
|
|
213
|
+
"""The webhook: {livemode, url (None when not set), secret, created_at, updated_at}."""
|
|
214
|
+
return self._client._request("GET", "/v1/webhook")["webhook"]
|
|
215
|
+
|
|
216
|
+
def set(self, url):
|
|
217
|
+
"""Sets the URL every event of the mode is POSTed to: https, public host, accepting POST
|
|
218
|
+
requests. Safe to retry."""
|
|
219
|
+
return self._client._request("PUT", "/v1/webhook", {"url": url})["webhook"]
|
|
220
|
+
|
|
221
|
+
def remove(self):
|
|
222
|
+
"""Stops POSTing to the URL (payments with their own webhook_url still get theirs); the
|
|
223
|
+
secret stays."""
|
|
224
|
+
return self._client._request("DELETE", "/v1/webhook")["webhook"]
|
|
225
|
+
|
|
226
|
+
def test(self):
|
|
227
|
+
"""Queues a signed "ping" event to the URL and returns its delivery."""
|
|
228
|
+
return self._client._request("POST", "/v1/webhook/test")["delivery"]
|
|
229
|
+
|
|
230
|
+
def construct_event(self, payload, signature_header, secret=None, tolerance=None):
|
|
231
|
+
"""Verifies a webhook request and returns its event; see ``Webhook.construct_event``.
|
|
232
|
+
|
|
233
|
+
``secret`` defaults to the client's ``webhook_secret``; without either it raises
|
|
234
|
+
SignatureVerificationError.
|
|
235
|
+
"""
|
|
236
|
+
secret = secret or self._client._webhook_secret
|
|
237
|
+
if not secret:
|
|
238
|
+
raise SignatureVerificationError("no webhook secret: pass one, or set the client's webhook_secret")
|
|
239
|
+
if tolerance is None:
|
|
240
|
+
tolerance = Webhook.DEFAULT_TOLERANCE
|
|
241
|
+
return Webhook.construct_event(payload, signature_header, secret, tolerance)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Webhook:
|
|
245
|
+
DEFAULT_TOLERANCE = 300
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def construct_event(payload, signature_header, secret, tolerance=DEFAULT_TOLERANCE):
|
|
249
|
+
"""Verifies a webhook request and returns its event.
|
|
250
|
+
|
|
251
|
+
Pass the raw request body (bytes, before any JSON parsing), the
|
|
252
|
+
X-Webhook-Signature header and the mode's signing secret (the webhook's
|
|
253
|
+
``secret``, from ``client.webhooks.get()`` or the dashboard). Raises
|
|
254
|
+
SignatureVerificationError when the signature is wrong or the timestamp
|
|
255
|
+
is more than ``tolerance`` seconds old (0 disables the timestamp check).
|
|
256
|
+
"""
|
|
257
|
+
if isinstance(payload, str):
|
|
258
|
+
payload = payload.encode()
|
|
259
|
+
parts = dict(p.strip().split("=", 1) for p in (signature_header or "").split(",") if "=" in p)
|
|
260
|
+
t, v1 = parts.get("t", ""), parts.get("v1", "")
|
|
261
|
+
if not t or not v1:
|
|
262
|
+
raise SignatureVerificationError("missing or malformed X-Webhook-Signature header")
|
|
263
|
+
expected = hmac.new(secret.encode(), t.encode() + b"." + payload, hashlib.sha256).hexdigest()
|
|
264
|
+
# Compared as bytes: a header with non-ASCII characters is a mismatch, not a TypeError.
|
|
265
|
+
if not hmac.compare_digest(expected.encode(), v1.encode()):
|
|
266
|
+
raise SignatureVerificationError("signature does not match")
|
|
267
|
+
if tolerance and tolerance > 0:
|
|
268
|
+
try:
|
|
269
|
+
if abs(time.time() - int(t)) > tolerance:
|
|
270
|
+
raise SignatureVerificationError("timestamp outside the tolerance")
|
|
271
|
+
except ValueError:
|
|
272
|
+
raise SignatureVerificationError("malformed timestamp") from None
|
|
273
|
+
return _wrap(json.loads(payload))
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: payitgo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the payitgo crypto payment gateway
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/thedivinez/payitgo-sdk/tree/main/python
|
|
7
|
+
Keywords: payitgo,payments,crypto,usdt,bitcoin
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# payitgo for Python
|
|
14
|
+
|
|
15
|
+
Accept crypto payments with [payitgo](https://api.payitgo.co/docs) from Python 3.8+. No dependencies.
|
|
16
|
+
|
|
17
|
+
Full reference, with every method, option, type and error: [api.payitgo.co/docs/sdk-python](https://api.payitgo.co/docs/sdk-python).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install payitgo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The package is `payitgo` on PyPI; you import it as `payitgo`.
|
|
24
|
+
|
|
25
|
+
## Accept a payment in three steps
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import os
|
|
29
|
+
import payitgo
|
|
30
|
+
from flask import Flask, abort, redirect, request
|
|
31
|
+
|
|
32
|
+
client = payitgo.Client(
|
|
33
|
+
os.environ["PAYITGO_KEY"],
|
|
34
|
+
base_url=os.environ["PAYITGO_BASE_URL"],
|
|
35
|
+
webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"], # "whsec_…": see Webhooks (IPN) below
|
|
36
|
+
)
|
|
37
|
+
app = Flask(__name__)
|
|
38
|
+
|
|
39
|
+
# 1. Create a payment and send the customer to the checkout page.
|
|
40
|
+
@app.post("/checkout")
|
|
41
|
+
def checkout():
|
|
42
|
+
payment = client.payments.create(
|
|
43
|
+
order_id=request.form["order_id"], # your order id: safe to retry
|
|
44
|
+
amount_usd=request.form["total"], # e.g. "19.99"
|
|
45
|
+
# token="USDT_TRC20", # optional: without it the customer picks the coin
|
|
46
|
+
success_url="https://shop.example/thanks",
|
|
47
|
+
)
|
|
48
|
+
return redirect(payment.checkout_url)
|
|
49
|
+
|
|
50
|
+
# 2. Receive the notification (the complete handler is under Webhooks (IPN) below).
|
|
51
|
+
# Use the raw body: the signature covers its exact bytes.
|
|
52
|
+
@app.post("/payitgo/ipn")
|
|
53
|
+
def ipn():
|
|
54
|
+
try:
|
|
55
|
+
event = client.webhooks.construct_event(request.get_data(), request.headers.get("X-Webhook-Signature"))
|
|
56
|
+
except payitgo.SignatureVerificationError:
|
|
57
|
+
abort(400)
|
|
58
|
+
# 3. Fulfil the order once the payment is final.
|
|
59
|
+
if event.type == "payment.confirmed":
|
|
60
|
+
mark_order_paid(event.data.order_id) # make this idempotent: events can arrive twice
|
|
61
|
+
return "", 200
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Start with your test key (`sk_test_…`) and testnet coins; switch to the live key
|
|
65
|
+
(`sk_live_…`) when the account owner has turned live mode on.
|
|
66
|
+
|
|
67
|
+
## Your own payment page
|
|
68
|
+
|
|
69
|
+
`checkout_url` is only for payitgo's hosted page. To show the payment on your own page,
|
|
70
|
+
render the payment your server gets back (`deposit_address`, `crypto_amount`,
|
|
71
|
+
`destination_tag` for XRP, a QR code of `qr_payload`, `expires_at`); no `checkout` calls
|
|
72
|
+
are needed.
|
|
73
|
+
|
|
74
|
+
- **You know the coin:** create the payment with `token` and render the payment it returns.
|
|
75
|
+
- **The customer chooses it:** create the payment without `token`, show
|
|
76
|
+
`payment.token_options` on your page, then call
|
|
77
|
+
`client.payments.select_token(payment.id, chosen)` from your server and render the
|
|
78
|
+
payment it returns: it now has its coin, address, amount, locked rate and a deadline 15
|
|
79
|
+
minutes away.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
payment = client.payments.create(order_id="ORD-1", amount_usd=10)
|
|
83
|
+
# your page shows payment.token_options; the customer picks one
|
|
84
|
+
chosen = client.payments.select_token(payment.id, "USDT_TRC20") # or your order id
|
|
85
|
+
# render chosen.deposit_address, chosen.crypto_amount, chosen.qr_payload, chosen.expires_at
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The coin is chosen once: choosing again is a `409` (`payments.retrieve` then returns the
|
|
89
|
+
payment with its coin). A coin outside `token_options`, or a payment that can no longer be
|
|
90
|
+
paid, is a `400`.
|
|
91
|
+
|
|
92
|
+
## Webhooks (IPN)
|
|
93
|
+
|
|
94
|
+
payitgo POSTs a signed notification for every change of a payment (`payment.confirmed`,
|
|
95
|
+
`payment.expired`, …), of a direct settlement and of a payout. Each mode has one webhook:
|
|
96
|
+
a test key manages the test one, a live key the live one.
|
|
97
|
+
|
|
98
|
+
**Where the notifications go.** Set the URL once per mode (https:// on a public host,
|
|
99
|
+
accepting POST requests), or give a payment its own when you create it:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Once per mode (safe to repeat):
|
|
103
|
+
client.webhooks.set("https://shop.example/payitgo/ipn")
|
|
104
|
+
|
|
105
|
+
# Or per payment: its events go there instead of the webhook's URL.
|
|
106
|
+
client.payments.create(order_id="ORD-1", amount_usd=10, webhook_url="https://shop.example/orders/ipn")
|
|
107
|
+
|
|
108
|
+
# Or as the client's default for every payment it creates (a payment's own webhook_url wins):
|
|
109
|
+
shop = payitgo.Client(os.environ["PAYITGO_KEY"], webhook_url="https://shop.example/orders/ipn")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A storefront's key cannot set the account's URL (`403`): pass `webhook_url` instead.
|
|
113
|
+
Payout events always go to the webhook's URL.
|
|
114
|
+
|
|
115
|
+
**The signing secret.** Every notification of a mode is signed with that mode's secret
|
|
116
|
+
(`whsec_…`), whichever URL it goes to. Copy it from the dashboard or read it with
|
|
117
|
+
`client.webhooks.get().secret`, and give it to the client once:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
client = payitgo.Client(os.environ["PAYITGO_KEY"], webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Receiving them** (Flask). Verify the raw body, handle each event once, and answer
|
|
124
|
+
`2xx` within 10 seconds; otherwise payitgo retries after 1 min, 5 min, 30 min, 2 h, 6 h and
|
|
125
|
+
24 h.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
@app.post("/payitgo/ipn")
|
|
129
|
+
def ipn():
|
|
130
|
+
try:
|
|
131
|
+
# Checks the signature with the client's webhook_secret and refuses anything older than 5 minutes.
|
|
132
|
+
event = client.webhooks.construct_event(request.get_data(), request.headers.get("X-Webhook-Signature"))
|
|
133
|
+
except payitgo.SignatureVerificationError:
|
|
134
|
+
abort(400)
|
|
135
|
+
# The same event can arrive more than once: handle each event id once,
|
|
136
|
+
# e.g. by inserting it into a table with a unique key.
|
|
137
|
+
if not record_event_once(event.id):
|
|
138
|
+
return "", 200
|
|
139
|
+
if event.type == "payment.confirmed":
|
|
140
|
+
jobs.enqueue("fulfil", event.data.order_id) # slow work off the request
|
|
141
|
+
elif event.type == "payment.expired":
|
|
142
|
+
jobs.enqueue("release_stock", event.data.order_id)
|
|
143
|
+
return "", 200
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Testing it.** `client.webhooks.test()` queues a signed `ping` event to the webhook's
|
|
147
|
+
URL and returns its delivery (`400` when no URL is set).
|
|
148
|
+
|
|
149
|
+
## API
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
client = payitgo.Client(api_key, base_url="https://api.example.com", # or PAYITGO_BASE_URL
|
|
153
|
+
timeout=30, max_retries=2, # network errors, 429, 5xx
|
|
154
|
+
webhook_secret="whsec_…", # for webhooks.construct_event
|
|
155
|
+
webhook_url="https://…") # default webhook_url of payments.create
|
|
156
|
+
|
|
157
|
+
client.livemode # False for sk_test_ keys
|
|
158
|
+
client.payments.create(order_id, amount_usd, token, webhook_url=None, description=None, metadata=None,
|
|
159
|
+
customer_email=None, success_url=None, cancel_url=None)
|
|
160
|
+
client.payments.select_token("pay_…", token) # or your order id: the payment with its coin
|
|
161
|
+
client.payments.retrieve("pay_…") # or your order id
|
|
162
|
+
client.payments.list(status="confirmed", store_id="sto_…", limit=50, offset=0) # every filter optional
|
|
163
|
+
client.tokens.list() # [{token, chain, min_usd, network_fee_usd, fee_note}]
|
|
164
|
+
client.tokens.list(amount_usd="49.90") # the coins a $49.90 payment offers, with amount_due_usd
|
|
165
|
+
client.checkout.retrieve(checkout_token) # public payer view
|
|
166
|
+
client.webhooks.get() # {livemode, url, secret, created_at, updated_at}
|
|
167
|
+
client.webhooks.set(url) # the webhook
|
|
168
|
+
client.webhooks.remove() # the webhook, url None (the secret stays)
|
|
169
|
+
client.webhooks.test() # the delivery of a "ping" event
|
|
170
|
+
client.webhooks.construct_event(raw_body, signature_header, secret=None, tolerance=300)
|
|
171
|
+
payitgo.Webhook.construct_event(raw_body, signature_header, secret, tolerance=300) # no client needed
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Responses are dicts that also allow attribute access: `payment["status"]` or
|
|
175
|
+
`payment.status`. Field names are the API's own (see the
|
|
176
|
+
[payment object](https://api.payitgo.co/docs/payments) and [webhooks](https://api.payitgo.co/docs/webhooks)).
|
|
177
|
+
`payitgo.WEBHOOK_EVENTS` lists the event types.
|
|
178
|
+
|
|
179
|
+
## Errors
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
try:
|
|
183
|
+
client.payments.create(order_id="ORD-1", amount_usd=1, token="BTC")
|
|
184
|
+
except payitgo.PayitgoError as e:
|
|
185
|
+
print(e.status, e.message) # 400 minimum payment in BTC is $10 right now (network fees)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`PayitgoError.status` is the HTTP status (0 for a network error).
|
|
189
|
+
`construct_event` raises `SignatureVerificationError` for a bad signature, a stale
|
|
190
|
+
timestamp, or when it has no secret (none passed and no `webhook_secret`).
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
python3 -m unittest discover -s tests
|
|
196
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
payitgo
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "payitgo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the payitgo crypto payment gateway"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["payitgo", "payments", "crypto", "usdt", "bitcoin"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/thedivinez/payitgo-sdk/tree/main/python"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
packages = ["payitgo"]
|
payitgo-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
12
|
+
import payitgo # noqa: E402
|
|
13
|
+
|
|
14
|
+
# Signed by the gateway's own signer.
|
|
15
|
+
SECRET = "whsec_test_secret"
|
|
16
|
+
HEADER = "t=1760000000,v1=6abd292fdf2ecc51779c161f5d4be2d47236f7a43fbaa9dd7093303904b91a7b"
|
|
17
|
+
BODY = b'{"id":"evt_test_123","type":"payment.confirmed","livemode":false,"created_at":"2026-10-09T12:00:00Z","data":{"id":"pay_123","order_id":"ORD-1","status":"confirmed","usd_amount":"10"}}'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sign(secret, t, body):
|
|
21
|
+
return "t=%d,v1=%s" % (t, hmac.new(secret.encode(), str(t).encode() + b"." + body, hashlib.sha256).hexdigest())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WebhookTest(unittest.TestCase):
|
|
25
|
+
def test_accepts_gateway_signature(self):
|
|
26
|
+
event = payitgo.Webhook.construct_event(BODY, HEADER, SECRET, tolerance=0)
|
|
27
|
+
self.assertEqual(event.type, "payment.confirmed")
|
|
28
|
+
self.assertEqual(event.data.order_id, "ORD-1")
|
|
29
|
+
self.assertFalse(event.livemode)
|
|
30
|
+
|
|
31
|
+
def test_accepts_str_body(self):
|
|
32
|
+
self.assertEqual(payitgo.Webhook.construct_event(BODY.decode(), HEADER, SECRET, tolerance=0)["id"], "evt_test_123")
|
|
33
|
+
|
|
34
|
+
def test_rejects(self):
|
|
35
|
+
for body, header, secret in [
|
|
36
|
+
(BODY.replace(b'"10"', b'"1000"'), HEADER, SECRET),
|
|
37
|
+
(BODY, HEADER, "whsec_other"),
|
|
38
|
+
(BODY, None, SECRET),
|
|
39
|
+
]:
|
|
40
|
+
with self.assertRaises(payitgo.SignatureVerificationError):
|
|
41
|
+
payitgo.Webhook.construct_event(body, header, secret, tolerance=0)
|
|
42
|
+
|
|
43
|
+
def test_rejects_non_ascii_signature(self):
|
|
44
|
+
with self.assertRaisesRegex(payitgo.SignatureVerificationError, "signature does not match"):
|
|
45
|
+
payitgo.Webhook.construct_event(BODY, "t=1760000000,v1=" + "é" * 64, SECRET, tolerance=0)
|
|
46
|
+
|
|
47
|
+
def test_rejects_non_numeric_timestamp(self):
|
|
48
|
+
header = "t=soon,v1=" + hmac.new(SECRET.encode(), b"soon." + BODY, hashlib.sha256).hexdigest()
|
|
49
|
+
with self.assertRaisesRegex(payitgo.SignatureVerificationError, "malformed timestamp"):
|
|
50
|
+
payitgo.Webhook.construct_event(BODY, header, SECRET)
|
|
51
|
+
self.assertEqual(payitgo.Webhook.construct_event(BODY, header, SECRET, tolerance=0).id, "evt_test_123")
|
|
52
|
+
|
|
53
|
+
def test_tolerance(self):
|
|
54
|
+
with self.assertRaises(payitgo.SignatureVerificationError):
|
|
55
|
+
payitgo.Webhook.construct_event(BODY, HEADER, SECRET)
|
|
56
|
+
fresh = sign(SECRET, int(time.time()), BODY)
|
|
57
|
+
self.assertEqual(payitgo.Webhook.construct_event(BODY, fresh, SECRET).id, "evt_test_123")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ClientTest(unittest.TestCase):
|
|
61
|
+
def test_checks(self):
|
|
62
|
+
os.environ.pop("PAYITGO_BASE_URL", None)
|
|
63
|
+
with self.assertRaises(ValueError):
|
|
64
|
+
payitgo.Client("pk_123", base_url="http://x")
|
|
65
|
+
# Without an address the client uses the release's default, or refuses.
|
|
66
|
+
if payitgo.DEFAULT_BASE_URL:
|
|
67
|
+
self.assertEqual(payitgo.Client("sk_test_abc")._base_url, payitgo.DEFAULT_BASE_URL.rstrip("/"))
|
|
68
|
+
else:
|
|
69
|
+
with self.assertRaises(ValueError):
|
|
70
|
+
payitgo.Client("sk_test_abc")
|
|
71
|
+
self.assertFalse(payitgo.Client("sk_test_abc", base_url="http://x").livemode)
|
|
72
|
+
self.assertTrue(payitgo.Client("sk_live_abc", base_url="http://x").livemode)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
WEBHOOK = {
|
|
76
|
+
"livemode": False, "url": "https://shop.example/payitgo/ipn", "secret": "whsec_" + "ab" * 32,
|
|
77
|
+
"created_at": "2026-10-10T09:00:00Z", "updated_at": "2026-10-10T09:05:00Z",
|
|
78
|
+
}
|
|
79
|
+
DELIVERY = {
|
|
80
|
+
"id": "whd_1", "merchant_id": "mch_1", "target": "account", "url": WEBHOOK["url"], "event_id": "evt_1",
|
|
81
|
+
"event_type": "ping", "payload": '{"id":"evt_1","type":"ping"}', "status": "pending", "attempts": 0,
|
|
82
|
+
"max_attempts": 7, "next_attempt_at": "2026-10-10T12:00:00Z", "created_at": "2026-10-10T12:00:00Z",
|
|
83
|
+
}
|
|
84
|
+
# A payment whose coin the customer chose on the merchant's own page.
|
|
85
|
+
CHOSEN = {
|
|
86
|
+
"id": "pay_1", "merchant_id": "mch_1", "order_id": "ORD-7", "status": "pending", "livemode": False,
|
|
87
|
+
"token": "XRP", "chain": "xrp", "deposit_address": "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe",
|
|
88
|
+
"destination_tag": 1042, "crypto_amount": "20", "received_amount": "0", "usd_amount": "10",
|
|
89
|
+
"amount_due_usd": "10", "fees_paid_by_customer": False, "network_fee": "0.000012", "network_fee_usd": "0.01",
|
|
90
|
+
"settlement": "custodial", "received_usd": "0", "rate_locked": "0.5", "deposits": [],
|
|
91
|
+
"expires_at": "2026-10-10T12:15:00Z", "created_at": "2026-10-10T12:00:00Z",
|
|
92
|
+
"network_fee_note": "XRP Ledger testnet: test XRP only. Include destination tag — required.",
|
|
93
|
+
"qr_payload": "xrpl:rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe?amount=20.000000&dt=1042",
|
|
94
|
+
"checkout_token": "ct_abc", "checkout_url": "https://pay.example/ct_abc", "description": "Order 7",
|
|
95
|
+
"metadata": {"cart": "42"},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _FakeGateway(BaseHTTPRequestHandler):
|
|
100
|
+
"""Records each request and answers with the next queued (status, body), or echoes a
|
|
101
|
+
created payment's body back as the payment."""
|
|
102
|
+
|
|
103
|
+
def _handle(self):
|
|
104
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
105
|
+
raw = self.rfile.read(length) if length else b""
|
|
106
|
+
body = json.loads(raw) if raw else None
|
|
107
|
+
self.server.requests.append({"method": self.command, "path": self.path, "headers": dict(self.headers),
|
|
108
|
+
"body": body})
|
|
109
|
+
if self.server.replies:
|
|
110
|
+
status, reply = self.server.replies.pop(0)
|
|
111
|
+
elif self.path == "/v1/payments":
|
|
112
|
+
status, reply = 201, dict(body, id="pay_1", status="pending")
|
|
113
|
+
else:
|
|
114
|
+
status, reply = 500, {"error": "no reply queued"}
|
|
115
|
+
data = json.dumps(reply).encode()
|
|
116
|
+
self.send_response(status)
|
|
117
|
+
self.send_header("Content-Type", "application/json")
|
|
118
|
+
self.send_header("Content-Length", str(len(data)))
|
|
119
|
+
self.end_headers()
|
|
120
|
+
self.wfile.write(data)
|
|
121
|
+
|
|
122
|
+
do_GET = do_POST = do_PUT = do_DELETE = _handle
|
|
123
|
+
|
|
124
|
+
def log_message(self, *args):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class WebhookIPNTest(unittest.TestCase):
|
|
129
|
+
def setUp(self):
|
|
130
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeGateway)
|
|
131
|
+
self.server.requests, self.server.replies = [], []
|
|
132
|
+
threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start()
|
|
133
|
+
self.addCleanup(self.server.server_close)
|
|
134
|
+
self.addCleanup(self.server.shutdown)
|
|
135
|
+
self.client = self.make_client()
|
|
136
|
+
|
|
137
|
+
def make_client(self, **options):
|
|
138
|
+
return payitgo.Client("sk_test_abc", base_url="http://127.0.0.1:%d/" % self.server.server_port,
|
|
139
|
+
max_retries=0, **options)
|
|
140
|
+
|
|
141
|
+
def reply(self, status, body):
|
|
142
|
+
self.server.replies.append((status, body))
|
|
143
|
+
|
|
144
|
+
def request(self, i=0):
|
|
145
|
+
return self.server.requests[i]
|
|
146
|
+
|
|
147
|
+
def test_tokens(self):
|
|
148
|
+
tokens = [{"token": "USDT_BEP20", "chain": "bsc", "min_usd": "1", "network_fee_usd": "0.01", "amount_due_usd": "40.43", "fee_note": ""}]
|
|
149
|
+
self.reply(200, {"tokens": tokens})
|
|
150
|
+
self.assertEqual(self.client.tokens.list(), tokens)
|
|
151
|
+
self.assertEqual((self.request(0)["method"], self.request(0)["path"]), ("GET", "/v1/tokens"))
|
|
152
|
+
self.reply(200, {"tokens": tokens})
|
|
153
|
+
self.client.tokens.list(amount_usd="49.90")
|
|
154
|
+
self.assertEqual(self.request(1)["path"], "/v1/tokens?amount_usd=49.90")
|
|
155
|
+
|
|
156
|
+
def test_get(self):
|
|
157
|
+
self.reply(200, {"webhook": WEBHOOK})
|
|
158
|
+
webhook = self.client.webhooks.get()
|
|
159
|
+
req = self.request()
|
|
160
|
+
self.assertEqual((req["method"], req["path"], req["body"]), ("GET", "/v1/webhook", None))
|
|
161
|
+
self.assertEqual(req["headers"]["Authorization"], "Bearer sk_test_abc")
|
|
162
|
+
self.assertEqual(webhook, WEBHOOK)
|
|
163
|
+
self.assertEqual(webhook.secret, WEBHOOK["secret"])
|
|
164
|
+
|
|
165
|
+
def test_set(self):
|
|
166
|
+
self.reply(200, {"webhook": WEBHOOK})
|
|
167
|
+
webhook = self.client.webhooks.set(WEBHOOK["url"])
|
|
168
|
+
req = self.request()
|
|
169
|
+
self.assertEqual((req["method"], req["path"]), ("PUT", "/v1/webhook"))
|
|
170
|
+
self.assertEqual(req["headers"]["Content-Type"], "application/json")
|
|
171
|
+
self.assertEqual(req["body"], {"url": WEBHOOK["url"]})
|
|
172
|
+
self.assertEqual(webhook.url, WEBHOOK["url"])
|
|
173
|
+
|
|
174
|
+
def test_remove(self):
|
|
175
|
+
self.reply(200, {"webhook": dict(WEBHOOK, url=None)})
|
|
176
|
+
webhook = self.client.webhooks.remove()
|
|
177
|
+
self.assertEqual((self.request()["method"], self.request()["path"]), ("DELETE", "/v1/webhook"))
|
|
178
|
+
self.assertIsNone(webhook.url)
|
|
179
|
+
self.assertEqual(webhook.secret, WEBHOOK["secret"])
|
|
180
|
+
|
|
181
|
+
def test_test(self):
|
|
182
|
+
self.reply(202, {"delivery": DELIVERY})
|
|
183
|
+
delivery = self.client.webhooks.test()
|
|
184
|
+
req = self.request()
|
|
185
|
+
self.assertEqual((req["method"], req["path"]), ("POST", "/v1/webhook/test"))
|
|
186
|
+
self.assertEqual(req["headers"]["Authorization"], "Bearer sk_test_abc")
|
|
187
|
+
self.assertEqual((delivery.event_type, delivery.status, delivery.target), ("ping", "pending", "account"))
|
|
188
|
+
|
|
189
|
+
def test_errors(self):
|
|
190
|
+
self.reply(400, {"error": "no webhook url is set for this mode: set one first"})
|
|
191
|
+
with self.assertRaises(payitgo.PayitgoError) as ctx:
|
|
192
|
+
self.client.webhooks.test()
|
|
193
|
+
self.assertEqual((ctx.exception.status, ctx.exception.message),
|
|
194
|
+
(400, "no webhook url is set for this mode: set one first"))
|
|
195
|
+
forbidden = "a storefront's key cannot change the account's webhook: set webhook_url on its payments instead"
|
|
196
|
+
self.reply(403, {"error": forbidden})
|
|
197
|
+
with self.assertRaises(payitgo.PayitgoError) as ctx:
|
|
198
|
+
self.client.webhooks.set(WEBHOOK["url"])
|
|
199
|
+
self.assertEqual((ctx.exception.status, ctx.exception.message), (403, forbidden))
|
|
200
|
+
|
|
201
|
+
def test_payment_webhook_url(self):
|
|
202
|
+
payment = self.client.payments.create("ORD-1", 10)
|
|
203
|
+
self.assertEqual(self.request(0)["body"], {"order_id": "ORD-1", "amount_usd": "10"})
|
|
204
|
+
self.assertNotIn("webhook_url", payment)
|
|
205
|
+
payment = self.client.payments.create("ORD-2", 10, webhook_url="https://shop.example/own")
|
|
206
|
+
self.assertEqual(self.request(1)["body"]["webhook_url"], "https://shop.example/own")
|
|
207
|
+
self.assertEqual(payment.webhook_url, "https://shop.example/own")
|
|
208
|
+
|
|
209
|
+
def test_payment_webhook_url_default(self):
|
|
210
|
+
client = self.make_client(webhook_url="https://shop.example/default")
|
|
211
|
+
payment = client.payments.create("ORD-1", 10)
|
|
212
|
+
self.assertEqual(self.request(0)["body"]["webhook_url"], "https://shop.example/default")
|
|
213
|
+
self.assertEqual(payment.webhook_url, "https://shop.example/default")
|
|
214
|
+
payment = client.payments.create("ORD-2", 10, webhook_url="https://shop.example/own") # the call's own wins
|
|
215
|
+
self.assertEqual(self.request(1)["body"]["webhook_url"], "https://shop.example/own")
|
|
216
|
+
self.assertEqual(payment.webhook_url, "https://shop.example/own")
|
|
217
|
+
|
|
218
|
+
def test_payment_select_token(self):
|
|
219
|
+
self.reply(200, CHOSEN)
|
|
220
|
+
payment = self.client.payments.select_token("pay_1", "XRP")
|
|
221
|
+
req = self.request()
|
|
222
|
+
self.assertEqual((req["method"], req["path"]), ("POST", "/v1/payments/pay_1/token"))
|
|
223
|
+
self.assertEqual(req["headers"]["Authorization"], "Bearer sk_test_abc")
|
|
224
|
+
self.assertEqual(req["headers"]["Content-Type"], "application/json")
|
|
225
|
+
self.assertEqual(req["body"], {"token": "XRP"})
|
|
226
|
+
self.assertEqual(payment, CHOSEN)
|
|
227
|
+
self.assertEqual((payment.deposit_address, payment.destination_tag, payment.metadata.cart),
|
|
228
|
+
(CHOSEN["deposit_address"], 1042, "42"))
|
|
229
|
+
# An order id is escaped in the path, as payments.retrieve does.
|
|
230
|
+
self.reply(200, CHOSEN)
|
|
231
|
+
self.client.payments.select_token("ORD 7/1", "XRP")
|
|
232
|
+
self.assertEqual(self.request(1)["path"], "/v1/payments/ORD%207%2F1/token")
|
|
233
|
+
|
|
234
|
+
def test_payments_list(self):
|
|
235
|
+
self.reply(200, {"payments": [], "total": 0, "limit": 10, "offset": 20})
|
|
236
|
+
page = self.client.payments.list(status="confirmed", store_id="sto_1", limit=10, offset=20)
|
|
237
|
+
req = self.request()
|
|
238
|
+
self.assertEqual(req["method"], "GET")
|
|
239
|
+
self.assertEqual(req["path"], "/v1/payments?status=confirmed&store_id=sto_1&limit=10&offset=20")
|
|
240
|
+
self.assertEqual((page.total, page.limit, page.offset), (0, 10, 20))
|
|
241
|
+
self.reply(200, {"payments": [], "total": 0, "limit": 50, "offset": 0})
|
|
242
|
+
self.client.payments.list()
|
|
243
|
+
self.assertEqual(self.request(1)["path"], "/v1/payments")
|
|
244
|
+
|
|
245
|
+
def test_payment_select_token_chosen(self):
|
|
246
|
+
self.reply(409, {"error": "the coin for this payment has already been chosen"})
|
|
247
|
+
with self.assertRaises(payitgo.PayitgoError) as ctx:
|
|
248
|
+
self.client.payments.select_token("pay_1", "XRP")
|
|
249
|
+
self.assertEqual((ctx.exception.status, ctx.exception.message),
|
|
250
|
+
(409, "the coin for this payment has already been chosen"))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ClientConstructEventTest(unittest.TestCase):
|
|
254
|
+
def test_client_secret(self):
|
|
255
|
+
client = payitgo.Client("sk_test_abc", base_url="http://x", webhook_secret=SECRET)
|
|
256
|
+
self.assertEqual(client.webhooks.construct_event(BODY, HEADER, tolerance=0).id, "evt_test_123")
|
|
257
|
+
fresh = sign(SECRET, int(time.time()), BODY)
|
|
258
|
+
self.assertEqual(client.webhooks.construct_event(BODY, fresh).id, "evt_test_123")
|
|
259
|
+
|
|
260
|
+
def test_explicit_secret_wins(self):
|
|
261
|
+
client = payitgo.Client("sk_test_abc", base_url="http://x", webhook_secret="whsec_other")
|
|
262
|
+
with self.assertRaises(payitgo.SignatureVerificationError):
|
|
263
|
+
client.webhooks.construct_event(BODY, HEADER, tolerance=0)
|
|
264
|
+
self.assertEqual(client.webhooks.construct_event(BODY, HEADER, SECRET, tolerance=0).id, "evt_test_123")
|
|
265
|
+
|
|
266
|
+
def test_no_secret(self):
|
|
267
|
+
client = payitgo.Client("sk_test_abc", base_url="http://x")
|
|
268
|
+
with self.assertRaisesRegex(payitgo.SignatureVerificationError, "no webhook secret"):
|
|
269
|
+
client.webhooks.construct_event(BODY, HEADER)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
unittest.main()
|