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/__init__.py +8 -0
- solverr/_client.py +48 -0
- solverr/_config.py +38 -0
- solverr/_errors.py +38 -0
- solverr/_http.py +192 -0
- solverr/py.typed +0 -0
- solverr/resources/__init__.py +10 -0
- solverr/resources/credits.py +45 -0
- solverr/resources/invoices.py +80 -0
- solverr/resources/payment_links.py +33 -0
- solverr/resources/payouts.py +51 -0
- solverr/resources/refunds.py +40 -0
- solverr/resources/subscriptions.py +40 -0
- solverr/resources/tokens.py +15 -0
- solverr/resources/webhooks.py +15 -0
- solverr/types.py +298 -0
- solverr/webhooks.py +109 -0
- solverr-0.1.0.dist-info/METADATA +235 -0
- solverr-0.1.0.dist-info/RECORD +21 -0
- solverr-0.1.0.dist-info/WHEEL +4 -0
- solverr-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|