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 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
+ ```
@@ -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,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ payitgo/__init__.py
5
+ payitgo.egg-info/PKG-INFO
6
+ payitgo.egg-info/SOURCES.txt
7
+ payitgo.egg-info/dependency_links.txt
8
+ payitgo.egg-info/top_level.txt
9
+ tests/test_payitgo.py
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()