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 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
@@ -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"]