solverr 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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .mypy_cache/
solverr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Solverr
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.
solverr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.5
2
+ Name: solverr
3
+ Version: 0.1.0
4
+ Summary: Server SDK for the Solverr crypto payment API.
5
+ Project-URL: Homepage, https://github.com/forcequitx/solverr/tree/main/packages/python#readme
6
+ Project-URL: Source, https://github.com/forcequitx/solverr/tree/main/packages/python
7
+ Project-URL: Issues, https://github.com/forcequitx/solverr/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: checkout,crypto,payments,solverr,webhooks
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+
20
+ # <img src="https://github.com/forcequitx/solverr/raw/main/app/icon.svg" alt="Solverr" width="30" height="30"> solverr
21
+
22
+ **The Python server SDK for the Solverr crypto payment API.**
23
+
24
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/lightbulb.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Overview
25
+
26
+ Your server uses this SDK to create invoices, payment links, refunds, payouts, subscriptions and credit charges, to list the coins the checkout accepts, and to verify the signed webhooks Solverr sends.
27
+
28
+ - **Checkout:** your server calls `client.invoices.create` -> the customer pays at the invoice's `checkout_url`.
29
+ - **Webhooks:** Solverr sends a signed `invoice.paid` event -> `solverr.webhooks.verify` checks it and returns the event.
30
+ - **Paid content:** `client.invoices.redeem` marks a paid invoice as used -> one payment grants access only once.
31
+
32
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/package.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Install
33
+
34
+ The SDK needs Python 3.11 or later, uses only the standard library, and ships type hints.
35
+
36
+ ```bash
37
+ pip install solverr
38
+ ```
39
+
40
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/terminal.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Quick Start
41
+
42
+ Create an invoice and send the customer to checkout:
43
+
44
+ ```python
45
+ import os
46
+
47
+ from solverr import Solverr
48
+
49
+ client = Solverr(
50
+ api_key=os.environ["SOLVERR_SECRET_KEY"], # sk_live_…
51
+ base_url=os.environ["SOLVERR_URL"], # the origin of your Solverr app
52
+ )
53
+
54
+ invoice = client.invoices.create(
55
+ amount="25.00",
56
+ currency="USD",
57
+ order_id="1042",
58
+ redirect_url="https://shop.example/thanks",
59
+ )
60
+
61
+ # Redirect the customer to invoice["checkout_url"]
62
+ ```
63
+
64
+ `base_url` can be left out when the `SOLVERR_URL` environment variable is set. It must be an https URL. Plain http works only for `localhost`, `127.0.0.1` and `[::1]`, so your API key never travels unencrypted. Arguments and response keys use the API's own field names (`order_id`, `checkout_url`), so the API reference applies as written. Responses are plain dictionaries, typed with the `TypedDict` classes in `solverr.types`.
65
+
66
+ Every key is a live key, so every invoice you create takes real payments. To see the checkout without moving real money, open the demo store at `/demo` on the Solverr app that `base_url` points to.
67
+
68
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/bell.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Webhooks
69
+
70
+ Confirm a payment with a webhook. Pass the raw request body exactly as it arrived: parsed and re-serialized JSON will not match the signature.
71
+
72
+ ```python
73
+ import os
74
+
75
+ from flask import Flask, request
76
+
77
+ import solverr
78
+
79
+ app = Flask(__name__)
80
+
81
+
82
+ @app.post("/webhooks/solverr")
83
+ def solverr_webhook():
84
+ try:
85
+ event = solverr.webhooks.verify(
86
+ request.get_data(),
87
+ request.headers,
88
+ os.environ["SOLVERR_WEBHOOK_SECRET"],
89
+ )
90
+ except solverr.WebhookVerificationError as error:
91
+ return str(error), 400
92
+
93
+ if event["type"] == "invoice.paid":
94
+ mark_order_paid(event["data"]["invoice"]["order_id"])
95
+ return "", 200
96
+ ```
97
+
98
+ In Django, pass `request.body` and `request.headers`. In FastAPI or Starlette, pass `await request.body()` and `request.headers`.
99
+
100
+ ### How `verify` checks an event
101
+
102
+ - `verify` checks the Standard Webhooks signature. The `webhook-signature` header holds `v1,` and the base64 HMAC-SHA256 of `id.timestamp.body` (`webhook-id`, `webhook-timestamp` and the raw body). The HMAC key is the part of the secret after `whsec_`, decoded from base64.
103
+ - Any `v1` entry may match, so events signed with both secrets during a rotation still pass.
104
+ - It compares the exact standard base64 text with `hmac.compare_digest`, so an unpadded or URL-safe spelling of a valid signature is refused.
105
+ - It rejects events whose `webhook-timestamp` is more than 300 seconds from now. Change that window with `tolerance=`, in seconds.
106
+ - Header names can use any casing.
107
+ - The same event can arrive more than once, so use `event["id"]` to skip events you already handled.
108
+
109
+ Each business has one webhook URL and one signing secret.
110
+
111
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/boxes.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Resources
112
+
113
+ | Call | Endpoint |
114
+ | --- | --- |
115
+ | `invoices.create(amount=, currency=, order_id=, description=, redirect_url=, customer_email=, metadata=, expires_in=, idempotency_key=)` | `POST /api/v1/invoices` |
116
+ | `invoices.retrieve(invoice_id)` | `GET /api/v1/invoices/:id` |
117
+ | `invoices.list(limit=, starting_after=, status=)` | `GET /api/v1/invoices` |
118
+ | `invoices.list_all(limit=, starting_after=, status=)` | Iterator over every matching invoice, fetching the next page as the loop goes on |
119
+ | `invoices.redeem(invoice_id)` | `POST /api/v1/invoices/:id/redeem` |
120
+ | `payment_links.create(title=, currency=, amount=, redirect_url=)`, `payment_links.list()` | `/api/v1/payment-links` |
121
+ | `payment_links.deactivate(link_id)` | `DELETE /api/v1/payment-links/:id` |
122
+ | `refunds.create(invoice_id=, payment_id=, amount=)`, `refunds.list()` | `/api/v1/refunds` |
123
+ | `payouts.create(recipient=, asset_id=, amount=)`, `payouts.list()` | `/api/v1/payouts` |
124
+ | `payouts.create_batch(payouts=[{"recipient": …, "asset_id": …, "amount": …}, …])` | `POST /api/v1/payouts/batch` |
125
+ | `refunds.retrieve(refund_id)`, `payouts.retrieve(payout_id)` | `GET /api/v1/refunds/:id`, `GET /api/v1/payouts/:id` |
126
+ | `refunds.cancel(refund_id)`, `payouts.cancel(payout_id)` | `POST /api/v1/refunds/:id/cancel`, `POST /api/v1/payouts/:id/cancel` |
127
+ | `subscriptions.create(customer_email=, amount=, currency=, interval=, description=)`, `subscriptions.list()` | `/api/v1/subscriptions` |
128
+ | `subscriptions.cancel(subscription_id)` | `DELETE /api/v1/subscriptions/:id` |
129
+ | `credits.topup(amount=, currency=, credit_account_id=)` | `POST /api/v1/credits/topup` |
130
+ | `credits.charge(token=, amount=, currency=, description=)` | `POST /api/v1/credits/charge` |
131
+ | `credits.refund_charge(charge_id)` | `POST /api/v1/credits/charges/:id/refund` |
132
+ | `tokens.list()` | `GET /api/v1/tokens` |
133
+ | `webhooks.send_test()` | `POST /api/v1/webhooks/test` |
134
+ | `solverr.webhooks.verify(raw_body, headers, secret, tolerance=300)` | Local check, no request |
135
+
136
+ In the resource calls, every argument except an id is keyword-only. `solverr.webhooks.verify` takes its arguments by position. Every `create`, `create_batch`, `topup`, `charge`, `refund_charge` and `redeem` call, and `refunds.cancel` and `payouts.cancel`, also takes `idempotency_key=`.
137
+
138
+ Credit balances are kept in USD: `credits.topup` needs `currency="USD"`, and `credits.charge` accepts only `"USD"` when you pass a currency.
139
+
140
+ ### Refunds and payouts
141
+
142
+ `refunds.create` refunds the payment that paid the invoice. To refund an extra payment on the same invoice, pass its id as `payment_id=`. Leave out `amount=` to refund what is left of that payment.
143
+
144
+ The `asset_id=` of a payout is the coin the recipient receives. Take it from `tokens.list()`, which returns every coin the checkout accepts, with its chain, decimals and USD price.
145
+
146
+ ### Lists and pages
147
+
148
+ Every list call except `tokens.list()` returns `{"object": "list", "data": [...], "has_more": bool}`. To get the next page, pass the last item's id as `starting_after`, or loop over `list_all()`:
149
+
150
+ ```python
151
+ for invoice in client.invoices.list_all(status="paid"):
152
+ print(invoice["id"])
153
+ ```
154
+
155
+ `tokens.list()` returns every coin in one `{"object": "list", "data": [...]}` response, without pages.
156
+
157
+ ### Grant access once per paid invoice
158
+
159
+ `invoices.redeem(invoice_id)` marks a paid invoice as used. Call it before you deliver a download or show paid content, so one payment cannot be used twice.
160
+
161
+ ```python
162
+ from solverr import SolverrError
163
+
164
+ try:
165
+ client.invoices.redeem(invoice_id)
166
+ # deliver the content
167
+ except SolverrError as error:
168
+ if error.status == 409:
169
+ ... # error.type is "invalid_state" (not paid) or "already_redeemed"
170
+ else:
171
+ raise
172
+ ```
173
+
174
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/triangle-alert.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Errors and Retries
175
+
176
+ API errors raise `solverr.SolverrError` with these attributes:
177
+
178
+ | Attribute | Meaning |
179
+ | --- | --- |
180
+ | `status` | The HTTP status, or `0` when the API cannot be reached. |
181
+ | `type` | The error type, or `connection_error` when the API cannot be reached. |
182
+ | `message` | The error message. |
183
+ | `param` | The invalid field, when a field is invalid. |
184
+ | `code` | A finer reason, when the API adds one. None of the endpoints this SDK calls send one today. |
185
+ | `retry_after` | The seconds the API asked you to wait, on a `429` for example. |
186
+
187
+ ```python
188
+ from solverr import SolverrError
189
+
190
+ try:
191
+ client.invoices.create(amount="0", currency="USD")
192
+ except SolverrError as error:
193
+ print(error.status, error.type, error.param, error.message)
194
+ # 400 invalid_request amount amount must be greater than 0.
195
+ ```
196
+
197
+ ### Timeouts and retries
198
+
199
+ Each attempt allows 15 seconds to connect and 15 seconds for each wait on the network. The SDK stops reading the response body once 15 seconds have passed since the attempt began.
200
+
201
+ | Response | How the SDK retries |
202
+ | --- | --- |
203
+ | A timeout, a network error or a 5xx response | Twice, with a growing delay. |
204
+ | A `429` (rate limit), or a `409` that says an earlier request with the same `Idempotency-Key` is still being processed | Up to three times, after the wait in its `Retry-After` header. When `Retry-After` asks for more than 10 seconds, the error is raised right away. |
205
+
206
+ With those retries, one call ends after about 47 seconds when the server does not answer at all. A slow connection or slow response headers can make it longer, and `Retry-After` retries add to that.
207
+
208
+ ### Idempotency keys
209
+
210
+ Every POST carries an `Idempotency-Key` header. The SDK generates one when you do not pass one, and every retry sends the same key, so a retried POST never creates a second object. Pass your own key to make retries across process restarts safe too.
211
+
212
+ A key belongs to the business for 24 hours and covers every endpoint. Give each operation its own key, for example `invoice-order-1042` for the invoice of order 1042 and `refund-order-1042` for its refund, not the order id alone. If you send a key again with a different request (another body or another endpoint), the API answers `409` with type `idempotency_conflict`, and the SDK raises it without a retry.
213
+
214
+ ```python
215
+ invoice = client.invoices.create(
216
+ amount="25.00",
217
+ currency="USD",
218
+ order_id="1042",
219
+ idempotency_key="invoice-order-1042",
220
+ )
221
+ ```
222
+
223
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/shield-check.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Tests
224
+
225
+ The tests use only the standard library (`unittest`) and need no install step: `tests/__init__.py` puts `src` on the import path. HTTP tests run against a local server on `127.0.0.1` and never reach a real host. Webhook tests generate their own secrets. From this folder, run:
226
+
227
+ ```bash
228
+ python -I -m unittest
229
+ ```
230
+
231
+ CI names the test folder explicitly: `python -I -m unittest discover -s tests -t .`. The `-t .` makes `tests` load as a package.
232
+
233
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/file-text.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> License
234
+
235
+ Released under the **MIT License**.
@@ -0,0 +1,216 @@
1
+ # <img src="https://github.com/forcequitx/solverr/raw/main/app/icon.svg" alt="Solverr" width="30" height="30"> solverr
2
+
3
+ **The Python server SDK for the Solverr crypto payment API.**
4
+
5
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/lightbulb.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Overview
6
+
7
+ Your server uses this SDK to create invoices, payment links, refunds, payouts, subscriptions and credit charges, to list the coins the checkout accepts, and to verify the signed webhooks Solverr sends.
8
+
9
+ - **Checkout:** your server calls `client.invoices.create` -> the customer pays at the invoice's `checkout_url`.
10
+ - **Webhooks:** Solverr sends a signed `invoice.paid` event -> `solverr.webhooks.verify` checks it and returns the event.
11
+ - **Paid content:** `client.invoices.redeem` marks a paid invoice as used -> one payment grants access only once.
12
+
13
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/package.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Install
14
+
15
+ The SDK needs Python 3.11 or later, uses only the standard library, and ships type hints.
16
+
17
+ ```bash
18
+ pip install solverr
19
+ ```
20
+
21
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/terminal.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Quick Start
22
+
23
+ Create an invoice and send the customer to checkout:
24
+
25
+ ```python
26
+ import os
27
+
28
+ from solverr import Solverr
29
+
30
+ client = Solverr(
31
+ api_key=os.environ["SOLVERR_SECRET_KEY"], # sk_live_…
32
+ base_url=os.environ["SOLVERR_URL"], # the origin of your Solverr app
33
+ )
34
+
35
+ invoice = client.invoices.create(
36
+ amount="25.00",
37
+ currency="USD",
38
+ order_id="1042",
39
+ redirect_url="https://shop.example/thanks",
40
+ )
41
+
42
+ # Redirect the customer to invoice["checkout_url"]
43
+ ```
44
+
45
+ `base_url` can be left out when the `SOLVERR_URL` environment variable is set. It must be an https URL. Plain http works only for `localhost`, `127.0.0.1` and `[::1]`, so your API key never travels unencrypted. Arguments and response keys use the API's own field names (`order_id`, `checkout_url`), so the API reference applies as written. Responses are plain dictionaries, typed with the `TypedDict` classes in `solverr.types`.
46
+
47
+ Every key is a live key, so every invoice you create takes real payments. To see the checkout without moving real money, open the demo store at `/demo` on the Solverr app that `base_url` points to.
48
+
49
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/bell.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Webhooks
50
+
51
+ Confirm a payment with a webhook. Pass the raw request body exactly as it arrived: parsed and re-serialized JSON will not match the signature.
52
+
53
+ ```python
54
+ import os
55
+
56
+ from flask import Flask, request
57
+
58
+ import solverr
59
+
60
+ app = Flask(__name__)
61
+
62
+
63
+ @app.post("/webhooks/solverr")
64
+ def solverr_webhook():
65
+ try:
66
+ event = solverr.webhooks.verify(
67
+ request.get_data(),
68
+ request.headers,
69
+ os.environ["SOLVERR_WEBHOOK_SECRET"],
70
+ )
71
+ except solverr.WebhookVerificationError as error:
72
+ return str(error), 400
73
+
74
+ if event["type"] == "invoice.paid":
75
+ mark_order_paid(event["data"]["invoice"]["order_id"])
76
+ return "", 200
77
+ ```
78
+
79
+ In Django, pass `request.body` and `request.headers`. In FastAPI or Starlette, pass `await request.body()` and `request.headers`.
80
+
81
+ ### How `verify` checks an event
82
+
83
+ - `verify` checks the Standard Webhooks signature. The `webhook-signature` header holds `v1,` and the base64 HMAC-SHA256 of `id.timestamp.body` (`webhook-id`, `webhook-timestamp` and the raw body). The HMAC key is the part of the secret after `whsec_`, decoded from base64.
84
+ - Any `v1` entry may match, so events signed with both secrets during a rotation still pass.
85
+ - It compares the exact standard base64 text with `hmac.compare_digest`, so an unpadded or URL-safe spelling of a valid signature is refused.
86
+ - It rejects events whose `webhook-timestamp` is more than 300 seconds from now. Change that window with `tolerance=`, in seconds.
87
+ - Header names can use any casing.
88
+ - The same event can arrive more than once, so use `event["id"]` to skip events you already handled.
89
+
90
+ Each business has one webhook URL and one signing secret.
91
+
92
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/boxes.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Resources
93
+
94
+ | Call | Endpoint |
95
+ | --- | --- |
96
+ | `invoices.create(amount=, currency=, order_id=, description=, redirect_url=, customer_email=, metadata=, expires_in=, idempotency_key=)` | `POST /api/v1/invoices` |
97
+ | `invoices.retrieve(invoice_id)` | `GET /api/v1/invoices/:id` |
98
+ | `invoices.list(limit=, starting_after=, status=)` | `GET /api/v1/invoices` |
99
+ | `invoices.list_all(limit=, starting_after=, status=)` | Iterator over every matching invoice, fetching the next page as the loop goes on |
100
+ | `invoices.redeem(invoice_id)` | `POST /api/v1/invoices/:id/redeem` |
101
+ | `payment_links.create(title=, currency=, amount=, redirect_url=)`, `payment_links.list()` | `/api/v1/payment-links` |
102
+ | `payment_links.deactivate(link_id)` | `DELETE /api/v1/payment-links/:id` |
103
+ | `refunds.create(invoice_id=, payment_id=, amount=)`, `refunds.list()` | `/api/v1/refunds` |
104
+ | `payouts.create(recipient=, asset_id=, amount=)`, `payouts.list()` | `/api/v1/payouts` |
105
+ | `payouts.create_batch(payouts=[{"recipient": …, "asset_id": …, "amount": …}, …])` | `POST /api/v1/payouts/batch` |
106
+ | `refunds.retrieve(refund_id)`, `payouts.retrieve(payout_id)` | `GET /api/v1/refunds/:id`, `GET /api/v1/payouts/:id` |
107
+ | `refunds.cancel(refund_id)`, `payouts.cancel(payout_id)` | `POST /api/v1/refunds/:id/cancel`, `POST /api/v1/payouts/:id/cancel` |
108
+ | `subscriptions.create(customer_email=, amount=, currency=, interval=, description=)`, `subscriptions.list()` | `/api/v1/subscriptions` |
109
+ | `subscriptions.cancel(subscription_id)` | `DELETE /api/v1/subscriptions/:id` |
110
+ | `credits.topup(amount=, currency=, credit_account_id=)` | `POST /api/v1/credits/topup` |
111
+ | `credits.charge(token=, amount=, currency=, description=)` | `POST /api/v1/credits/charge` |
112
+ | `credits.refund_charge(charge_id)` | `POST /api/v1/credits/charges/:id/refund` |
113
+ | `tokens.list()` | `GET /api/v1/tokens` |
114
+ | `webhooks.send_test()` | `POST /api/v1/webhooks/test` |
115
+ | `solverr.webhooks.verify(raw_body, headers, secret, tolerance=300)` | Local check, no request |
116
+
117
+ In the resource calls, every argument except an id is keyword-only. `solverr.webhooks.verify` takes its arguments by position. Every `create`, `create_batch`, `topup`, `charge`, `refund_charge` and `redeem` call, and `refunds.cancel` and `payouts.cancel`, also takes `idempotency_key=`.
118
+
119
+ Credit balances are kept in USD: `credits.topup` needs `currency="USD"`, and `credits.charge` accepts only `"USD"` when you pass a currency.
120
+
121
+ ### Refunds and payouts
122
+
123
+ `refunds.create` refunds the payment that paid the invoice. To refund an extra payment on the same invoice, pass its id as `payment_id=`. Leave out `amount=` to refund what is left of that payment.
124
+
125
+ The `asset_id=` of a payout is the coin the recipient receives. Take it from `tokens.list()`, which returns every coin the checkout accepts, with its chain, decimals and USD price.
126
+
127
+ ### Lists and pages
128
+
129
+ Every list call except `tokens.list()` returns `{"object": "list", "data": [...], "has_more": bool}`. To get the next page, pass the last item's id as `starting_after`, or loop over `list_all()`:
130
+
131
+ ```python
132
+ for invoice in client.invoices.list_all(status="paid"):
133
+ print(invoice["id"])
134
+ ```
135
+
136
+ `tokens.list()` returns every coin in one `{"object": "list", "data": [...]}` response, without pages.
137
+
138
+ ### Grant access once per paid invoice
139
+
140
+ `invoices.redeem(invoice_id)` marks a paid invoice as used. Call it before you deliver a download or show paid content, so one payment cannot be used twice.
141
+
142
+ ```python
143
+ from solverr import SolverrError
144
+
145
+ try:
146
+ client.invoices.redeem(invoice_id)
147
+ # deliver the content
148
+ except SolverrError as error:
149
+ if error.status == 409:
150
+ ... # error.type is "invalid_state" (not paid) or "already_redeemed"
151
+ else:
152
+ raise
153
+ ```
154
+
155
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/triangle-alert.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Errors and Retries
156
+
157
+ API errors raise `solverr.SolverrError` with these attributes:
158
+
159
+ | Attribute | Meaning |
160
+ | --- | --- |
161
+ | `status` | The HTTP status, or `0` when the API cannot be reached. |
162
+ | `type` | The error type, or `connection_error` when the API cannot be reached. |
163
+ | `message` | The error message. |
164
+ | `param` | The invalid field, when a field is invalid. |
165
+ | `code` | A finer reason, when the API adds one. None of the endpoints this SDK calls send one today. |
166
+ | `retry_after` | The seconds the API asked you to wait, on a `429` for example. |
167
+
168
+ ```python
169
+ from solverr import SolverrError
170
+
171
+ try:
172
+ client.invoices.create(amount="0", currency="USD")
173
+ except SolverrError as error:
174
+ print(error.status, error.type, error.param, error.message)
175
+ # 400 invalid_request amount amount must be greater than 0.
176
+ ```
177
+
178
+ ### Timeouts and retries
179
+
180
+ Each attempt allows 15 seconds to connect and 15 seconds for each wait on the network. The SDK stops reading the response body once 15 seconds have passed since the attempt began.
181
+
182
+ | Response | How the SDK retries |
183
+ | --- | --- |
184
+ | A timeout, a network error or a 5xx response | Twice, with a growing delay. |
185
+ | A `429` (rate limit), or a `409` that says an earlier request with the same `Idempotency-Key` is still being processed | Up to three times, after the wait in its `Retry-After` header. When `Retry-After` asks for more than 10 seconds, the error is raised right away. |
186
+
187
+ With those retries, one call ends after about 47 seconds when the server does not answer at all. A slow connection or slow response headers can make it longer, and `Retry-After` retries add to that.
188
+
189
+ ### Idempotency keys
190
+
191
+ Every POST carries an `Idempotency-Key` header. The SDK generates one when you do not pass one, and every retry sends the same key, so a retried POST never creates a second object. Pass your own key to make retries across process restarts safe too.
192
+
193
+ A key belongs to the business for 24 hours and covers every endpoint. Give each operation its own key, for example `invoice-order-1042` for the invoice of order 1042 and `refund-order-1042` for its refund, not the order id alone. If you send a key again with a different request (another body or another endpoint), the API answers `409` with type `idempotency_conflict`, and the SDK raises it without a retry.
194
+
195
+ ```python
196
+ invoice = client.invoices.create(
197
+ amount="25.00",
198
+ currency="USD",
199
+ order_id="1042",
200
+ idempotency_key="invoice-order-1042",
201
+ )
202
+ ```
203
+
204
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/shield-check.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Tests
205
+
206
+ The tests use only the standard library (`unittest`) and need no install step: `tests/__init__.py` puts `src` on the import path. HTTP tests run against a local server on `127.0.0.1` and never reach a real host. Webhook tests generate their own secrets. From this folder, run:
207
+
208
+ ```bash
209
+ python -I -m unittest
210
+ ```
211
+
212
+ CI names the test folder explicitly: `python -I -m unittest discover -s tests -t .`. The `-t .` makes `tests` load as a package.
213
+
214
+ ## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/file-text.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> License
215
+
216
+ Released under the **MIT License**.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "solverr"
7
+ version = "0.1.0"
8
+ description = "Server SDK for the Solverr crypto payment API."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ dependencies = []
14
+ keywords = ["solverr", "payments", "crypto", "checkout", "webhooks"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/forcequitx/solverr/tree/main/packages/python#readme"
26
+ Source = "https://github.com/forcequitx/solverr/tree/main/packages/python"
27
+ Issues = "https://github.com/forcequitx/solverr/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/solverr"]
@@ -0,0 +1,8 @@
1
+ """Server SDK for the Solverr crypto payment API."""
2
+
3
+ from . import types, webhooks
4
+ from ._client import Solverr
5
+ from ._config import SDK_VERSION as __version__
6
+ from ._errors import SolverrError, WebhookVerificationError
7
+
8
+ __all__ = ["Solverr", "SolverrError", "WebhookVerificationError", "__version__", "types", "webhooks"]
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import urllib.parse
6
+
7
+ from ._config import BASE_URL_ENV, LOCAL_HOSTS
8
+ from ._http import HttpClient
9
+ from .resources import Credits, Invoices, PaymentLinks, Payouts, Refunds, Subscriptions, Tokens, Webhooks
10
+
11
+ _API_KEY_PATTERN = re.compile(r"sk_live_[\x21-\x7e]+")
12
+ # Whitespace and control characters, which http.client refuses in a URL.
13
+ _URL_UNSAFE = re.compile(r"[\s\x00-\x1f\x7f]")
14
+
15
+
16
+ def _resolve_base_url(base_url: str | None) -> str:
17
+ """Validates the base URL and drops a trailing slash so API paths can be appended."""
18
+ value = base_url if base_url is not None else os.environ.get(BASE_URL_ENV)
19
+ if not value:
20
+ raise ValueError(f"Pass base_url to Solverr() or set the {BASE_URL_ENV} environment variable.")
21
+ parts = urllib.parse.urlsplit(value)
22
+ if not parts.netloc or _URL_UNSAFE.search(value):
23
+ raise ValueError(f"base_url is not a valid URL: {value}")
24
+ if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in LOCAL_HOSTS):
25
+ raise ValueError("base_url must be an https URL (http only for localhost).")
26
+ # "?" or "#" with nothing after it gives an empty query or fragment, yet still breaks the appended API paths.
27
+ if "?" in value or "#" in value:
28
+ raise ValueError("base_url must not have a query string or fragment.")
29
+ return value.rstrip("/")
30
+
31
+
32
+ class Solverr:
33
+ """Client for the Solverr API; ``api_key`` is an API key starting with ``sk_live_``.
34
+
35
+ ``base_url`` is the origin of the Solverr app, such as ``"https://pay.example.com"``; defaults to ``SOLVERR_URL``."""
36
+
37
+ def __init__(self, api_key: str | None, base_url: str | None = None) -> None:
38
+ if not api_key or not _API_KEY_PATTERN.fullmatch(api_key):
39
+ raise ValueError("api_key must be a Solverr API key starting with sk_live_.")
40
+ http = HttpClient(api_key, _resolve_base_url(base_url))
41
+ self.invoices = Invoices(http)
42
+ self.payment_links = PaymentLinks(http)
43
+ self.refunds = Refunds(http)
44
+ self.payouts = Payouts(http)
45
+ self.subscriptions = Subscriptions(http)
46
+ self.credits = Credits(http)
47
+ self.tokens = Tokens(http)
48
+ self.webhooks = Webhooks(http)
@@ -0,0 +1,38 @@
1
+ SDK_VERSION = "0.1.0"
2
+
3
+ BASE_URL_ENV = "SOLVERR_URL"
4
+
5
+ # Hosts that may serve the API over plain http, for local development; every other host needs https.
6
+ LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
7
+
8
+ # Socket timeout for connecting and for each read; reading the body also stops once this many seconds have passed since the attempt began.
9
+ REQUEST_TIMEOUT_SECONDS = 15.0
10
+
11
+ # Largest piece of a response body read at once, between checks of the request deadline.
12
+ READ_CHUNK_BYTES = 65536
13
+
14
+ # Extra attempts after a network error or a 5xx response.
15
+ MAX_RETRIES = 2
16
+
17
+ # Delay before the first retry; each later retry doubles it.
18
+ RETRY_BASE_DELAY_SECONDS = 0.5
19
+
20
+ # Extra attempts after a 429, or a 409 for a key that is still being processed, each after its Retry-After wait.
21
+ MAX_RETRY_AFTER_RETRIES = 3
22
+
23
+ # Longest Retry-After the client waits for; a longer one is raised as an error.
24
+ MAX_RETRY_AFTER_SECONDS = 10
25
+
26
+ WEBHOOK_TOLERANCE_SECONDS = 300
27
+
28
+ # Standard Webhooks headers (standardwebhooks.com), lowercase for lookups.
29
+ WEBHOOK_ID_HEADER = "webhook-id"
30
+
31
+ WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp"
32
+
33
+ WEBHOOK_SIGNATURE_HEADER = "webhook-signature"
34
+
35
+ WEBHOOK_SECRET_PREFIX = "whsec_"
36
+
37
+ # Shortest signing key accepted after decoding; Standard Webhooks secrets are 24 to 64 bytes.
38
+ WEBHOOK_SECRET_MIN_BYTES = 24
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SolverrError(Exception):
5
+ """An error returned by the Solverr API, or a request that never got a response (``status`` 0)."""
6
+
7
+ def __init__(
8
+ self,
9
+ status: int,
10
+ type: str,
11
+ message: str,
12
+ param: str | None = None,
13
+ code: str | None = None,
14
+ retry_after: int | None = None,
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.type = type
19
+ self.message = message
20
+ self.param = param
21
+ # Finer reason inside ``type``, such as ``invoice_paid``, when the API sends one.
22
+ self.code = code
23
+ # Seconds the API asked to wait before trying again (its ``Retry-After`` header), such as on a 429.
24
+ self.retry_after = retry_after
25
+
26
+ def __reduce__(self) -> tuple[type[SolverrError], tuple[int, str, str, str | None, str | None, int | None]]:
27
+ """Rebuilds the error from all its fields, so it survives pickling across processes and ``copy``."""
28
+ return (SolverrError, (self.status, self.type, self.message, self.param, self.code, self.retry_after))
29
+
30
+ def __repr__(self) -> str:
31
+ return (
32
+ f"SolverrError(status={self.status!r}, type={self.type!r}, code={self.code!r}, "
33
+ f"message={self.message!r}, param={self.param!r}, retry_after={self.retry_after!r})"
34
+ )
35
+
36
+
37
+ class WebhookVerificationError(Exception):
38
+ """A webhook whose signature, timestamp or body could not be verified."""