pawpayments 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: write
12
+ id-token: write # required for PyPI Trusted Publishing (no token needed)
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: '3.11'
19
+
20
+ - run: pip install --no-cache-dir build
21
+ - run: python -m build
22
+
23
+ - uses: pypa/gh-action-pypi-publish@release/v1
24
+
25
+ - run: gh release create "$GITHUB_REF_NAME" --generate-notes
26
+ env:
27
+ GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) PawPayments
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.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: pawpayments
3
+ Version: 2.0.0
4
+ Summary: Official Python SDK for the PawPayments Native V2 API
5
+ Project-URL: Homepage, https://pawpayments.com
6
+ Project-URL: Documentation, https://docs.pawpayments.com
7
+ Project-URL: Source, https://github.com/pawpayments/python-sdk
8
+ Author-email: PawPayments <support@pawpayments.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Office/Business :: Financial
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx>=0.24
22
+ Requires-Dist: requests>=2.28
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
25
+ Requires-Dist: pytest>=7; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # PawPayments — Python SDK
29
+
30
+ Official Python SDK for the [PawPayments](https://pawpayments.com) Native V2 API.
31
+ Ships both a synchronous client (`PawPayments`, built on `requests`) and an
32
+ asynchronous one (`AsyncPawPayments`, built on `httpx`).
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install pawpayments
38
+ ```
39
+
40
+ Requires Python **3.9+**.
41
+
42
+ ## Quickstart (sync)
43
+
44
+ ```python
45
+ from pawpayments import PawPayments
46
+
47
+ paw = PawPayments(api_key="…")
48
+
49
+ invoice = paw.invoices.create(
50
+ amount=25,
51
+ fiat_currency="USD",
52
+ billing_type="STATIC",
53
+ asset="usdt_tron",
54
+ description="Pro plan, 1 month",
55
+ notify_url="https://example.com/paw/webhook",
56
+ )
57
+ print(invoice["payment_url"])
58
+ ```
59
+
60
+ ## Quickstart (async)
61
+
62
+ ```python
63
+ import asyncio
64
+ from pawpayments import AsyncPawPayments
65
+
66
+
67
+ async def main():
68
+ async with AsyncPawPayments(api_key="…") as paw:
69
+ invoice = await paw.invoices.create(
70
+ amount=25,
71
+ fiat_currency="USD",
72
+ billing_type="STATIC",
73
+ asset="usdt_tron",
74
+ )
75
+ print(invoice["payment_url"])
76
+
77
+
78
+ asyncio.run(main())
79
+ ```
80
+
81
+ ## Resources
82
+
83
+ | Group | Methods |
84
+ |-------|---------|
85
+ | `paw.assets` | `list()` |
86
+ | `paw.rates` | `get(base=, assets=)` |
87
+ | `paw.balance` | `get()` |
88
+ | `paw.invoices` | `create(**)`, `get(id)`, `list(**)`, `notify(id)` |
89
+ | `paw.payouts` | `create(**, uniq_id=)`, `get(id)`, `list(**)`, `batch(items, uniq_id=)` |
90
+ | `paw.ledger` | `list(type=, ...)` |
91
+ | `paw.notifications` | `list(**)`, `test(url=)` |
92
+ | `paw.permanent` | `create(**)`, `get(id)`, `list(**)`, `deactivate(id)` |
93
+
94
+ `payouts.create` and `payouts.batch` accept an optional `uniq_id` for explicit
95
+ idempotency (UUIDv4). When omitted the SDK generates one with `uuid.uuid4()`.
96
+ A repeated `uniq_id` within 2 hours yields a 409.
97
+
98
+ ## Webhook verification
99
+
100
+ ```python
101
+ from flask import Flask, request, abort
102
+ from pawpayments import Webhook
103
+
104
+ app = Flask(__name__)
105
+
106
+
107
+ @app.post("/paw/webhook")
108
+ def webhook():
109
+ raw = request.get_data()
110
+ sig = request.headers.get("X-Paw-Signature", "")
111
+ if not Webhook.verify_raw_body(raw, sig, API_KEY):
112
+ abort(401)
113
+ payload = Webhook.parse_payload(raw)
114
+ # …handle the invoice update…
115
+ return "", 200
116
+ ```
117
+
118
+ ## Errors
119
+
120
+ ```python
121
+ from pawpayments import PawPaymentsApiError
122
+
123
+ try:
124
+ paw.invoices.create(...)
125
+ except PawPaymentsApiError as exc:
126
+ print(exc.code, exc.http_status, str(exc), exc.details)
127
+ ```
128
+
129
+ ## Testing
130
+
131
+ - `pytest tests/test_webhook.py` — signature unit tests + PHP-interop parity.
132
+ - `PAW_API_KEY=… pytest tests/test_live.py tests/test_negative.py` — live happy-path + negative-case suites against `https://api.pawpayments.com`. Tests skip when `PAW_API_KEY` is absent.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,109 @@
1
+ # PawPayments — Python SDK
2
+
3
+ Official Python SDK for the [PawPayments](https://pawpayments.com) Native V2 API.
4
+ Ships both a synchronous client (`PawPayments`, built on `requests`) and an
5
+ asynchronous one (`AsyncPawPayments`, built on `httpx`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install pawpayments
11
+ ```
12
+
13
+ Requires Python **3.9+**.
14
+
15
+ ## Quickstart (sync)
16
+
17
+ ```python
18
+ from pawpayments import PawPayments
19
+
20
+ paw = PawPayments(api_key="…")
21
+
22
+ invoice = paw.invoices.create(
23
+ amount=25,
24
+ fiat_currency="USD",
25
+ billing_type="STATIC",
26
+ asset="usdt_tron",
27
+ description="Pro plan, 1 month",
28
+ notify_url="https://example.com/paw/webhook",
29
+ )
30
+ print(invoice["payment_url"])
31
+ ```
32
+
33
+ ## Quickstart (async)
34
+
35
+ ```python
36
+ import asyncio
37
+ from pawpayments import AsyncPawPayments
38
+
39
+
40
+ async def main():
41
+ async with AsyncPawPayments(api_key="…") as paw:
42
+ invoice = await paw.invoices.create(
43
+ amount=25,
44
+ fiat_currency="USD",
45
+ billing_type="STATIC",
46
+ asset="usdt_tron",
47
+ )
48
+ print(invoice["payment_url"])
49
+
50
+
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ ## Resources
55
+
56
+ | Group | Methods |
57
+ |-------|---------|
58
+ | `paw.assets` | `list()` |
59
+ | `paw.rates` | `get(base=, assets=)` |
60
+ | `paw.balance` | `get()` |
61
+ | `paw.invoices` | `create(**)`, `get(id)`, `list(**)`, `notify(id)` |
62
+ | `paw.payouts` | `create(**, uniq_id=)`, `get(id)`, `list(**)`, `batch(items, uniq_id=)` |
63
+ | `paw.ledger` | `list(type=, ...)` |
64
+ | `paw.notifications` | `list(**)`, `test(url=)` |
65
+ | `paw.permanent` | `create(**)`, `get(id)`, `list(**)`, `deactivate(id)` |
66
+
67
+ `payouts.create` and `payouts.batch` accept an optional `uniq_id` for explicit
68
+ idempotency (UUIDv4). When omitted the SDK generates one with `uuid.uuid4()`.
69
+ A repeated `uniq_id` within 2 hours yields a 409.
70
+
71
+ ## Webhook verification
72
+
73
+ ```python
74
+ from flask import Flask, request, abort
75
+ from pawpayments import Webhook
76
+
77
+ app = Flask(__name__)
78
+
79
+
80
+ @app.post("/paw/webhook")
81
+ def webhook():
82
+ raw = request.get_data()
83
+ sig = request.headers.get("X-Paw-Signature", "")
84
+ if not Webhook.verify_raw_body(raw, sig, API_KEY):
85
+ abort(401)
86
+ payload = Webhook.parse_payload(raw)
87
+ # …handle the invoice update…
88
+ return "", 200
89
+ ```
90
+
91
+ ## Errors
92
+
93
+ ```python
94
+ from pawpayments import PawPaymentsApiError
95
+
96
+ try:
97
+ paw.invoices.create(...)
98
+ except PawPaymentsApiError as exc:
99
+ print(exc.code, exc.http_status, str(exc), exc.details)
100
+ ```
101
+
102
+ ## Testing
103
+
104
+ - `pytest tests/test_webhook.py` — signature unit tests + PHP-interop parity.
105
+ - `PAW_API_KEY=… pytest tests/test_live.py tests/test_negative.py` — live happy-path + negative-case suites against `https://api.pawpayments.com`. Tests skip when `PAW_API_KEY` is absent.
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pawpayments"
7
+ version = "2.0.0"
8
+ description = "Official Python SDK for the PawPayments Native V2 API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "PawPayments", email = "support@pawpayments.com" }]
12
+ requires-python = ">=3.9"
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Office/Business :: Financial",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "requests>=2.28",
26
+ "httpx>=0.24",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=7", "pytest-asyncio>=0.21"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://pawpayments.com"
34
+ Documentation = "https://docs.pawpayments.com"
35
+ Source = "https://github.com/pawpayments/python-sdk"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/pawpayments"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ asyncio_mode = "auto"
@@ -0,0 +1,17 @@
1
+ """Official PawPayments Python SDK."""
2
+ from ._async_client import AsyncPawPayments
3
+ from ._client import PawPayments
4
+ from ._models import ListResult, Pagination
5
+ from .errors import PawPaymentsApiError
6
+ from .webhook import Webhook
7
+
8
+ __all__ = [
9
+ "PawPayments",
10
+ "AsyncPawPayments",
11
+ "PawPaymentsApiError",
12
+ "Webhook",
13
+ "ListResult",
14
+ "Pagination",
15
+ ]
16
+
17
+ __version__ = "0.1.0"
@@ -0,0 +1,152 @@
1
+ """Asynchronous client built on `httpx.AsyncClient`."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Iterable, Mapping, Optional
5
+
6
+ import httpx
7
+
8
+ from . import _resources as R
9
+ from ._http import base_headers, decode_envelope, unwrap_list, unwrap_result
10
+ from ._models import ListResult
11
+ from .errors import PawPaymentsApiError
12
+
13
+
14
+ class _AsyncResource:
15
+ def __init__(self, client: "AsyncPawPayments") -> None:
16
+ self._client = client
17
+
18
+
19
+ class _AsyncAssets(_AsyncResource):
20
+ async def list(self) -> list[dict[str, Any]]:
21
+ return await self._client._dispatch(R.assets_list())
22
+
23
+
24
+ class _AsyncRates(_AsyncResource):
25
+ async def get(self, *, base: str = "USD", assets: Optional[Iterable[str] | str] = None) -> dict[str, Any]:
26
+ return await self._client._dispatch(R.rates_get(base=base, assets=assets))
27
+
28
+
29
+ class _AsyncBalance(_AsyncResource):
30
+ async def get(self) -> dict[str, Any]:
31
+ return await self._client._dispatch(R.balance_get())
32
+
33
+
34
+ class _AsyncInvoices(_AsyncResource):
35
+ async def create(self, **params: Any) -> dict[str, Any]:
36
+ return await self._client._dispatch(R.invoices_create(params))
37
+
38
+ async def get(self, order_id: str) -> dict[str, Any]:
39
+ return await self._client._dispatch(R.invoices_get(order_id))
40
+
41
+ async def list(self, **params: Any) -> ListResult:
42
+ return await self._client._dispatch_list(R.invoices_list(**params))
43
+
44
+ async def notify(self, order_id: str) -> dict[str, Any]:
45
+ return await self._client._dispatch(R.invoices_notify(order_id))
46
+
47
+
48
+ class _AsyncPayouts(_AsyncResource):
49
+ async def create(self, *, uniq_id: Optional[str] = None, **params: Any) -> dict[str, Any]:
50
+ return await self._client._dispatch(R.payouts_create(params, uniq_id=uniq_id))
51
+
52
+ async def batch(self, items: list[Mapping[str, Any]], *, uniq_id: Optional[str] = None) -> dict[str, Any]:
53
+ return await self._client._dispatch(R.payouts_batch(items, uniq_id=uniq_id))
54
+
55
+ async def get(self, payout_id: str) -> dict[str, Any]:
56
+ return await self._client._dispatch(R.payouts_get(payout_id))
57
+
58
+ async def list(self, **params: Any) -> ListResult:
59
+ return await self._client._dispatch_list(R.payouts_list(**params))
60
+
61
+
62
+ class _AsyncLedger(_AsyncResource):
63
+ async def list(self, **params: Any) -> ListResult:
64
+ return await self._client._dispatch_list(R.ledger_list(**params))
65
+
66
+
67
+ class _AsyncNotifications(_AsyncResource):
68
+ async def list(self, **params: Any) -> ListResult:
69
+ return await self._client._dispatch_list(R.notifications_list(**params))
70
+
71
+ async def test(self, url: Optional[str] = None) -> dict[str, Any]:
72
+ return await self._client._dispatch(R.notifications_test(url))
73
+
74
+
75
+ class _AsyncPermanent(_AsyncResource):
76
+ async def create(self, **params: Any) -> dict[str, Any]:
77
+ return await self._client._dispatch(R.permanent_create(params))
78
+
79
+ async def get(self, address_id: str) -> dict[str, Any]:
80
+ return await self._client._dispatch(R.permanent_get(address_id))
81
+
82
+ async def list(self, **params: Any) -> ListResult:
83
+ return await self._client._dispatch_list(R.permanent_list(**params))
84
+
85
+ async def deactivate(self, address_id: str) -> dict[str, Any]:
86
+ return await self._client._dispatch(R.permanent_deactivate(address_id))
87
+
88
+
89
+ class AsyncPawPayments:
90
+ """Asynchronous PawPayments client (built on `httpx.AsyncClient`)."""
91
+
92
+ def __init__(
93
+ self,
94
+ api_key: str,
95
+ base_url: str = "https://api.pawpayments.com",
96
+ timeout: float = 30.0,
97
+ client: Optional[httpx.AsyncClient] = None,
98
+ ) -> None:
99
+ if not api_key:
100
+ raise ValueError("api_key is required")
101
+ self._api_key = api_key
102
+ self._base_url = base_url.rstrip("/")
103
+ self._timeout = timeout
104
+ self._client = client or httpx.AsyncClient(timeout=timeout)
105
+ self._owns_client = client is None
106
+
107
+ self.assets = _AsyncAssets(self)
108
+ self.rates = _AsyncRates(self)
109
+ self.balance = _AsyncBalance(self)
110
+ self.invoices = _AsyncInvoices(self)
111
+ self.payouts = _AsyncPayouts(self)
112
+ self.ledger = _AsyncLedger(self)
113
+ self.notifications = _AsyncNotifications(self)
114
+ self.permanent = _AsyncPermanent(self)
115
+
116
+ @property
117
+ def base_url(self) -> str:
118
+ return self._base_url
119
+
120
+ async def aclose(self) -> None:
121
+ if self._owns_client:
122
+ await self._client.aclose()
123
+
124
+ async def __aenter__(self) -> "AsyncPawPayments":
125
+ return self
126
+
127
+ async def __aexit__(self, *_: object) -> None:
128
+ await self.aclose()
129
+
130
+ async def _dispatch(self, spec: R.RequestSpec) -> Any:
131
+ envelope = await self._send(spec)
132
+ return unwrap_result(envelope)
133
+
134
+ async def _dispatch_list(self, spec: R.RequestSpec) -> ListResult:
135
+ envelope = await self._send(spec)
136
+ return unwrap_list(envelope) # type: ignore[return-value]
137
+
138
+ async def _send(self, spec: R.RequestSpec) -> dict[str, Any]:
139
+ url = self._base_url + spec.path
140
+ headers = base_headers(self._api_key, spec.uniq_id)
141
+ kwargs: dict[str, Any] = {"headers": headers, "timeout": self._timeout}
142
+ if spec.query:
143
+ kwargs["params"] = spec.query
144
+ if spec.body is not None:
145
+ kwargs["json"] = spec.body
146
+ try:
147
+ response = await self._client.request(spec.method, url, **kwargs)
148
+ except httpx.HTTPError as exc:
149
+ raise PawPaymentsApiError(
150
+ f"Network error: {exc}", "NETWORK_ERROR", None
151
+ ) from exc
152
+ return decode_envelope(response.status_code, response.content or b"")
@@ -0,0 +1,152 @@
1
+ """Synchronous client built on `requests`."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Iterable, Mapping, Optional
5
+
6
+ import requests
7
+
8
+ from . import _resources as R
9
+ from ._http import base_headers, decode_envelope, unwrap_list, unwrap_result
10
+ from ._models import ListResult
11
+ from .errors import PawPaymentsApiError
12
+
13
+
14
+ class _Resource:
15
+ def __init__(self, client: "PawPayments") -> None:
16
+ self._client = client
17
+
18
+
19
+ class _Assets(_Resource):
20
+ def list(self) -> list[dict[str, Any]]:
21
+ return self._client._dispatch(R.assets_list())
22
+
23
+
24
+ class _Rates(_Resource):
25
+ def get(self, *, base: str = "USD", assets: Optional[Iterable[str] | str] = None) -> dict[str, Any]:
26
+ return self._client._dispatch(R.rates_get(base=base, assets=assets))
27
+
28
+
29
+ class _Balance(_Resource):
30
+ def get(self) -> dict[str, Any]:
31
+ return self._client._dispatch(R.balance_get())
32
+
33
+
34
+ class _Invoices(_Resource):
35
+ def create(self, **params: Any) -> dict[str, Any]:
36
+ return self._client._dispatch(R.invoices_create(params))
37
+
38
+ def get(self, order_id: str) -> dict[str, Any]:
39
+ return self._client._dispatch(R.invoices_get(order_id))
40
+
41
+ def list(self, **params: Any) -> ListResult:
42
+ return self._client._dispatch_list(R.invoices_list(**params))
43
+
44
+ def notify(self, order_id: str) -> dict[str, Any]:
45
+ return self._client._dispatch(R.invoices_notify(order_id))
46
+
47
+
48
+ class _Payouts(_Resource):
49
+ def create(self, *, uniq_id: Optional[str] = None, **params: Any) -> dict[str, Any]:
50
+ return self._client._dispatch(R.payouts_create(params, uniq_id=uniq_id))
51
+
52
+ def batch(self, items: list[Mapping[str, Any]], *, uniq_id: Optional[str] = None) -> dict[str, Any]:
53
+ return self._client._dispatch(R.payouts_batch(items, uniq_id=uniq_id))
54
+
55
+ def get(self, payout_id: str) -> dict[str, Any]:
56
+ return self._client._dispatch(R.payouts_get(payout_id))
57
+
58
+ def list(self, **params: Any) -> ListResult:
59
+ return self._client._dispatch_list(R.payouts_list(**params))
60
+
61
+
62
+ class _Ledger(_Resource):
63
+ def list(self, **params: Any) -> ListResult:
64
+ return self._client._dispatch_list(R.ledger_list(**params))
65
+
66
+
67
+ class _Notifications(_Resource):
68
+ def list(self, **params: Any) -> ListResult:
69
+ return self._client._dispatch_list(R.notifications_list(**params))
70
+
71
+ def test(self, url: Optional[str] = None) -> dict[str, Any]:
72
+ return self._client._dispatch(R.notifications_test(url))
73
+
74
+
75
+ class _Permanent(_Resource):
76
+ def create(self, **params: Any) -> dict[str, Any]:
77
+ return self._client._dispatch(R.permanent_create(params))
78
+
79
+ def get(self, address_id: str) -> dict[str, Any]:
80
+ return self._client._dispatch(R.permanent_get(address_id))
81
+
82
+ def list(self, **params: Any) -> ListResult:
83
+ return self._client._dispatch_list(R.permanent_list(**params))
84
+
85
+ def deactivate(self, address_id: str) -> dict[str, Any]:
86
+ return self._client._dispatch(R.permanent_deactivate(address_id))
87
+
88
+
89
+ class PawPayments:
90
+ """Synchronous PawPayments client (built on `requests`)."""
91
+
92
+ def __init__(
93
+ self,
94
+ api_key: str,
95
+ base_url: str = "https://api.pawpayments.com",
96
+ timeout: float = 30.0,
97
+ session: Optional[requests.Session] = None,
98
+ ) -> None:
99
+ if not api_key:
100
+ raise ValueError("api_key is required")
101
+ self._api_key = api_key
102
+ self._base_url = base_url.rstrip("/")
103
+ self._timeout = timeout
104
+ self._session = session or requests.Session()
105
+ self._owns_session = session is None
106
+
107
+ self.assets = _Assets(self)
108
+ self.rates = _Rates(self)
109
+ self.balance = _Balance(self)
110
+ self.invoices = _Invoices(self)
111
+ self.payouts = _Payouts(self)
112
+ self.ledger = _Ledger(self)
113
+ self.notifications = _Notifications(self)
114
+ self.permanent = _Permanent(self)
115
+
116
+ @property
117
+ def base_url(self) -> str:
118
+ return self._base_url
119
+
120
+ def close(self) -> None:
121
+ if self._owns_session:
122
+ self._session.close()
123
+
124
+ def __enter__(self) -> "PawPayments":
125
+ return self
126
+
127
+ def __exit__(self, *_: object) -> None:
128
+ self.close()
129
+
130
+ def _dispatch(self, spec: R.RequestSpec) -> Any:
131
+ envelope = self._send(spec)
132
+ return unwrap_result(envelope)
133
+
134
+ def _dispatch_list(self, spec: R.RequestSpec) -> ListResult:
135
+ envelope = self._send(spec)
136
+ return unwrap_list(envelope) # type: ignore[return-value]
137
+
138
+ def _send(self, spec: R.RequestSpec) -> dict[str, Any]:
139
+ url = self._base_url + spec.path
140
+ headers = base_headers(self._api_key, spec.uniq_id)
141
+ kwargs: dict[str, Any] = {"headers": headers, "timeout": self._timeout}
142
+ if spec.query:
143
+ kwargs["params"] = spec.query
144
+ if spec.body is not None:
145
+ kwargs["json"] = spec.body
146
+ try:
147
+ response = self._session.request(spec.method, url, **kwargs)
148
+ except requests.RequestException as exc:
149
+ raise PawPaymentsApiError(
150
+ f"Network error: {exc}", "NETWORK_ERROR", None
151
+ ) from exc
152
+ return decode_envelope(response.status_code, response.content or b"")
@@ -0,0 +1,72 @@
1
+ """Shared envelope/error decoding used by both sync and async transports."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any, Optional
6
+
7
+ from .errors import PawPaymentsApiError
8
+
9
+ DEFAULT_USER_AGENT = "pawpayments-python-sdk/0.1.0"
10
+
11
+
12
+ def base_headers(api_key: str, uniq_id: Optional[str] = None) -> dict[str, str]:
13
+ headers = {
14
+ "x-api-key": api_key,
15
+ "Accept": "application/json",
16
+ "User-Agent": DEFAULT_USER_AGENT,
17
+ }
18
+ if uniq_id:
19
+ headers["x-uniq-id"] = uniq_id
20
+ return headers
21
+
22
+
23
+ def decode_envelope(status: int, raw: bytes) -> dict[str, Any]:
24
+ if not raw:
25
+ if status >= 400:
26
+ raise PawPaymentsApiError(f"HTTP {status}", "UNKNOWN", status)
27
+ return {}
28
+ try:
29
+ decoded = json.loads(raw.decode("utf-8"))
30
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
31
+ raise PawPaymentsApiError(
32
+ f"Invalid JSON response (HTTP {status})", "INVALID_RESPONSE", status
33
+ ) from exc
34
+ if not isinstance(decoded, dict):
35
+ raise PawPaymentsApiError(
36
+ f"Invalid JSON response (HTTP {status})", "INVALID_RESPONSE", status
37
+ )
38
+ if status >= 400 or decoded.get("ok") is False:
39
+ err = decoded.get("error")
40
+ if isinstance(err, dict):
41
+ code = str(err.get("code") or "UNKNOWN")
42
+ message = str(err.get("message") or f"HTTP {status}")
43
+ details = err.get("details") if isinstance(err.get("details"), list) else None
44
+ else:
45
+ code = str(decoded.get("code") or err or "UNKNOWN")
46
+ message = str(decoded.get("message") or err or f"HTTP {status}")
47
+ details = None
48
+ raise PawPaymentsApiError(message, code, status, details)
49
+ return decoded
50
+
51
+
52
+ def unwrap_result(envelope: dict[str, Any]) -> Any:
53
+ if "result" in envelope:
54
+ return envelope["result"]
55
+ if "data" in envelope:
56
+ return envelope["data"]
57
+ return envelope
58
+
59
+
60
+ def unwrap_list(envelope: dict[str, Any]) -> dict[str, Any]:
61
+ items = envelope.get("result")
62
+ if items is None:
63
+ items = envelope.get("data") or []
64
+ if not isinstance(items, list):
65
+ items = []
66
+ pagination = envelope.get("pagination") or {
67
+ "page": 1,
68
+ "per_page": len(items),
69
+ "total": len(items),
70
+ "pages": 1,
71
+ }
72
+ return {"items": items, "pagination": pagination}
@@ -0,0 +1,21 @@
1
+ """Lightweight typed result wrappers.
2
+
3
+ These are TypedDicts so the SDK can stay dependency-light (no pydantic).
4
+ At runtime they are just plain dicts; the typing exists for editor
5
+ support only.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, TypedDict
10
+
11
+
12
+ class Pagination(TypedDict):
13
+ page: int
14
+ per_page: int
15
+ total: int
16
+ pages: int
17
+
18
+
19
+ class ListResult(TypedDict):
20
+ items: list[dict[str, Any]]
21
+ pagination: Pagination
@@ -0,0 +1,122 @@
1
+ """Route specifications shared between the sync and async clients.
2
+
3
+ Each resource method builds a ``RequestSpec`` describing how to call the
4
+ HTTP API. The two transport implementations (`_client.PawPayments` and
5
+ `_async_client.AsyncPawPayments`) wire the spec into their respective
6
+ HTTP libraries so we don't repeat URL/query/body wiring twice.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Iterable, Mapping, Optional
13
+
14
+
15
+ @dataclass
16
+ class RequestSpec:
17
+ method: str
18
+ path: str
19
+ query: dict[str, Any] = field(default_factory=dict)
20
+ body: Optional[Any] = None
21
+ uniq_id: Optional[str] = None
22
+ paginated: bool = False
23
+
24
+
25
+ def _drop_none(d: Mapping[str, Any]) -> dict[str, Any]:
26
+ return {k: v for k, v in d.items() if v is not None}
27
+
28
+
29
+ def _join(value: Any) -> Any:
30
+ if isinstance(value, (list, tuple, set)) and not isinstance(value, str):
31
+ return ",".join(str(v) for v in value)
32
+ return value
33
+
34
+
35
+ def assets_list() -> RequestSpec:
36
+ return RequestSpec("GET", "/api/v2/assets")
37
+
38
+
39
+ def rates_get(*, base: str = "USD", assets: Optional[Iterable[str] | str] = None) -> RequestSpec:
40
+ return RequestSpec(
41
+ "GET",
42
+ "/api/v2/rates",
43
+ query=_drop_none({"base": base, "assets": _join(assets) if assets else None}),
44
+ )
45
+
46
+
47
+ def balance_get() -> RequestSpec:
48
+ return RequestSpec("GET", "/api/v2/balance")
49
+
50
+
51
+ def invoices_create(params: Mapping[str, Any]) -> RequestSpec:
52
+ return RequestSpec("POST", "/api/v2/invoices", body=dict(params))
53
+
54
+
55
+ def invoices_get(order_id: str) -> RequestSpec:
56
+ return RequestSpec("GET", f"/api/v2/invoices/{order_id}")
57
+
58
+
59
+ def invoices_list(**params: Any) -> RequestSpec:
60
+ if "order_ids" in params:
61
+ params["order_ids"] = _join(params["order_ids"])
62
+ return RequestSpec("GET", "/api/v2/invoices", query=_drop_none(params), paginated=True)
63
+
64
+
65
+ def invoices_notify(order_id: str) -> RequestSpec:
66
+ return RequestSpec("POST", f"/api/v2/invoices/{order_id}/notify")
67
+
68
+
69
+ def payouts_create(params: Mapping[str, Any], uniq_id: Optional[str] = None) -> RequestSpec:
70
+ return RequestSpec(
71
+ "POST",
72
+ "/api/v2/payouts",
73
+ body=dict(params),
74
+ uniq_id=uniq_id or str(uuid.uuid4()),
75
+ )
76
+
77
+
78
+ def payouts_batch(items: list[Mapping[str, Any]], uniq_id: Optional[str] = None) -> RequestSpec:
79
+ return RequestSpec(
80
+ "POST",
81
+ "/api/v2/payouts/batch",
82
+ body={"items": [dict(i) for i in items]},
83
+ uniq_id=uniq_id or str(uuid.uuid4()),
84
+ )
85
+
86
+
87
+ def payouts_get(payout_id: str) -> RequestSpec:
88
+ return RequestSpec("GET", f"/api/v2/payouts/{payout_id}")
89
+
90
+
91
+ def payouts_list(**params: Any) -> RequestSpec:
92
+ if "order_ids" in params:
93
+ params["order_ids"] = _join(params["order_ids"])
94
+ return RequestSpec("GET", "/api/v2/payouts", query=_drop_none(params), paginated=True)
95
+
96
+
97
+ def ledger_list(**params: Any) -> RequestSpec:
98
+ return RequestSpec("GET", "/api/v2/ledger", query=_drop_none(params), paginated=True)
99
+
100
+
101
+ def notifications_list(**params: Any) -> RequestSpec:
102
+ return RequestSpec("GET", "/api/v2/notifications", query=_drop_none(params), paginated=True)
103
+
104
+
105
+ def notifications_test(url: Optional[str] = None) -> RequestSpec:
106
+ return RequestSpec("POST", "/api/v2/notifications/test", body={"url": url} if url else {})
107
+
108
+
109
+ def permanent_create(params: Mapping[str, Any]) -> RequestSpec:
110
+ return RequestSpec("POST", "/api/v2/permanent", body=dict(params))
111
+
112
+
113
+ def permanent_get(address_id: str) -> RequestSpec:
114
+ return RequestSpec("GET", f"/api/v2/permanent/{address_id}")
115
+
116
+
117
+ def permanent_list(**params: Any) -> RequestSpec:
118
+ return RequestSpec("GET", "/api/v2/permanent", query=_drop_none(params), paginated=True)
119
+
120
+
121
+ def permanent_deactivate(address_id: str) -> RequestSpec:
122
+ return RequestSpec("DELETE", f"/api/v2/permanent/{address_id}")
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class PawPaymentsApiError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ code: str = "UNKNOWN",
11
+ http_status: Optional[int] = None,
12
+ details: Optional[list[dict[str, Any]]] = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.code = code
16
+ self.http_status = http_status
17
+ self.details = details
18
+
19
+ def __repr__(self) -> str:
20
+ return (
21
+ f"PawPaymentsApiError(code={self.code!r}, http_status={self.http_status!r}, "
22
+ f"message={str(self)!r})"
23
+ )
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ from typing import Any, Union
7
+
8
+ _BodyT = Union[str, bytes, bytearray, memoryview]
9
+
10
+
11
+ def _to_bytes(body: _BodyT) -> bytes:
12
+ if isinstance(body, (bytes, bytearray, memoryview)):
13
+ return bytes(body)
14
+ return body.encode("utf-8")
15
+
16
+
17
+ class Webhook:
18
+ @staticmethod
19
+ def verify_raw_body(raw_body: _BodyT, header_signature: str, api_key: str) -> bool:
20
+ if not header_signature:
21
+ return False
22
+ expected = hmac.new(api_key.encode("utf-8"), _to_bytes(raw_body), hashlib.sha256).hexdigest()
23
+ return hmac.compare_digest(expected, header_signature)
24
+
25
+ @staticmethod
26
+ def parse_payload(raw_body: _BodyT) -> dict[str, Any]:
27
+ try:
28
+ data = json.loads(_to_bytes(raw_body).decode("utf-8"))
29
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
30
+ raise ValueError("Invalid webhook payload: not valid JSON") from exc
31
+ if not isinstance(data, dict):
32
+ raise ValueError("Invalid webhook payload: not valid JSON")
33
+ return data
@@ -0,0 +1,105 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from pawpayments import AsyncPawPayments, PawPayments
6
+
7
+ API_KEY = os.environ.get("PAW_API_KEY")
8
+ BASE = os.environ.get("PAW_BASE", "https://api.pawpayments.com")
9
+
10
+ pytestmark = pytest.mark.skipif(not API_KEY, reason="PAW_API_KEY not set")
11
+
12
+
13
+ @pytest.fixture
14
+ def client():
15
+ with PawPayments(api_key=API_KEY, base_url=BASE) as c:
16
+ yield c
17
+
18
+
19
+ @pytest.fixture
20
+ async def async_client():
21
+ async with AsyncPawPayments(api_key=API_KEY, base_url=BASE) as c:
22
+ yield c
23
+
24
+
25
+ def test_assets_list(client):
26
+ assets = client.assets.list()
27
+ assert isinstance(assets, list)
28
+ assert assets
29
+ assert any(a.get("enabled", True) for a in assets)
30
+
31
+
32
+ def test_rates_get(client):
33
+ rates = client.rates.get(base="USD")
34
+ assert rates["base"] == "USD"
35
+ assert isinstance(rates["rates"], dict)
36
+ assert rates["rates"]
37
+
38
+
39
+ def test_balance_get(client):
40
+ balance = client.balance.get()
41
+ assert isinstance(balance, dict)
42
+
43
+
44
+ def test_invoices_create_get_list(client):
45
+ invoice = client.invoices.create(
46
+ amount=25,
47
+ fiat_currency="USD",
48
+ billing_type="STATIC",
49
+ asset="usdt_tron",
50
+ description="Python SDK live test",
51
+ ttl=1800,
52
+ )
53
+ assert invoice["order_id"]
54
+ fetched = client.invoices.get(invoice["order_id"])
55
+ assert fetched["order_id"] == invoice["order_id"]
56
+ page = client.invoices.list(page=1, per_page=5)
57
+ assert page["pagination"]["page"] == 1
58
+ assert isinstance(page["items"], list)
59
+
60
+
61
+ def test_payouts_list_readonly(client):
62
+ page = client.payouts.list(page=1, per_page=5)
63
+ assert page["pagination"]["page"] == 1
64
+ assert isinstance(page["items"], list)
65
+
66
+
67
+ def test_ledger_list(client):
68
+ page = client.ledger.list(page=1, per_page=5)
69
+ assert page["pagination"]["page"] == 1
70
+
71
+
72
+ def test_notifications_list(client):
73
+ page = client.notifications.list(page=1, per_page=5)
74
+ assert isinstance(page["items"], list)
75
+
76
+
77
+ def test_notifications_test_probe(client):
78
+ probe = client.notifications.test("https://httpbin.org/status/200")
79
+ assert probe["url"] == "https://httpbin.org/status/200"
80
+ assert "delivered" in probe
81
+
82
+
83
+ def test_permanent_list(client):
84
+ page = client.permanent.list(page=1, per_page=5)
85
+ assert page["pagination"]["page"] == 1
86
+
87
+
88
+ async def test_async_assets_list(async_client):
89
+ assets = await async_client.assets.list()
90
+ assert isinstance(assets, list)
91
+ assert assets
92
+
93
+
94
+ async def test_async_invoices_round_trip(async_client):
95
+ invoice = await async_client.invoices.create(
96
+ amount=15,
97
+ fiat_currency="USD",
98
+ billing_type="STATIC",
99
+ asset="usdt_tron",
100
+ description="Async Python SDK live test",
101
+ ttl=1800,
102
+ )
103
+ assert invoice["order_id"]
104
+ fetched = await async_client.invoices.get(invoice["order_id"])
105
+ assert fetched["order_id"] == invoice["order_id"]
@@ -0,0 +1,81 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from pawpayments import PawPayments, PawPaymentsApiError
6
+
7
+ API_KEY = os.environ.get("PAW_API_KEY")
8
+ BASE = os.environ.get("PAW_BASE", "https://api.pawpayments.com")
9
+
10
+ pytestmark = pytest.mark.skipif(not API_KEY, reason="PAW_API_KEY not set")
11
+
12
+
13
+ @pytest.fixture
14
+ def client():
15
+ with PawPayments(api_key=API_KEY, base_url=BASE) as c:
16
+ yield c
17
+
18
+
19
+ def test_bad_api_key_returns_4xx():
20
+ with PawPayments(api_key="definitely-not-valid", base_url=BASE) as bad:
21
+ with pytest.raises(PawPaymentsApiError) as exc:
22
+ bad.balance.get()
23
+ assert exc.value.http_status is not None
24
+ assert 400 <= exc.value.http_status < 500
25
+
26
+
27
+ def test_invoices_create_empty_body_rejected(client):
28
+ with pytest.raises(PawPaymentsApiError) as exc:
29
+ client.invoices.create()
30
+ assert 400 <= (exc.value.http_status or 0) < 500
31
+
32
+
33
+ def test_invoices_create_unknown_asset_rejected(client):
34
+ with pytest.raises(PawPaymentsApiError) as exc:
35
+ client.invoices.create(
36
+ amount=5,
37
+ fiat_currency="USD",
38
+ billing_type="STATIC",
39
+ asset="doge_doge",
40
+ )
41
+ assert 400 <= (exc.value.http_status or 0) < 500
42
+
43
+
44
+ def test_invoices_create_negative_amount_rejected(client):
45
+ with pytest.raises(PawPaymentsApiError) as exc:
46
+ client.invoices.create(
47
+ amount=-1,
48
+ fiat_currency="USD",
49
+ billing_type="STATIC",
50
+ asset="usdt_tron",
51
+ )
52
+ assert 400 <= (exc.value.http_status or 0) < 500
53
+
54
+
55
+ def test_invoices_get_unknown_id(client):
56
+ with pytest.raises(PawPaymentsApiError) as exc:
57
+ client.invoices.get("ffffffffffffffffffffffff")
58
+ assert 400 <= (exc.value.http_status or 0) < 500
59
+
60
+
61
+ def test_payout_without_balance_or_ip(client):
62
+ with pytest.raises(PawPaymentsApiError) as exc:
63
+ client.payouts.create(
64
+ address="TTestAddress1234567890",
65
+ amount=10,
66
+ fiat_currency="USD",
67
+ asset="usdt_tron",
68
+ )
69
+ assert 400 <= (exc.value.http_status or 0) < 500
70
+
71
+
72
+ def test_payouts_get_unknown_id(client):
73
+ with pytest.raises(PawPaymentsApiError) as exc:
74
+ client.payouts.get("ffffffffffffffffffffffff")
75
+ assert 400 <= (exc.value.http_status or 0) < 500
76
+
77
+
78
+ def test_permanent_create_bad_family(client):
79
+ with pytest.raises(PawPaymentsApiError) as exc:
80
+ client.permanent.create(user_id="live-test-user", family="not_a_family")
81
+ assert 400 <= (exc.value.http_status or 0) < 500
@@ -0,0 +1,79 @@
1
+ import hashlib
2
+ import hmac
3
+ import shutil
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from pawpayments import Webhook
9
+
10
+ API_KEY = "test_api_key_abc123"
11
+
12
+
13
+ def _sig(message: bytes | str) -> str:
14
+ if isinstance(message, str):
15
+ message = message.encode()
16
+ return hmac.new(API_KEY.encode(), message, hashlib.sha256).hexdigest()
17
+
18
+
19
+ def test_verify_raw_body_valid():
20
+ body = '{"order_id":"abc","status":"success"}'
21
+ assert Webhook.verify_raw_body(body, _sig(body), API_KEY) is True
22
+
23
+
24
+ def test_verify_raw_body_bad_signature():
25
+ body = '{"order_id":"abc","status":"success"}'
26
+ assert Webhook.verify_raw_body(body, "deadbeef", API_KEY) is False
27
+
28
+
29
+ def test_verify_raw_body_tampered():
30
+ body = '{"order_id":"abc","status":"success"}'
31
+ sig = _sig(body)
32
+ assert Webhook.verify_raw_body('{"order_id":"abc","status":"cancelled"}', sig, API_KEY) is False
33
+
34
+
35
+ def test_verify_raw_body_bytes_input():
36
+ body = '{"order_id":"abc"}'
37
+ assert Webhook.verify_raw_body(body.encode(), _sig(body), API_KEY) is True
38
+
39
+
40
+ def test_verify_raw_body_empty_signature():
41
+ assert Webhook.verify_raw_body("{}", "", API_KEY) is False
42
+
43
+
44
+ def test_parse_payload_object():
45
+ body = '{"order_id":"abc","status":"success","amount":100.5}'
46
+ payload = Webhook.parse_payload(body)
47
+ assert payload["order_id"] == "abc"
48
+ assert payload["amount"] == 100.5
49
+
50
+
51
+ def test_parse_payload_invalid_json():
52
+ with pytest.raises(ValueError):
53
+ Webhook.parse_payload("not json")
54
+
55
+
56
+ def test_parse_payload_array_rejected():
57
+ with pytest.raises(ValueError):
58
+ Webhook.parse_payload("[1,2,3]")
59
+
60
+
61
+ @pytest.mark.skipif(shutil.which("php") is None, reason="PHP not installed")
62
+ def test_php_interop_round_trip(tmp_path):
63
+ """PHP `Webhook::verifyRawBody` from the official PHP SDK must accept
64
+ our Python-generated signature unchanged."""
65
+ body = '{"order_id":"abc","status":"success"}'
66
+ sig = _sig(body)
67
+ php_code = (
68
+ "$body = file_get_contents($argv[1]);"
69
+ "$expected = hash_hmac('sha256', $body, $argv[3]);"
70
+ "echo hash_equals($expected, $argv[2]) ? 'OK' : 'BAD';"
71
+ )
72
+ body_file = tmp_path / "body.json"
73
+ body_file.write_text(body)
74
+ proc = subprocess.run(
75
+ ["php", "-r", php_code, str(body_file), sig, API_KEY],
76
+ capture_output=True,
77
+ timeout=5,
78
+ )
79
+ assert proc.stdout.strip() == b"OK", proc.stdout + proc.stderr