usdpay 1.0.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.
- usdpay/__init__.py +13 -0
- usdpay/client.py +266 -0
- usdpay/exceptions.py +26 -0
- usdpay/py.typed +1 -0
- usdpay/transport.py +109 -0
- usdpay/webhook.py +40 -0
- usdpay-1.0.0.dist-info/METADATA +205 -0
- usdpay-1.0.0.dist-info/RECORD +10 -0
- usdpay-1.0.0.dist-info/WHEEL +4 -0
- usdpay-1.0.0.dist-info/licenses/LICENSE +21 -0
usdpay/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Official Python SDK for the USDPAY Payments API."""
|
|
2
|
+
|
|
3
|
+
from .client import UsdpayClient
|
|
4
|
+
from .exceptions import UsdpayApiError
|
|
5
|
+
from .webhook import verify_webhook_signature
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"UsdpayClient",
|
|
9
|
+
"UsdpayApiError",
|
|
10
|
+
"verify_webhook_signature",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
__version__ = "1.0.0"
|
usdpay/client.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Synchronous client for the public USDPAY Payments API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote, urlsplit
|
|
11
|
+
|
|
12
|
+
from .exceptions import UsdpayApiError
|
|
13
|
+
from .transport import HttpResponse, HttpTransport, Transport, TransportError, TransportTimeout
|
|
14
|
+
|
|
15
|
+
SDK_VERSION = "1.0.0"
|
|
16
|
+
DEFAULT_BASE_URL = "https://usdpay.me"
|
|
17
|
+
DEFAULT_TIMEOUT = 10.0
|
|
18
|
+
DEFAULT_CONNECT_TIMEOUT = 5.0
|
|
19
|
+
|
|
20
|
+
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,160}$")
|
|
21
|
+
_SENSITIVE_KEY = re.compile(r"authorization|api.?key|secret|token|password", re.IGNORECASE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UsdpayClient:
|
|
25
|
+
"""A typed, synchronous client for creating and inspecting invoices."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
secret_key: str,
|
|
30
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
31
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
32
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
33
|
+
*,
|
|
34
|
+
_transport: Transport | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
if not isinstance(secret_key, str) or not secret_key.strip():
|
|
37
|
+
raise ValueError("secret_key is required")
|
|
38
|
+
if "\r" in secret_key or "\n" in secret_key:
|
|
39
|
+
raise ValueError("secret_key must not contain line breaks")
|
|
40
|
+
|
|
41
|
+
self._secret_key = secret_key
|
|
42
|
+
self.base_url = _normalize_base_url(base_url)
|
|
43
|
+
self.timeout = _positive_timeout(timeout, "timeout")
|
|
44
|
+
self.connect_timeout = _positive_timeout(connect_timeout, "connect_timeout")
|
|
45
|
+
self._transport = _transport or HttpTransport()
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return (
|
|
49
|
+
f"UsdpayClient(base_url={self.base_url!r}, timeout={self.timeout!r}, "
|
|
50
|
+
f"connect_timeout={self.connect_timeout!r})"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def create_invoice(
|
|
54
|
+
self,
|
|
55
|
+
payload: Mapping[str, Any],
|
|
56
|
+
idempotency_key: str | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Create a payment invoice with an optional stable idempotency key."""
|
|
59
|
+
|
|
60
|
+
if not isinstance(payload, Mapping):
|
|
61
|
+
raise TypeError("payload must be a mapping")
|
|
62
|
+
amount = payload.get("amount")
|
|
63
|
+
if not isinstance(amount, str) or not amount.strip():
|
|
64
|
+
raise ValueError("amount must be a non-empty decimal string")
|
|
65
|
+
|
|
66
|
+
headers: dict[str, str] = {}
|
|
67
|
+
if idempotency_key is not None:
|
|
68
|
+
if not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch(
|
|
69
|
+
idempotency_key
|
|
70
|
+
):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"idempotency_key must contain 1-160 letters, digits, dots, "
|
|
73
|
+
"underscores, colons, or hyphens"
|
|
74
|
+
)
|
|
75
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
body = json.dumps(
|
|
79
|
+
dict(payload),
|
|
80
|
+
ensure_ascii=False,
|
|
81
|
+
separators=(",", ":"),
|
|
82
|
+
allow_nan=False,
|
|
83
|
+
).encode("utf-8")
|
|
84
|
+
except (TypeError, ValueError) as exc:
|
|
85
|
+
raise ValueError("payload must contain JSON-serializable values") from exc
|
|
86
|
+
|
|
87
|
+
return self._request(
|
|
88
|
+
"POST",
|
|
89
|
+
"/api/invoices",
|
|
90
|
+
body=body,
|
|
91
|
+
authenticated=True,
|
|
92
|
+
extra_headers=headers,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def get_invoice(self, invoice_id: str) -> dict[str, Any]:
|
|
96
|
+
"""Retrieve public checkout status without sending the secret key."""
|
|
97
|
+
|
|
98
|
+
if not isinstance(invoice_id, str) or not invoice_id.strip():
|
|
99
|
+
raise ValueError("invoice_id is required")
|
|
100
|
+
return self._request(
|
|
101
|
+
"GET",
|
|
102
|
+
"/api/invoices/" + quote(invoice_id.strip(), safe=""),
|
|
103
|
+
authenticated=False,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def list_invoices(self) -> dict[str, Any]:
|
|
107
|
+
"""List invoices that belong to the store selected by the secret key."""
|
|
108
|
+
|
|
109
|
+
return self._request("GET", "/api/invoices", authenticated=True)
|
|
110
|
+
|
|
111
|
+
def _request(
|
|
112
|
+
self,
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
body: bytes | None = None,
|
|
117
|
+
authenticated: bool,
|
|
118
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
headers = {
|
|
121
|
+
"Accept": "application/json",
|
|
122
|
+
"User-Agent": f"usdpay-python/{SDK_VERSION}",
|
|
123
|
+
}
|
|
124
|
+
if authenticated:
|
|
125
|
+
headers["Authorization"] = "Bearer " + self._secret_key
|
|
126
|
+
if body is not None:
|
|
127
|
+
headers["Content-Type"] = "application/json"
|
|
128
|
+
if extra_headers:
|
|
129
|
+
headers.update(extra_headers)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
response = self._transport.send(
|
|
133
|
+
method,
|
|
134
|
+
self.base_url + path,
|
|
135
|
+
headers,
|
|
136
|
+
body,
|
|
137
|
+
self.timeout,
|
|
138
|
+
self.connect_timeout,
|
|
139
|
+
)
|
|
140
|
+
except TransportTimeout:
|
|
141
|
+
raise UsdpayApiError(
|
|
142
|
+
"USDPAY request timed out",
|
|
143
|
+
code="request_timeout",
|
|
144
|
+
) from None
|
|
145
|
+
except TransportError:
|
|
146
|
+
raise UsdpayApiError(
|
|
147
|
+
"Could not reach USDPAY",
|
|
148
|
+
code="network_error",
|
|
149
|
+
) from None
|
|
150
|
+
except Exception:
|
|
151
|
+
# Custom transports are permitted for tests, but their internals and
|
|
152
|
+
# request headers must never leak through a public exception.
|
|
153
|
+
raise UsdpayApiError(
|
|
154
|
+
"Could not reach USDPAY",
|
|
155
|
+
code="network_error",
|
|
156
|
+
) from None
|
|
157
|
+
|
|
158
|
+
if not 200 <= response.status < 300:
|
|
159
|
+
raise self._http_error(response)
|
|
160
|
+
return self._decode_success(response)
|
|
161
|
+
|
|
162
|
+
def _decode_success(self, response: HttpResponse) -> dict[str, Any]:
|
|
163
|
+
if not response.body:
|
|
164
|
+
return {}
|
|
165
|
+
decoded = _decode_json_object(response.body)
|
|
166
|
+
if decoded is None:
|
|
167
|
+
raise UsdpayApiError(
|
|
168
|
+
"USDPAY returned invalid JSON",
|
|
169
|
+
status=response.status,
|
|
170
|
+
code="invalid_response",
|
|
171
|
+
request_id=_clean_header(response.header("X-Request-ID"), self._secret_key),
|
|
172
|
+
)
|
|
173
|
+
return decoded
|
|
174
|
+
|
|
175
|
+
def _http_error(self, response: HttpResponse) -> UsdpayApiError:
|
|
176
|
+
decoded = _decode_json_object(response.body) if response.body else None
|
|
177
|
+
redacted = _redact(decoded, self._secret_key) if decoded is not None else None
|
|
178
|
+
|
|
179
|
+
code = "request_failed"
|
|
180
|
+
if decoded is not None and isinstance(decoded.get("error"), str):
|
|
181
|
+
candidate = decoded["error"].strip()
|
|
182
|
+
if candidate and self._secret_key not in candidate:
|
|
183
|
+
code = candidate
|
|
184
|
+
|
|
185
|
+
request_id = _clean_header(response.header("X-Request-ID"), self._secret_key)
|
|
186
|
+
if decoded is not None and isinstance(decoded.get("requestId"), str):
|
|
187
|
+
candidate_request_id = _clean_header(decoded["requestId"], self._secret_key)
|
|
188
|
+
if candidate_request_id:
|
|
189
|
+
request_id = candidate_request_id
|
|
190
|
+
|
|
191
|
+
retry_after = _retry_after(response.header("Retry-After"), self._secret_key)
|
|
192
|
+
return UsdpayApiError(
|
|
193
|
+
f"USDPAY request failed with HTTP {response.status} ({code})",
|
|
194
|
+
status=response.status,
|
|
195
|
+
code=code,
|
|
196
|
+
details=redacted,
|
|
197
|
+
retry_after=retry_after,
|
|
198
|
+
request_id=request_id,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
203
|
+
if not isinstance(base_url, str):
|
|
204
|
+
raise TypeError("base_url must be a string")
|
|
205
|
+
normalized = base_url.strip().rstrip("/")
|
|
206
|
+
target = urlsplit(normalized)
|
|
207
|
+
if (
|
|
208
|
+
not normalized
|
|
209
|
+
or target.scheme != "https"
|
|
210
|
+
or not target.hostname
|
|
211
|
+
or target.username is not None
|
|
212
|
+
or target.password is not None
|
|
213
|
+
or target.query
|
|
214
|
+
or target.fragment
|
|
215
|
+
or target.path not in ("", "/")
|
|
216
|
+
):
|
|
217
|
+
raise ValueError("base_url must be an HTTPS origin")
|
|
218
|
+
return normalized
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _positive_timeout(value: float, name: str) -> float:
|
|
222
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
223
|
+
raise TypeError(f"{name} must be a number")
|
|
224
|
+
number = float(value)
|
|
225
|
+
if not math.isfinite(number) or number <= 0:
|
|
226
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
227
|
+
return number
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _decode_json_object(body: bytes) -> dict[str, Any] | None:
|
|
231
|
+
try:
|
|
232
|
+
decoded = json.loads(body.decode("utf-8"))
|
|
233
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
234
|
+
return None
|
|
235
|
+
return decoded if isinstance(decoded, dict) else None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _clean_header(value: str | None, secret: str) -> str | None:
|
|
239
|
+
if value is None:
|
|
240
|
+
return None
|
|
241
|
+
cleaned = value.strip()
|
|
242
|
+
if not cleaned or secret in cleaned:
|
|
243
|
+
return None
|
|
244
|
+
return cleaned
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _retry_after(value: str | None, secret: str) -> int | str | None:
|
|
248
|
+
cleaned = _clean_header(value, secret)
|
|
249
|
+
if cleaned is None:
|
|
250
|
+
return None
|
|
251
|
+
return int(cleaned) if cleaned.isdigit() else cleaned
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _redact(value: Any, secret: str, key: str | None = None) -> Any:
|
|
255
|
+
if key is not None and _SENSITIVE_KEY.search(key):
|
|
256
|
+
return "[REDACTED]"
|
|
257
|
+
if isinstance(value, str):
|
|
258
|
+
return value.replace(secret, "[REDACTED]")
|
|
259
|
+
if isinstance(value, list):
|
|
260
|
+
return [_redact(item, secret) for item in value]
|
|
261
|
+
if isinstance(value, dict):
|
|
262
|
+
return {
|
|
263
|
+
item_key: _redact(item_value, secret, str(item_key))
|
|
264
|
+
for item_key, item_value in value.items()
|
|
265
|
+
}
|
|
266
|
+
return value
|
usdpay/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exceptions raised by the USDPAY SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UsdpayApiError(RuntimeError):
|
|
9
|
+
"""A structured HTTP, transport, or response error from the USDPAY API."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status: int = 0,
|
|
16
|
+
code: str = "request_failed",
|
|
17
|
+
details: Any = None,
|
|
18
|
+
retry_after: int | str | None = None,
|
|
19
|
+
request_id: str | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.details = details
|
|
25
|
+
self.retry_after = retry_after
|
|
26
|
+
self.request_id = request_id
|
usdpay/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
usdpay/transport.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Small HTTPS transport used by the synchronous USDPAY client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import ssl
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
from urllib.parse import SplitResult, urlsplit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class HttpResponse:
|
|
15
|
+
"""Transport response consumed by :class:`usdpay.UsdpayClient`."""
|
|
16
|
+
|
|
17
|
+
status: int
|
|
18
|
+
body: bytes
|
|
19
|
+
headers: Mapping[str, str]
|
|
20
|
+
|
|
21
|
+
def header(self, name: str) -> str | None:
|
|
22
|
+
return self.headers.get(name.lower())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Transport(Protocol):
|
|
26
|
+
"""Internal transport interface used to keep HTTP behavior testable."""
|
|
27
|
+
|
|
28
|
+
def send(
|
|
29
|
+
self,
|
|
30
|
+
method: str,
|
|
31
|
+
url: str,
|
|
32
|
+
headers: Mapping[str, str],
|
|
33
|
+
body: bytes | None,
|
|
34
|
+
timeout: float,
|
|
35
|
+
connect_timeout: float,
|
|
36
|
+
) -> HttpResponse: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TransportError(Exception):
|
|
40
|
+
"""The remote service could not be reached."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TransportTimeout(TransportError):
|
|
44
|
+
"""The connection or response exceeded its configured timeout."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HttpTransport:
|
|
48
|
+
"""Zero-dependency HTTPS transport with certificate verification enabled."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, *, ssl_context: ssl.SSLContext | None = None) -> None:
|
|
51
|
+
self._ssl_context = ssl_context or ssl.create_default_context()
|
|
52
|
+
|
|
53
|
+
def send(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
url: str,
|
|
57
|
+
headers: Mapping[str, str],
|
|
58
|
+
body: bytes | None,
|
|
59
|
+
timeout: float,
|
|
60
|
+
connect_timeout: float,
|
|
61
|
+
) -> HttpResponse:
|
|
62
|
+
target = _validated_https_url(url)
|
|
63
|
+
host = target.hostname
|
|
64
|
+
if host is None: # Kept explicit for static type checkers.
|
|
65
|
+
raise TransportError("HTTPS URL has no hostname")
|
|
66
|
+
|
|
67
|
+
connection = http.client.HTTPSConnection(
|
|
68
|
+
host,
|
|
69
|
+
target.port or 443,
|
|
70
|
+
timeout=connect_timeout,
|
|
71
|
+
context=self._ssl_context,
|
|
72
|
+
)
|
|
73
|
+
request_target = target.path or "/"
|
|
74
|
+
if target.query:
|
|
75
|
+
request_target += "?" + target.query
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
connection.request(method, request_target, body=body, headers=dict(headers))
|
|
79
|
+
if connection.sock is not None:
|
|
80
|
+
connection.sock.settimeout(timeout)
|
|
81
|
+
response = connection.getresponse()
|
|
82
|
+
response_body = response.read()
|
|
83
|
+
response_headers: dict[str, str] = {}
|
|
84
|
+
for name, value in response.getheaders():
|
|
85
|
+
normalized = name.lower()
|
|
86
|
+
if normalized in response_headers:
|
|
87
|
+
response_headers[normalized] += ", " + value
|
|
88
|
+
else:
|
|
89
|
+
response_headers[normalized] = value
|
|
90
|
+
return HttpResponse(response.status, response_body, response_headers)
|
|
91
|
+
except TimeoutError as exc:
|
|
92
|
+
raise TransportTimeout("HTTPS request timed out") from exc
|
|
93
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
94
|
+
raise TransportError("HTTPS request failed") from exc
|
|
95
|
+
finally:
|
|
96
|
+
connection.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validated_https_url(url: str) -> SplitResult:
|
|
100
|
+
target = urlsplit(url)
|
|
101
|
+
if (
|
|
102
|
+
target.scheme != "https"
|
|
103
|
+
or not target.hostname
|
|
104
|
+
or target.username is not None
|
|
105
|
+
or target.password is not None
|
|
106
|
+
or target.fragment
|
|
107
|
+
):
|
|
108
|
+
raise TransportError("transport requires an HTTPS URL without credentials or fragment")
|
|
109
|
+
return target
|
usdpay/webhook.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Helpers for authenticating USDPAY webhook deliveries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def verify_webhook_signature(
|
|
10
|
+
raw_body: bytes | bytearray | memoryview | str,
|
|
11
|
+
signature: str,
|
|
12
|
+
secret: bytes | str,
|
|
13
|
+
) -> bool:
|
|
14
|
+
"""Return whether ``signature`` is the HMAC-SHA256 of the unparsed body.
|
|
15
|
+
|
|
16
|
+
USDPAY sends signatures as ``sha256=<lowercase hex digest>``. Call this
|
|
17
|
+
function before decoding JSON, and pass the exact bytes received over HTTP.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
if isinstance(raw_body, str):
|
|
21
|
+
body = raw_body.encode("utf-8")
|
|
22
|
+
elif isinstance(raw_body, (bytes, bytearray, memoryview)):
|
|
23
|
+
body = bytes(raw_body)
|
|
24
|
+
else:
|
|
25
|
+
raise TypeError("raw_body must be bytes-like or str")
|
|
26
|
+
|
|
27
|
+
if isinstance(secret, str):
|
|
28
|
+
secret_bytes = secret.encode("utf-8")
|
|
29
|
+
elif isinstance(secret, bytes):
|
|
30
|
+
secret_bytes = secret
|
|
31
|
+
else:
|
|
32
|
+
raise TypeError("secret must be bytes or str")
|
|
33
|
+
if not secret_bytes:
|
|
34
|
+
raise ValueError("webhook secret is required")
|
|
35
|
+
|
|
36
|
+
received = signature.strip() if isinstance(signature, str) else ""
|
|
37
|
+
expected = "sha256=" + hmac.new(secret_bytes, body, hashlib.sha256).hexdigest()
|
|
38
|
+
if len(received) != len(expected):
|
|
39
|
+
return False
|
|
40
|
+
return hmac.compare_digest(received, expected)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: usdpay
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for accepting USDT payments directly to your wallet with automatic payment verification and webhooks.
|
|
5
|
+
Project-URL: Homepage, https://usdpay.me/
|
|
6
|
+
Project-URL: Documentation, https://usdpay.me/docs/payments
|
|
7
|
+
Project-URL: Webhooks, https://usdpay.me/webhooks
|
|
8
|
+
Project-URL: Security, https://usdpay.me/security
|
|
9
|
+
Project-URL: Support, https://usdpay.me/contact
|
|
10
|
+
Project-URL: Source, https://github.com/probizi/usdpay-python
|
|
11
|
+
Project-URL: Issues, https://github.com/probizi/usdpay-python/issues
|
|
12
|
+
Author: USDPAY
|
|
13
|
+
License-Expression: MIT
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Keywords: bep20,bsc,crypto-payments,payment-api,payment-gateway,payments,ton,trc20,tron,usdpay,usdt,usdt-api,webhook
|
|
16
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build<2,>=1.2; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest<10,>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff<1,>=0.9; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine<7,>=6; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# USDPAY Python SDK
|
|
35
|
+
|
|
36
|
+
Official Python SDK for USDPAY.
|
|
37
|
+
|
|
38
|
+
Accept USDT directly to your wallet. USDPAY verifies the payment on-chain and notifies your application automatically with signed webhooks.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.10 or newer
|
|
43
|
+
- A USDPAY store secret key for authenticated methods
|
|
44
|
+
- A server-side application; never expose the secret key in browser or mobile code
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install usdpay
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import os
|
|
56
|
+
|
|
57
|
+
from usdpay import UsdpayClient
|
|
58
|
+
|
|
59
|
+
client = UsdpayClient(
|
|
60
|
+
secret_key=os.environ["USDPAY_SECRET"]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
result = client.create_invoice(
|
|
64
|
+
{
|
|
65
|
+
"amount": "49.00",
|
|
66
|
+
"orderId": "ORDER-1042",
|
|
67
|
+
"network": "TRC20",
|
|
68
|
+
"callbackUrl": "https://merchant.example/usdpay/webhook",
|
|
69
|
+
"returnUrl": "https://merchant.example/orders/1042",
|
|
70
|
+
},
|
|
71
|
+
idempotency_key="ORDER-1042-create",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
print(result["invoice"]["checkoutUrl"])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Create an Invoice
|
|
78
|
+
|
|
79
|
+
`create_invoice()` sends `POST /api/invoices`. JSON field names match the REST API exactly.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
result = client.create_invoice(
|
|
83
|
+
{
|
|
84
|
+
"amount": "49.00",
|
|
85
|
+
"orderId": "ORDER-1042",
|
|
86
|
+
"network": "TRC20",
|
|
87
|
+
"expiresInMinutes": 30,
|
|
88
|
+
"callbackUrl": "https://merchant.example/usdpay/webhook",
|
|
89
|
+
"returnUrl": "https://merchant.example/orders/1042",
|
|
90
|
+
},
|
|
91
|
+
idempotency_key="ORDER-1042-create",
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Omit `network` to let the customer choose an enabled network in the hosted checkout.
|
|
96
|
+
|
|
97
|
+
## Get an Invoice
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
result = client.get_invoice("inv_7Fq2xK9")
|
|
101
|
+
print(result["invoice"]["status"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The production API exposes this status endpoint to anyone who has the unguessable invoice ID. The SDK therefore does not send your Bearer key with `get_invoice()`.
|
|
105
|
+
|
|
106
|
+
## List Invoices
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
result = client.list_invoices()
|
|
110
|
+
for invoice in result["invoices"]:
|
|
111
|
+
print(invoice["id"], invoice["status"])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`list_invoices()` is authenticated and returns invoices for the store selected by the secret key. The current API does not define filtering or pagination parameters, so the SDK does not invent any.
|
|
115
|
+
|
|
116
|
+
## Fiat Order Amounts
|
|
117
|
+
|
|
118
|
+
Keep monetary values as decimal strings. USDPAY performs the currency conversion; the SDK does not use floating-point math or calculate FX rates.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
result = client.create_invoice(
|
|
122
|
+
{
|
|
123
|
+
"amount": "49.00",
|
|
124
|
+
"currency": "EUR",
|
|
125
|
+
"orderId": "ORDER-1042",
|
|
126
|
+
"callbackUrl": "https://merchant.example/usdpay/webhook",
|
|
127
|
+
},
|
|
128
|
+
idempotency_key="ORDER-1042-create",
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Idempotency
|
|
133
|
+
|
|
134
|
+
Pass one stable `idempotency_key` for a logical create operation. If a timeout, `429`, or retryable `5xx` occurs, retry with the same key. Do not generate a new key for each attempt.
|
|
135
|
+
|
|
136
|
+
USDPAY accepts 1–160 letters, digits, dots, underscores, colons, or hyphens. The SDK validates the key but does not automatically retry requests.
|
|
137
|
+
|
|
138
|
+
## Verify Webhooks
|
|
139
|
+
|
|
140
|
+
Verify the signature against the exact raw request body before parsing JSON.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import os
|
|
144
|
+
|
|
145
|
+
from usdpay import verify_webhook_signature
|
|
146
|
+
|
|
147
|
+
raw_body = request_body_bytes
|
|
148
|
+
signature = request_headers.get("X-USDPAY-Signature", "")
|
|
149
|
+
|
|
150
|
+
if not verify_webhook_signature(
|
|
151
|
+
raw_body,
|
|
152
|
+
signature,
|
|
153
|
+
os.environ["USDPAY_WEBHOOK_SECRET"],
|
|
154
|
+
):
|
|
155
|
+
# Return HTTP 401.
|
|
156
|
+
...
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The signature format is `sha256=<hex HMAC-SHA256>`. Store `X-USDPAY-Idempotency-Key` under a unique database constraint before fulfilling an order, and acknowledge an already processed delivery with a `2xx` response.
|
|
160
|
+
|
|
161
|
+
## Error Handling
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from usdpay import UsdpayApiError
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
client.create_invoice(
|
|
168
|
+
{"amount": "49.00", "orderId": "ORDER-1042"},
|
|
169
|
+
idempotency_key="ORDER-1042-create",
|
|
170
|
+
)
|
|
171
|
+
except UsdpayApiError as exc:
|
|
172
|
+
print(exc.status)
|
|
173
|
+
print(exc.code)
|
|
174
|
+
print(exc.retry_after)
|
|
175
|
+
print(exc.request_id)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`UsdpayApiError` covers HTTP failures, timeouts, network failures, and malformed JSON. Its public attributes are:
|
|
179
|
+
|
|
180
|
+
- `status`: HTTP status, or `0` when no HTTP response was received
|
|
181
|
+
- `code`: stable API or SDK error code
|
|
182
|
+
- `details`: redacted response object when available
|
|
183
|
+
- `retry_after`: parsed `Retry-After` seconds or HTTP-date
|
|
184
|
+
- `request_id`: response request identifier for support
|
|
185
|
+
|
|
186
|
+
## Security
|
|
187
|
+
|
|
188
|
+
- Keep `USDPAY_SECRET` and the webhook signing secret on your server.
|
|
189
|
+
- The default transport accepts HTTPS only and uses Python's verified system trust store with hostname verification.
|
|
190
|
+
- Requests have finite connect and response timeouts; configure them with `connect_timeout` and `timeout`.
|
|
191
|
+
- The client does not follow redirects or make network calls when imported.
|
|
192
|
+
- Secrets are not included in `repr(client)`, public exception messages, or exception details.
|
|
193
|
+
- Never disable TLS verification.
|
|
194
|
+
|
|
195
|
+
## Documentation
|
|
196
|
+
|
|
197
|
+
- [Official website](https://usdpay.me/)
|
|
198
|
+
- [Payment API documentation](https://usdpay.me/docs/payments)
|
|
199
|
+
- [Webhook documentation](https://usdpay.me/webhooks)
|
|
200
|
+
- [Security](https://usdpay.me/security)
|
|
201
|
+
- [Support](https://usdpay.me/contact)
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
[MIT](LICENSE) © 2026 PIXELTIDE LLC.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
usdpay/__init__.py,sha256=7L0w5VZTuHH0yhyWPGBQj95I12TIicVRpDWBunMijow,286
|
|
2
|
+
usdpay/client.py,sha256=cllUU3RMtL3Clo2fc_lJpNJXLKq1t_LS84NdHikFbgk,9341
|
|
3
|
+
usdpay/exceptions.py,sha256=NAEt-P9O2rR7WKijxc75_dht7AbrwaznWMJpXd683ps,673
|
|
4
|
+
usdpay/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
usdpay/transport.py,sha256=vuEXnsKtEifD9BLTTGYOTQUqqs6JYD2fndwQeL3AYlE,3421
|
|
6
|
+
usdpay/webhook.py,sha256=GGbBZZT2Li8uxCxuEGIIHInNzThZvPfbK8TMbrvB0jc,1304
|
|
7
|
+
usdpay-1.0.0.dist-info/METADATA,sha256=21n-1vui61OeGivhkq2beozEi2PbTcxTYAXV49gA5Uw,6485
|
|
8
|
+
usdpay-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
usdpay-1.0.0.dist-info/licenses/LICENSE,sha256=kTY14J2FROteaZgWL09PFrUFdbYohOnJVAI3YVe5EDQ,1070
|
|
10
|
+
usdpay-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PIXELTIDE LLC
|
|
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.
|