payfurl 1.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.
- payfurl-1.0.0/LICENSE +21 -0
- payfurl-1.0.0/PKG-INFO +287 -0
- payfurl-1.0.0/README.md +260 -0
- payfurl-1.0.0/pyproject.toml +82 -0
- payfurl-1.0.0/pyproject.toml.orig +55 -0
- payfurl-1.0.0/src/payfurl/__init__.py +35 -0
- payfurl-1.0.0/src/payfurl/_client.py +278 -0
- payfurl-1.0.0/src/payfurl/errors.py +261 -0
- payfurl-1.0.0/src/payfurl/models.py +1420 -0
- payfurl-1.0.0/src/payfurl/py.typed +0 -0
- payfurl-1.0.0/src/payfurl/resources.py +468 -0
- payfurl-1.0.0/src/payfurl/webhooks.py +52 -0
payfurl-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 payFURL
|
|
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.
|
payfurl-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: payfurl
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: payFURL's Python SDK: the payFURL payments API, typed
|
|
5
|
+
Keywords: payfurl,payments,payment orchestration,sdk
|
|
6
|
+
Author: payFURL
|
|
7
|
+
Author-email: payFURL <support@payfurl.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: httpx>=0.27,<1
|
|
20
|
+
Requires-Dist: pydantic>=2.11,<3
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Project-URL: Homepage, https://payfurl.com
|
|
23
|
+
Project-URL: Documentation, https://docs.payfurl.com
|
|
24
|
+
Project-URL: Source, https://github.com/payfurl/pythonsdk
|
|
25
|
+
Project-URL: Changelog, https://github.com/payfurl/pythonsdk/blob/main/CHANGELOG.md
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# payFURL Python SDK
|
|
29
|
+
|
|
30
|
+
The [payFURL](https://payfurl.com) payments API for Python servers: charges, customers, saved payment methods,
|
|
31
|
+
subscriptions, refunds and the rest of the public API, with an account's secret key. Typed: every request and
|
|
32
|
+
response is a [Pydantic](https://docs.pydantic.dev) model, and the package ships `py.typed`.
|
|
33
|
+
|
|
34
|
+
The API reference is at [docs.payfurl.com](https://docs.payfurl.com). Card details are collected in the browser with
|
|
35
|
+
payFURL's browser SDK ([`@payfurl/client`](https://www.npmjs.com/package/@payfurl/client)), which turns them into a
|
|
36
|
+
token; this SDK charges the token on your server.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install payfurl
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Python 3.11 or later. It depends on `httpx` and `pydantic` 2.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from decimal import Decimal
|
|
50
|
+
|
|
51
|
+
from payfurl import Environment, PayfurlClient
|
|
52
|
+
from payfurl.models import NewChargeToken
|
|
53
|
+
|
|
54
|
+
client = PayfurlClient("sectest...-au", Environment.SANDBOX)
|
|
55
|
+
|
|
56
|
+
charge = client.charges.create_with_token(
|
|
57
|
+
NewChargeToken(amount=Decimal("20.00"), currency="AUD", token=token, reference="order-1001")
|
|
58
|
+
)
|
|
59
|
+
print(charge.charge_id, charge.status)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Make one client per account and reuse it: it keeps connections open. `client.close()`, or
|
|
63
|
+
`with PayfurlClient(...) as client:`, closes them.
|
|
64
|
+
|
|
65
|
+
## Environments and regions
|
|
66
|
+
|
|
67
|
+
| `Environment` | API |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `SANDBOX` | `https://sandbox-api.payfurl.com`, or the key region's host, such as `https://sandbox-api-au.payfurl.com` |
|
|
70
|
+
| `PRODUCTION` | `https://api.payfurl.com`, or the key region's host, such as `https://api-au.payfurl.com` |
|
|
71
|
+
| `DEVELOPMENT` | payFURL's development environment |
|
|
72
|
+
| `LOCAL` | `https://localhost:5001`, a payFURL API on this machine |
|
|
73
|
+
|
|
74
|
+
A key ends in its account's region (`sectest...-au`), and the client calls that region's host. `base_url=` calls
|
|
75
|
+
another host. TLS certificates are verified, except with `Environment.LOCAL`, whose API serves a development
|
|
76
|
+
certificate.
|
|
77
|
+
|
|
78
|
+
Other options: `timeout` (seconds, per request, default 60), `max_retries` and `retry_delay` (below), `public_key`
|
|
79
|
+
(below) and `http_client`, an `httpx.Client` of your own for proxies or event hooks. The SDK uses a client you pass
|
|
80
|
+
as it is, TLS settings included, and doesn't close it.
|
|
81
|
+
|
|
82
|
+
## Models
|
|
83
|
+
|
|
84
|
+
Requests take keyword arguments in snake_case and are sent in the API's camelCase. A field you leave as `None` isn't
|
|
85
|
+
sent, so the API applies its default; an argument the model doesn't have is an error. Amounts are `Decimal`.
|
|
86
|
+
|
|
87
|
+
Responses have every field optional. A field this version of the SDK doesn't know is kept in `model_extra`, and an
|
|
88
|
+
enum value it doesn't know (a new charge status, say) is the plain string. `model_dump(by_alias=True)` gives the
|
|
89
|
+
API's JSON back.
|
|
90
|
+
|
|
91
|
+
## Examples
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from datetime import date, datetime, timezone
|
|
95
|
+
from decimal import Decimal
|
|
96
|
+
|
|
97
|
+
from payfurl.models import (
|
|
98
|
+
CardRequestInformation,
|
|
99
|
+
ChargeSearch,
|
|
100
|
+
CustomerSearch,
|
|
101
|
+
NewChargeCard,
|
|
102
|
+
NewChargeCustomer,
|
|
103
|
+
NewChargePaymentMethod,
|
|
104
|
+
NewChargeToken,
|
|
105
|
+
NewCustomerToken,
|
|
106
|
+
NewPaymentMethodToken,
|
|
107
|
+
NewTransfer,
|
|
108
|
+
NewTransferGroup,
|
|
109
|
+
NewVault,
|
|
110
|
+
NewWebhookSubscription,
|
|
111
|
+
SubscriptionCreate,
|
|
112
|
+
SubscriptionInterval,
|
|
113
|
+
SubscriptionStatus,
|
|
114
|
+
UpdateCustomer,
|
|
115
|
+
WebhookType,
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Charges
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# A token from the browser SDK
|
|
123
|
+
charge = client.charges.create_with_token(NewChargeToken(amount=Decimal("20.00"), currency="AUD", token=token))
|
|
124
|
+
|
|
125
|
+
# A card, with one of the account's providers (this puts your server in PCI DSS scope: prefer tokens)
|
|
126
|
+
charge = client.charges.create_with_card(
|
|
127
|
+
NewChargeCard(
|
|
128
|
+
amount=Decimal("20.00"),
|
|
129
|
+
currency="AUD",
|
|
130
|
+
provider_id=provider_id,
|
|
131
|
+
payment_information=CardRequestInformation(card_number="4111111111111111", expiry_date="12/30", ccv="123"),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# A saved payment method, or a customer's default one
|
|
136
|
+
client.charges.create_with_payment_method(NewChargePaymentMethod(amount=Decimal("9.95"), payment_method_id=pm_id))
|
|
137
|
+
client.charges.create_with_customer(NewChargeCustomer(amount=Decimal("9.95"), customer_id=customer_id))
|
|
138
|
+
|
|
139
|
+
# Authorise, then capture or void
|
|
140
|
+
authorised = client.charges.create_with_token(
|
|
141
|
+
NewChargeToken(amount=Decimal("50.00"), currency="AUD", token=token, capture=False)
|
|
142
|
+
)
|
|
143
|
+
client.charges.capture(authorised.charge_id, Decimal("45.00")) # or client.charges.void(authorised.charge_id)
|
|
144
|
+
|
|
145
|
+
# Refund part of a charge, or all of it
|
|
146
|
+
client.charges.refund(charge.charge_id, Decimal("5.00"), comment="Damaged in transit")
|
|
147
|
+
client.charges.refund(charge.charge_id)
|
|
148
|
+
|
|
149
|
+
client.charges.get(charge.charge_id)
|
|
150
|
+
page = client.charges.search(ChargeSearch(added_after=datetime(2026, 11, 1, tzinfo=timezone.utc), limit=50))
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Also `create_with_card_least_cost`, `create_with_bank_account` and `create_with_network_token`.
|
|
154
|
+
|
|
155
|
+
### Customers and payment methods
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
customer = client.customers.create_with_token(NewCustomerToken(token=token, email="ann@example.com"))
|
|
159
|
+
client.customers.add_payment_method_with_token(customer.customer_id, NewPaymentMethodToken(token=other_token))
|
|
160
|
+
client.customers.payment_methods(customer.customer_id)
|
|
161
|
+
client.customers.update(customer.customer_id, UpdateCustomer(phone="+61400000000"))
|
|
162
|
+
client.customers.search(CustomerSearch(email="ann@example.com"))
|
|
163
|
+
client.customers.delete(customer.customer_id)
|
|
164
|
+
|
|
165
|
+
payment_method = client.payment_methods.create_with_token(NewPaymentMethodToken(token=token))
|
|
166
|
+
client.payment_methods.get(payment_method.payment_method_id)
|
|
167
|
+
client.payment_methods.delete(payment_method.payment_method_id)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Customers and payment methods can also be created from a card, a bank account, a PayTo agreement or a gateway's
|
|
171
|
+
token (`create_with_provider_token`, `create_with_single_use_token`); payment methods also from the vault
|
|
172
|
+
(`create_with_vault`).
|
|
173
|
+
|
|
174
|
+
### Subscriptions
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
subscription = client.subscriptions.create(
|
|
178
|
+
SubscriptionCreate(
|
|
179
|
+
payment_method_id=pm_id,
|
|
180
|
+
amount=Decimal("9.95"),
|
|
181
|
+
currency="AUD",
|
|
182
|
+
interval=SubscriptionInterval.MONTH,
|
|
183
|
+
frequency=1,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
client.subscriptions.update_status(subscription.subscription_id, SubscriptionStatus.SUSPENDED)
|
|
187
|
+
client.subscriptions.delete(subscription.subscription_id)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### The rest
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
client.transfers.create(
|
|
194
|
+
NewTransferGroup(
|
|
195
|
+
provider_id=provider_id,
|
|
196
|
+
group_reference="g1",
|
|
197
|
+
charge_id=charge_id,
|
|
198
|
+
transfers=[NewTransfer(account="acct_1", amount=Decimal("5.00"))],
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
client.vaults.create(NewVault(card_number="4111111111111111", expire_seconds=3600)) # .get, .delete
|
|
202
|
+
client.webhook_subscriptions.create(
|
|
203
|
+
NewWebhookSubscription(url="https://shop.example/hooks", types=[WebhookType.TRANSACTION])
|
|
204
|
+
)
|
|
205
|
+
client.disputes.search() # .get
|
|
206
|
+
client.settlements.search(date(2026, 11, 1), date(2026, 11, 7))
|
|
207
|
+
client.info.providers(Decimal("20.00"), "AUD") # the payment methods a checkout can show
|
|
208
|
+
client.version() # the API's version and region
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Also `batches` (charge many payment methods at once), `providers` (the account's gateways), `payment_links` and
|
|
212
|
+
`tokens`. `info.providers()` also works with only a public key:
|
|
213
|
+
`PayfurlClient(None, Environment.SANDBOX, public_key="pub...")`.
|
|
214
|
+
|
|
215
|
+
## Errors and retries
|
|
216
|
+
|
|
217
|
+
Every error is a `payfurl.PayfurlError`:
|
|
218
|
+
|
|
219
|
+
| Exception | When |
|
|
220
|
+
| --- | --- |
|
|
221
|
+
| `APIError` | The API answered with an error: a decline, a validation error, a conflict, a server error |
|
|
222
|
+
| `AuthenticationError` (an `APIError`) | HTTP 401 or 403: a wrong key, or a key that can't call the endpoint |
|
|
223
|
+
| `NotFoundError` (an `APIError`) | HTTP 404 |
|
|
224
|
+
| `APIConnectionError` | No response: DNS, a refused or dropped connection, TLS |
|
|
225
|
+
| `APITimeoutError` (an `APIConnectionError`) | No response within `timeout` |
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from payfurl import APIError, ErrorCode
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
client.charges.create_with_token(NewChargeToken(amount=Decimal("20.00"), currency="AUD", token=token))
|
|
232
|
+
except APIError as e:
|
|
233
|
+
print(e.status_code, e.error_code, e.message, e.gateway_code, e.gateway_message, e.charge_id)
|
|
234
|
+
if e.error_code == ErrorCode.INSUFFICIENT_FUNDS:
|
|
235
|
+
...
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`error_code` is payFURL's [error code](https://docs.payfurl.com/errorcodes); `body` is the API's error response.
|
|
239
|
+
`is_retryable` says whether the same request may succeed later.
|
|
240
|
+
|
|
241
|
+
GETs are retried twice by default (`max_retries`) after a connection error, a timeout or a retryable API error,
|
|
242
|
+
waiting 0.5 s and then 1 s (`retry_delay`, doubled each time). Nothing else is retried: the API has no idempotency
|
|
243
|
+
keys, so a retried charge could charge twice. After a timeout on a charge, look it up (`charges.search` by your
|
|
244
|
+
`reference`) before charging again.
|
|
245
|
+
|
|
246
|
+
## Webhooks
|
|
247
|
+
|
|
248
|
+
payFURL signs each webhook with the account's webhook signing key: the `X-Payfurl-Signature` header holds base64 of
|
|
249
|
+
the body's HMAC-SHA256. Check it against the raw body, before you parse it:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from payfurl import webhooks
|
|
253
|
+
|
|
254
|
+
event = webhooks.parse(request.body, request.headers.get("X-Payfurl-Signature"), signing_key)
|
|
255
|
+
if event["meta"]["type"] == "Transaction":
|
|
256
|
+
charge_id = event["data"]["chargeId"]
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`parse` raises `webhooks.WebhookSignatureError` when the signature doesn't match; `webhooks.verify` returns a bool.
|
|
260
|
+
|
|
261
|
+
## Logging
|
|
262
|
+
|
|
263
|
+
The SDK logs each request at `DEBUG` on the `payfurl` logger: the method, the path, the status and the time. It never
|
|
264
|
+
logs a body or a header, so card numbers and keys don't reach your logs.
|
|
265
|
+
|
|
266
|
+
## Calling an endpoint the SDK has no method for
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
data = client.request("GET", "/some/path", params={"limit": "10"})
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`request(method, path, *, json=None, params=None, auth="secret", headers=None)` sends the request with the client's
|
|
273
|
+
key, TLS, retries and errors, and returns the response's JSON (`None` for an empty body). `auth` is `"secret"`,
|
|
274
|
+
`"public"` (the client's `public_key`) or `None`. It's for advanced use: prefer the typed methods.
|
|
275
|
+
|
|
276
|
+
## Versions
|
|
277
|
+
|
|
278
|
+
The SDK follows [semantic versioning](https://semver.org); [CHANGELOG.md](CHANGELOG.md) lists every release. It
|
|
279
|
+
supports the Python versions that are not end-of-life.
|
|
280
|
+
|
|
281
|
+
## Contributing
|
|
282
|
+
|
|
283
|
+
[CONTRIBUTING.md](CONTRIBUTING.md): development, tests and releases.
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT
|
payfurl-1.0.0/README.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# payFURL Python SDK
|
|
2
|
+
|
|
3
|
+
The [payFURL](https://payfurl.com) payments API for Python servers: charges, customers, saved payment methods,
|
|
4
|
+
subscriptions, refunds and the rest of the public API, with an account's secret key. Typed: every request and
|
|
5
|
+
response is a [Pydantic](https://docs.pydantic.dev) model, and the package ships `py.typed`.
|
|
6
|
+
|
|
7
|
+
The API reference is at [docs.payfurl.com](https://docs.payfurl.com). Card details are collected in the browser with
|
|
8
|
+
payFURL's browser SDK ([`@payfurl/client`](https://www.npmjs.com/package/@payfurl/client)), which turns them into a
|
|
9
|
+
token; this SDK charges the token on your server.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install payfurl
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Python 3.11 or later. It depends on `httpx` and `pydantic` 2.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
|
|
24
|
+
from payfurl import Environment, PayfurlClient
|
|
25
|
+
from payfurl.models import NewChargeToken
|
|
26
|
+
|
|
27
|
+
client = PayfurlClient("sectest...-au", Environment.SANDBOX)
|
|
28
|
+
|
|
29
|
+
charge = client.charges.create_with_token(
|
|
30
|
+
NewChargeToken(amount=Decimal("20.00"), currency="AUD", token=token, reference="order-1001")
|
|
31
|
+
)
|
|
32
|
+
print(charge.charge_id, charge.status)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Make one client per account and reuse it: it keeps connections open. `client.close()`, or
|
|
36
|
+
`with PayfurlClient(...) as client:`, closes them.
|
|
37
|
+
|
|
38
|
+
## Environments and regions
|
|
39
|
+
|
|
40
|
+
| `Environment` | API |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `SANDBOX` | `https://sandbox-api.payfurl.com`, or the key region's host, such as `https://sandbox-api-au.payfurl.com` |
|
|
43
|
+
| `PRODUCTION` | `https://api.payfurl.com`, or the key region's host, such as `https://api-au.payfurl.com` |
|
|
44
|
+
| `DEVELOPMENT` | payFURL's development environment |
|
|
45
|
+
| `LOCAL` | `https://localhost:5001`, a payFURL API on this machine |
|
|
46
|
+
|
|
47
|
+
A key ends in its account's region (`sectest...-au`), and the client calls that region's host. `base_url=` calls
|
|
48
|
+
another host. TLS certificates are verified, except with `Environment.LOCAL`, whose API serves a development
|
|
49
|
+
certificate.
|
|
50
|
+
|
|
51
|
+
Other options: `timeout` (seconds, per request, default 60), `max_retries` and `retry_delay` (below), `public_key`
|
|
52
|
+
(below) and `http_client`, an `httpx.Client` of your own for proxies or event hooks. The SDK uses a client you pass
|
|
53
|
+
as it is, TLS settings included, and doesn't close it.
|
|
54
|
+
|
|
55
|
+
## Models
|
|
56
|
+
|
|
57
|
+
Requests take keyword arguments in snake_case and are sent in the API's camelCase. A field you leave as `None` isn't
|
|
58
|
+
sent, so the API applies its default; an argument the model doesn't have is an error. Amounts are `Decimal`.
|
|
59
|
+
|
|
60
|
+
Responses have every field optional. A field this version of the SDK doesn't know is kept in `model_extra`, and an
|
|
61
|
+
enum value it doesn't know (a new charge status, say) is the plain string. `model_dump(by_alias=True)` gives the
|
|
62
|
+
API's JSON back.
|
|
63
|
+
|
|
64
|
+
## Examples
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from datetime import date, datetime, timezone
|
|
68
|
+
from decimal import Decimal
|
|
69
|
+
|
|
70
|
+
from payfurl.models import (
|
|
71
|
+
CardRequestInformation,
|
|
72
|
+
ChargeSearch,
|
|
73
|
+
CustomerSearch,
|
|
74
|
+
NewChargeCard,
|
|
75
|
+
NewChargeCustomer,
|
|
76
|
+
NewChargePaymentMethod,
|
|
77
|
+
NewChargeToken,
|
|
78
|
+
NewCustomerToken,
|
|
79
|
+
NewPaymentMethodToken,
|
|
80
|
+
NewTransfer,
|
|
81
|
+
NewTransferGroup,
|
|
82
|
+
NewVault,
|
|
83
|
+
NewWebhookSubscription,
|
|
84
|
+
SubscriptionCreate,
|
|
85
|
+
SubscriptionInterval,
|
|
86
|
+
SubscriptionStatus,
|
|
87
|
+
UpdateCustomer,
|
|
88
|
+
WebhookType,
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Charges
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# A token from the browser SDK
|
|
96
|
+
charge = client.charges.create_with_token(NewChargeToken(amount=Decimal("20.00"), currency="AUD", token=token))
|
|
97
|
+
|
|
98
|
+
# A card, with one of the account's providers (this puts your server in PCI DSS scope: prefer tokens)
|
|
99
|
+
charge = client.charges.create_with_card(
|
|
100
|
+
NewChargeCard(
|
|
101
|
+
amount=Decimal("20.00"),
|
|
102
|
+
currency="AUD",
|
|
103
|
+
provider_id=provider_id,
|
|
104
|
+
payment_information=CardRequestInformation(card_number="4111111111111111", expiry_date="12/30", ccv="123"),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# A saved payment method, or a customer's default one
|
|
109
|
+
client.charges.create_with_payment_method(NewChargePaymentMethod(amount=Decimal("9.95"), payment_method_id=pm_id))
|
|
110
|
+
client.charges.create_with_customer(NewChargeCustomer(amount=Decimal("9.95"), customer_id=customer_id))
|
|
111
|
+
|
|
112
|
+
# Authorise, then capture or void
|
|
113
|
+
authorised = client.charges.create_with_token(
|
|
114
|
+
NewChargeToken(amount=Decimal("50.00"), currency="AUD", token=token, capture=False)
|
|
115
|
+
)
|
|
116
|
+
client.charges.capture(authorised.charge_id, Decimal("45.00")) # or client.charges.void(authorised.charge_id)
|
|
117
|
+
|
|
118
|
+
# Refund part of a charge, or all of it
|
|
119
|
+
client.charges.refund(charge.charge_id, Decimal("5.00"), comment="Damaged in transit")
|
|
120
|
+
client.charges.refund(charge.charge_id)
|
|
121
|
+
|
|
122
|
+
client.charges.get(charge.charge_id)
|
|
123
|
+
page = client.charges.search(ChargeSearch(added_after=datetime(2026, 11, 1, tzinfo=timezone.utc), limit=50))
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Also `create_with_card_least_cost`, `create_with_bank_account` and `create_with_network_token`.
|
|
127
|
+
|
|
128
|
+
### Customers and payment methods
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
customer = client.customers.create_with_token(NewCustomerToken(token=token, email="ann@example.com"))
|
|
132
|
+
client.customers.add_payment_method_with_token(customer.customer_id, NewPaymentMethodToken(token=other_token))
|
|
133
|
+
client.customers.payment_methods(customer.customer_id)
|
|
134
|
+
client.customers.update(customer.customer_id, UpdateCustomer(phone="+61400000000"))
|
|
135
|
+
client.customers.search(CustomerSearch(email="ann@example.com"))
|
|
136
|
+
client.customers.delete(customer.customer_id)
|
|
137
|
+
|
|
138
|
+
payment_method = client.payment_methods.create_with_token(NewPaymentMethodToken(token=token))
|
|
139
|
+
client.payment_methods.get(payment_method.payment_method_id)
|
|
140
|
+
client.payment_methods.delete(payment_method.payment_method_id)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Customers and payment methods can also be created from a card, a bank account, a PayTo agreement or a gateway's
|
|
144
|
+
token (`create_with_provider_token`, `create_with_single_use_token`); payment methods also from the vault
|
|
145
|
+
(`create_with_vault`).
|
|
146
|
+
|
|
147
|
+
### Subscriptions
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
subscription = client.subscriptions.create(
|
|
151
|
+
SubscriptionCreate(
|
|
152
|
+
payment_method_id=pm_id,
|
|
153
|
+
amount=Decimal("9.95"),
|
|
154
|
+
currency="AUD",
|
|
155
|
+
interval=SubscriptionInterval.MONTH,
|
|
156
|
+
frequency=1,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
client.subscriptions.update_status(subscription.subscription_id, SubscriptionStatus.SUSPENDED)
|
|
160
|
+
client.subscriptions.delete(subscription.subscription_id)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### The rest
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
client.transfers.create(
|
|
167
|
+
NewTransferGroup(
|
|
168
|
+
provider_id=provider_id,
|
|
169
|
+
group_reference="g1",
|
|
170
|
+
charge_id=charge_id,
|
|
171
|
+
transfers=[NewTransfer(account="acct_1", amount=Decimal("5.00"))],
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
client.vaults.create(NewVault(card_number="4111111111111111", expire_seconds=3600)) # .get, .delete
|
|
175
|
+
client.webhook_subscriptions.create(
|
|
176
|
+
NewWebhookSubscription(url="https://shop.example/hooks", types=[WebhookType.TRANSACTION])
|
|
177
|
+
)
|
|
178
|
+
client.disputes.search() # .get
|
|
179
|
+
client.settlements.search(date(2026, 11, 1), date(2026, 11, 7))
|
|
180
|
+
client.info.providers(Decimal("20.00"), "AUD") # the payment methods a checkout can show
|
|
181
|
+
client.version() # the API's version and region
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Also `batches` (charge many payment methods at once), `providers` (the account's gateways), `payment_links` and
|
|
185
|
+
`tokens`. `info.providers()` also works with only a public key:
|
|
186
|
+
`PayfurlClient(None, Environment.SANDBOX, public_key="pub...")`.
|
|
187
|
+
|
|
188
|
+
## Errors and retries
|
|
189
|
+
|
|
190
|
+
Every error is a `payfurl.PayfurlError`:
|
|
191
|
+
|
|
192
|
+
| Exception | When |
|
|
193
|
+
| --- | --- |
|
|
194
|
+
| `APIError` | The API answered with an error: a decline, a validation error, a conflict, a server error |
|
|
195
|
+
| `AuthenticationError` (an `APIError`) | HTTP 401 or 403: a wrong key, or a key that can't call the endpoint |
|
|
196
|
+
| `NotFoundError` (an `APIError`) | HTTP 404 |
|
|
197
|
+
| `APIConnectionError` | No response: DNS, a refused or dropped connection, TLS |
|
|
198
|
+
| `APITimeoutError` (an `APIConnectionError`) | No response within `timeout` |
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from payfurl import APIError, ErrorCode
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
client.charges.create_with_token(NewChargeToken(amount=Decimal("20.00"), currency="AUD", token=token))
|
|
205
|
+
except APIError as e:
|
|
206
|
+
print(e.status_code, e.error_code, e.message, e.gateway_code, e.gateway_message, e.charge_id)
|
|
207
|
+
if e.error_code == ErrorCode.INSUFFICIENT_FUNDS:
|
|
208
|
+
...
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`error_code` is payFURL's [error code](https://docs.payfurl.com/errorcodes); `body` is the API's error response.
|
|
212
|
+
`is_retryable` says whether the same request may succeed later.
|
|
213
|
+
|
|
214
|
+
GETs are retried twice by default (`max_retries`) after a connection error, a timeout or a retryable API error,
|
|
215
|
+
waiting 0.5 s and then 1 s (`retry_delay`, doubled each time). Nothing else is retried: the API has no idempotency
|
|
216
|
+
keys, so a retried charge could charge twice. After a timeout on a charge, look it up (`charges.search` by your
|
|
217
|
+
`reference`) before charging again.
|
|
218
|
+
|
|
219
|
+
## Webhooks
|
|
220
|
+
|
|
221
|
+
payFURL signs each webhook with the account's webhook signing key: the `X-Payfurl-Signature` header holds base64 of
|
|
222
|
+
the body's HMAC-SHA256. Check it against the raw body, before you parse it:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from payfurl import webhooks
|
|
226
|
+
|
|
227
|
+
event = webhooks.parse(request.body, request.headers.get("X-Payfurl-Signature"), signing_key)
|
|
228
|
+
if event["meta"]["type"] == "Transaction":
|
|
229
|
+
charge_id = event["data"]["chargeId"]
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`parse` raises `webhooks.WebhookSignatureError` when the signature doesn't match; `webhooks.verify` returns a bool.
|
|
233
|
+
|
|
234
|
+
## Logging
|
|
235
|
+
|
|
236
|
+
The SDK logs each request at `DEBUG` on the `payfurl` logger: the method, the path, the status and the time. It never
|
|
237
|
+
logs a body or a header, so card numbers and keys don't reach your logs.
|
|
238
|
+
|
|
239
|
+
## Calling an endpoint the SDK has no method for
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
data = client.request("GET", "/some/path", params={"limit": "10"})
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`request(method, path, *, json=None, params=None, auth="secret", headers=None)` sends the request with the client's
|
|
246
|
+
key, TLS, retries and errors, and returns the response's JSON (`None` for an empty body). `auth` is `"secret"`,
|
|
247
|
+
`"public"` (the client's `public_key`) or `None`. It's for advanced use: prefer the typed methods.
|
|
248
|
+
|
|
249
|
+
## Versions
|
|
250
|
+
|
|
251
|
+
The SDK follows [semantic versioning](https://semver.org); [CHANGELOG.md](CHANGELOG.md) lists every release. It
|
|
252
|
+
supports the Python versions that are not end-of-life.
|
|
253
|
+
|
|
254
|
+
## Contributing
|
|
255
|
+
|
|
256
|
+
[CONTRIBUTING.md](CONTRIBUTING.md): development, tests and releases.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "payfurl"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "payFURL's Python SDK: the payFURL payments API, typed"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"httpx>=0.27,<1",
|
|
11
|
+
"pydantic>=2.11,<3",
|
|
12
|
+
]
|
|
13
|
+
keywords = [
|
|
14
|
+
"payfurl",
|
|
15
|
+
"payments",
|
|
16
|
+
"payment orchestration",
|
|
17
|
+
"sdk",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 5 - Production/Stable",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Office/Business :: Financial",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "payFURL"
|
|
33
|
+
email = "support@payfurl.com"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://payfurl.com"
|
|
37
|
+
Documentation = "https://docs.payfurl.com"
|
|
38
|
+
Source = "https://github.com/payfurl/pythonsdk"
|
|
39
|
+
Changelog = "https://github.com/payfurl/pythonsdk/blob/main/CHANGELOG.md"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"mypy>=1.18",
|
|
44
|
+
"pytest>=8",
|
|
45
|
+
"respx>=0.22",
|
|
46
|
+
"ruff>=0.14",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.9,<0.14"]
|
|
51
|
+
build-backend = "uv_build"
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 120
|
|
55
|
+
target-version = "py311"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = [
|
|
59
|
+
"E",
|
|
60
|
+
"F",
|
|
61
|
+
"W",
|
|
62
|
+
"I",
|
|
63
|
+
"UP",
|
|
64
|
+
"B",
|
|
65
|
+
"SIM",
|
|
66
|
+
"RUF",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
strict = true
|
|
71
|
+
plugins = ["pydantic.mypy"]
|
|
72
|
+
files = [
|
|
73
|
+
"src",
|
|
74
|
+
"tests",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.pydantic-mypy]
|
|
78
|
+
init_forbid_extra = true
|
|
79
|
+
init_typed = true
|
|
80
|
+
|
|
81
|
+
[tool.pytest.ini_options]
|
|
82
|
+
testpaths = ["tests"]
|