solverr 0.1.0__py3-none-any.whl

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.
solverr/types.py ADDED
@@ -0,0 +1,298 @@
1
+ """Shapes of the objects the Solverr API returns and the events it sends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, TypedDict
6
+
7
+ # Every object an API key reaches is live; test mode exists only for demo store invoices.
8
+ Mode = Literal["live"]
9
+
10
+ Currency = Literal["USD", "EUR", "INR"]
11
+
12
+ InvoiceStatus = Literal[
13
+ "created",
14
+ "awaiting_payment",
15
+ "detected",
16
+ "processing",
17
+ "paid",
18
+ "underpaid",
19
+ "refunded",
20
+ "failed",
21
+ "expired",
22
+ ]
23
+
24
+ RefundStatus = Literal["pending", "processing", "completed", "failed", "canceled"]
25
+
26
+ PayoutStatus = Literal["pending", "processing", "completed", "failed", "canceled"]
27
+
28
+ SubscriptionInterval = Literal["week", "month"]
29
+
30
+ SubscriptionStatus = Literal["active", "canceled"]
31
+
32
+
33
+ class Settlement(TypedDict):
34
+ """The coin and amount the business receives for an invoice."""
35
+
36
+ asset_id: str
37
+ symbol: str
38
+ blockchain: str
39
+ amount: str
40
+ amount_base: str
41
+
42
+
43
+ class Invoice(TypedDict):
44
+ id: str
45
+ object: Literal["invoice"]
46
+ mode: Mode
47
+ status: InvoiceStatus
48
+ amount: str
49
+ currency: Currency
50
+ order_id: str | None
51
+ description: str | None
52
+ customer_email: str | None
53
+ redirect_url: str | None
54
+ metadata: dict[str, str]
55
+ settlement: Settlement
56
+ late: bool
57
+ extra_payment: bool
58
+ checkout_url: str
59
+ receipt_url: str | None
60
+ redeemed_at: str | None
61
+ expires_at: str
62
+ created_at: str
63
+
64
+
65
+ class PaymentCoin(TypedDict):
66
+ asset_id: str
67
+ symbol: str
68
+ blockchain: str
69
+ chain_name: str
70
+ decimals: int
71
+
72
+
73
+ class PaymentTransaction(TypedDict):
74
+ hash: str
75
+ url: str | None
76
+
77
+
78
+ class Payment(TypedDict):
79
+ """One payment attempt on an invoice: a quote and its latest network status."""
80
+
81
+ id: str
82
+ object: Literal["payment"]
83
+ status: str
84
+ coin: PaymentCoin
85
+ amount_in: str
86
+ amount_in_base: str
87
+ min_amount_in_base: str
88
+ amount_in_usd: str | None
89
+ deposit_address: str
90
+ deposit_memo: str | None
91
+ refund_address: str
92
+ # True when NEAR Intents quoted this attempt as a confidential intent (private payments).
93
+ confidential: bool
94
+ deadline: str
95
+ time_estimate_seconds: int
96
+ deposited_amount: str | None
97
+ refunded_amount: str | None
98
+ refund_fee: str | None
99
+ refund_reason: str | None
100
+ origin_transactions: list[PaymentTransaction]
101
+ destination_transactions: list[PaymentTransaction]
102
+ correlation_id: str
103
+ quote_signature: str
104
+ created_at: str
105
+
106
+
107
+ class PaymentLink(TypedDict):
108
+ id: str
109
+ object: Literal["payment_link"]
110
+ mode: Mode
111
+ url: str
112
+ title: str
113
+ amount: str | None
114
+ currency: Currency
115
+ redirect_url: str | None
116
+ active: bool
117
+ created_at: str
118
+
119
+
120
+ class Refund(TypedDict):
121
+ """``amount`` is in ``source_asset``, the business's settlement coin; the payer receives ``asset_id`` at ``recipient``."""
122
+
123
+ id: str
124
+ object: Literal["refund"]
125
+ status: RefundStatus
126
+ invoice_id: str
127
+ batch_id: str | None
128
+ amount: str
129
+ source_asset: str
130
+ asset_id: str
131
+ recipient: str
132
+ tx_hash: str | None
133
+ failure_reason: str | None
134
+ sign_url: str
135
+ created_at: str
136
+
137
+
138
+ class Payout(TypedDict):
139
+ """``amount`` is in ``source_asset``, the business's settlement coin; the recipient receives ``asset_id``."""
140
+
141
+ id: str
142
+ object: Literal["payout"]
143
+ status: PayoutStatus
144
+ invoice_id: str | None
145
+ batch_id: str | None
146
+ amount: str
147
+ source_asset: str
148
+ asset_id: str
149
+ recipient: str
150
+ tx_hash: str | None
151
+ failure_reason: str | None
152
+ sign_url: str
153
+ created_at: str
154
+
155
+
156
+ class PayoutBatch(TypedDict):
157
+ """``id`` is the ``batch_id`` of every payout in ``data``."""
158
+
159
+ object: Literal["payout_batch"]
160
+ id: str
161
+ data: list[Payout]
162
+
163
+
164
+ class Subscription(TypedDict):
165
+ id: str
166
+ object: Literal["subscription"]
167
+ status: SubscriptionStatus
168
+ customer_email: str
169
+ amount: str
170
+ currency: Currency
171
+ interval: SubscriptionInterval
172
+ description: str | None
173
+ next_invoice_at: str
174
+ created_at: str
175
+
176
+
177
+ class _CreditAccountBase(TypedDict):
178
+ id: str
179
+ object: Literal["credit_account"]
180
+ balance: str
181
+ currency: Literal["USD"]
182
+
183
+
184
+ class CreditAccount(_CreditAccountBase, total=False):
185
+ # Returned only when the account is created. Store it like a password.
186
+ token: str
187
+
188
+
189
+ class CreditTopup(TypedDict):
190
+ credit_account: CreditAccount
191
+ invoice: Invoice
192
+
193
+
194
+ class _CreditChargeBase(TypedDict):
195
+ # Charge id (cru_…); pass it to credits.refund_charge to give the charge back.
196
+ id: str
197
+ object: Literal["credit_charge"]
198
+ amount: str
199
+ # Balance after the charge or refund, with 6 decimals.
200
+ balance: str
201
+ currency: Literal["USD"]
202
+
203
+
204
+ class CreditCharge(_CreditChargeBase, total=False):
205
+ # True on the answer of credits.refund_charge.
206
+ refunded: bool
207
+
208
+
209
+ class Token(TypedDict):
210
+ """A coin the checkout accepts; a payout's ``asset_id`` is one of these."""
211
+
212
+ asset_id: str
213
+ symbol: str
214
+ blockchain: str
215
+ # Display name of the chain, such as "Base".
216
+ chain_name: str
217
+ decimals: int
218
+ price_usd: float
219
+ # Token contract, or None for a chain's native coin.
220
+ contract_address: str | None
221
+
222
+
223
+ class WebhookTest(TypedDict):
224
+ queued: Literal[True]
225
+
226
+
227
+ class InvoiceRedemption(TypedDict):
228
+ id: str
229
+ redeemed_at: str
230
+ payment: Payment | None
231
+
232
+
233
+ class InvoiceList(TypedDict):
234
+ object: Literal["list"]
235
+ data: list[Invoice]
236
+ has_more: bool
237
+
238
+
239
+ class PaymentLinkList(TypedDict):
240
+ object: Literal["list"]
241
+ data: list[PaymentLink]
242
+ has_more: bool
243
+
244
+
245
+ class RefundList(TypedDict):
246
+ object: Literal["list"]
247
+ data: list[Refund]
248
+ has_more: bool
249
+
250
+
251
+ class PayoutList(TypedDict):
252
+ object: Literal["list"]
253
+ data: list[Payout]
254
+ has_more: bool
255
+
256
+
257
+ class SubscriptionList(TypedDict):
258
+ object: Literal["list"]
259
+ data: list[Subscription]
260
+ has_more: bool
261
+
262
+
263
+ class TokenList(TypedDict):
264
+ """Every coin at once; unlike other lists it has no pages and no ``has_more``."""
265
+
266
+ object: Literal["list"]
267
+ data: list[Token]
268
+
269
+
270
+ WebhookEventType = Literal[
271
+ "invoice.detected",
272
+ "invoice.paid",
273
+ "invoice.underpaid",
274
+ "invoice.refunded",
275
+ "invoice.failed",
276
+ "invoice.expired",
277
+ "invoice.extra_payment",
278
+ "subscription.invoice_created",
279
+ "refund.completed",
280
+ "refund.failed",
281
+ "refund.canceled",
282
+ "payout.completed",
283
+ "payout.failed",
284
+ "payout.canceled",
285
+ "support_case.created",
286
+ "test.ping",
287
+ ]
288
+
289
+
290
+ class WebhookEvent(TypedDict):
291
+ """A verified webhook event; by type, ``data`` holds ``invoice`` (and ``payment``), ``refund``, ``payout``,
292
+ ``support_case`` with ``invoice``, or ``message`` for ``test.ping``."""
293
+
294
+ id: str
295
+ type: WebhookEventType
296
+ mode: Mode
297
+ created_at: str
298
+ data: dict[str, Any]
solverr/webhooks.py ADDED
@@ -0,0 +1,109 @@
1
+ """Verification of the signed webhook events Solverr sends to a business's endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import re
11
+ import time
12
+ from collections.abc import Iterable
13
+ from typing import Protocol, cast
14
+
15
+ from ._config import (
16
+ WEBHOOK_ID_HEADER,
17
+ WEBHOOK_SECRET_MIN_BYTES,
18
+ WEBHOOK_SECRET_PREFIX,
19
+ WEBHOOK_SIGNATURE_HEADER,
20
+ WEBHOOK_TIMESTAMP_HEADER,
21
+ WEBHOOK_TOLERANCE_SECONDS,
22
+ )
23
+ from ._errors import WebhookVerificationError
24
+ from .types import WebhookEvent
25
+
26
+ __all__ = ["WebhookHeaders", "WebhookVerificationError", "verify"]
27
+
28
+ _TIMESTAMP_PATTERN = re.compile(r"[0-9]{1,12}")
29
+ _SIGNATURE_VERSION = "v1,"
30
+
31
+
32
+ class WebhookHeaders(Protocol):
33
+ """Request headers: a dict or any framework header object with ``items()`` (Flask, Django, Starlette)."""
34
+
35
+ def items(self) -> Iterable[tuple[str, object]]: ...
36
+
37
+
38
+ def _read_header(headers: WebhookHeaders, name: str) -> str | None:
39
+ """Finds a header by name with any key casing; a list value counts only when it holds exactly one string."""
40
+ for key, value in headers.items():
41
+ if key.lower() != name:
42
+ continue
43
+ if isinstance(value, (list, tuple)) and len(value) == 1:
44
+ value = value[0]
45
+ return value if isinstance(value, str) else None
46
+ return None
47
+
48
+
49
+ def _secret_key(secret: str) -> bytes:
50
+ """The HMAC key of a ``whsec_`` secret: the base64 after the prefix, decoded, and long enough to be a real secret."""
51
+ encoded = secret[len(WEBHOOK_SECRET_PREFIX) :]
52
+ try:
53
+ key = base64.b64decode(encoded + "=" * (-len(encoded) % 4), validate=True)
54
+ except binascii.Error:
55
+ key = b""
56
+ if len(key) < WEBHOOK_SECRET_MIN_BYTES:
57
+ raise WebhookVerificationError(
58
+ f"The webhook secret must be whsec_ followed by at least {WEBHOOK_SECRET_MIN_BYTES} bytes in standard base64."
59
+ )
60
+ return key
61
+
62
+
63
+ def verify(
64
+ raw_body: bytes | str,
65
+ headers: WebhookHeaders,
66
+ secret: str,
67
+ tolerance: int = WEBHOOK_TOLERANCE_SECONDS,
68
+ ) -> WebhookEvent:
69
+ """Checks the Standard Webhooks signature (any ``v1`` entry, so secret rotations verify) and timestamp age; returns the event.
70
+
71
+ Pass the raw body exactly as received; ``tolerance`` is the allowed clock gap in seconds. Raises ``WebhookVerificationError``."""
72
+ if not isinstance(tolerance, int) or tolerance < 0:
73
+ raise WebhookVerificationError("tolerance must be a whole number of seconds, 0 or more.")
74
+ if not isinstance(secret, str) or not secret.startswith(WEBHOOK_SECRET_PREFIX):
75
+ raise WebhookVerificationError("The webhook secret must start with whsec_.")
76
+ key = _secret_key(secret)
77
+
78
+ message_id = _read_header(headers, WEBHOOK_ID_HEADER)
79
+ timestamp = _read_header(headers, WEBHOOK_TIMESTAMP_HEADER)
80
+ signatures = _read_header(headers, WEBHOOK_SIGNATURE_HEADER)
81
+ if not message_id or not timestamp or not signatures:
82
+ raise WebhookVerificationError("The webhook-id, webhook-timestamp or webhook-signature header is missing.")
83
+ if not _TIMESTAMP_PATTERN.fullmatch(timestamp):
84
+ raise WebhookVerificationError("webhook-timestamp must be a unix time in seconds.")
85
+
86
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
87
+ signed = f"{message_id}.{timestamp}.".encode("utf-8") + body
88
+ # Compared as exact standard base64 text, as in every Solverr SDK, so no other spelling of the same bytes is accepted.
89
+ expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
90
+ received = [
91
+ entry[len(_SIGNATURE_VERSION) :].encode("ascii", "replace")
92
+ for entry in signatures.split(" ")
93
+ if entry.startswith(_SIGNATURE_VERSION)
94
+ ]
95
+ if not any(hmac.compare_digest(expected, signature) for signature in received):
96
+ raise WebhookVerificationError(
97
+ "The webhook signature does not match. Check the webhook secret and pass the raw request body."
98
+ )
99
+
100
+ if abs(int(time.time()) - int(timestamp)) > tolerance:
101
+ raise WebhookVerificationError(f"The webhook timestamp is more than {tolerance} seconds from the current time.")
102
+
103
+ try:
104
+ event = json.loads(body)
105
+ except ValueError:
106
+ raise WebhookVerificationError("The webhook body is not valid JSON.") from None
107
+ if not isinstance(event, dict):
108
+ raise WebhookVerificationError("The webhook body is not a JSON object.")
109
+ return cast(WebhookEvent, event)
@@ -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,21 @@
1
+ solverr/__init__.py,sha256=jhBp6PHxu3kJJ9BY8MQ4OV9MaVLaCXCvTErZbtdb-No,324
2
+ solverr/_client.py,sha256=Glh4RpOAmaz6W3n3XoIqWSL4fx3Wq6wmRdr_VhaTXrE,2239
3
+ solverr/_config.py,sha256=vryLiheKpNcvPNmNSTYnVNxTw4CGmVUSdZu010Nr-es,1316
4
+ solverr/_errors.py,sha256=8AKdOQ9pA37mpFBUtis23pFcyJOfBBzJTDyi96hSBx4,1502
5
+ solverr/_http.py,sha256=Xisbwh-c_VCcY0uvH0aJHrkKEVB6t83hIriVwbyFR40,8386
6
+ solverr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ solverr/types.py,sha256=vWP5HQGwunkm2opFgc2X3GhDXVKKI_1EkIet2DcZPjw,6742
8
+ solverr/webhooks.py,sha256=94w_AKNY1wl_2tFIzr2ouyAb-fyS8TDmJk6jboH85js,4480
9
+ solverr/resources/__init__.py,sha256=xJ3RAS0Ba_8bfG-jZkPFTA4pI7Puqfd_V4GCqHRImFw,369
10
+ solverr/resources/credits.py,sha256=0vjzY2n1sVQgRYrbZ-1gFxyZEQy0W1ySW3-SUChvxk0,1995
11
+ solverr/resources/invoices.py,sha256=NgZO-AW7mmAOzp5Pd6vB6kGioIvSJ6z9roZFLjKJpLw,3115
12
+ solverr/resources/payment_links.py,sha256=tAyQxT3JCdsmo6gVYxrO2XszYMdf6WdAP3JvldymNkw,1472
13
+ solverr/resources/payouts.py,sha256=RgB06IK1Q35VIyoowBleZSB-Qm5_zaJSGk8lYLbFfD0,2155
14
+ solverr/resources/refunds.py,sha256=lvmh6rj0V8pWoFnvwxiMmqqXov8tK2BZh5dxBnf5Gtg,1717
15
+ solverr/resources/subscriptions.py,sha256=qVHt-D1D_OAgSOlIdChJ_oKFWaFr7SuRIQzS_aGNmbU,1585
16
+ solverr/resources/tokens.py,sha256=xIeuOhJBODrmwAxHgRPwSyUkr_HTL8pUQyg67t3FyqI,433
17
+ solverr/resources/webhooks.py,sha256=JFKum3UyXMcVEorEH6HXUxGKWEVChC_86U0VY1oMX2w,472
18
+ solverr-0.1.0.dist-info/METADATA,sha256=_PO2wm0S_2QkGB5o5vbLHw0Gul4ZK_3T2Zf-2yY9Avs,12481
19
+ solverr-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
20
+ solverr-0.1.0.dist-info/licenses/LICENSE,sha256=mD8ZhPyM7NV8ggWJBWGGu1PEo_stzEj1f884JJRp-O0,1064
21
+ solverr-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.