payhero 0.1.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.
- payhero-0.1.0/LICENSE +21 -0
- payhero-0.1.0/PKG-INFO +202 -0
- payhero-0.1.0/README.md +173 -0
- payhero-0.1.0/pyproject.toml +60 -0
- payhero-0.1.0/setup.cfg +4 -0
- payhero-0.1.0/src/payhero/__init__.py +89 -0
- payhero-0.1.0/src/payhero/_http.py +103 -0
- payhero-0.1.0/src/payhero/client.py +46 -0
- payhero-0.1.0/src/payhero/errors.py +39 -0
- payhero-0.1.0/src/payhero/models.py +267 -0
- payhero-0.1.0/src/payhero/services.py +121 -0
- payhero-0.1.0/src/payhero.egg-info/PKG-INFO +202 -0
- payhero-0.1.0/src/payhero.egg-info/SOURCES.txt +20 -0
- payhero-0.1.0/src/payhero.egg-info/dependency_links.txt +1 -0
- payhero-0.1.0/src/payhero.egg-info/requires.txt +8 -0
- payhero-0.1.0/src/payhero.egg-info/top_level.txt +1 -0
- payhero-0.1.0/tests/test_client.py +47 -0
- payhero-0.1.0/tests/test_errors.py +58 -0
- payhero-0.1.0/tests/test_global.py +147 -0
- payhero-0.1.0/tests/test_kenya.py +116 -0
- payhero-0.1.0/tests/test_models.py +92 -0
- payhero-0.1.0/tests/test_teams.py +127 -0
payhero-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PayHero and contributors
|
|
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.
|
payhero-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: payhero
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the PayHero Africa API — collect and disburse money across Africa.
|
|
5
|
+
Author: PayHero
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://payhero.africa
|
|
8
|
+
Project-URL: Documentation, https://docs.payhero.africa
|
|
9
|
+
Project-URL: Repository, https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: payhero,payments,africa,mpesa,mobile-money,collections,payouts
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.25
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
26
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
27
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# payhero (Python SDK)
|
|
31
|
+
|
|
32
|
+
Official Python SDK for the **PayHero Africa API** — collect and disburse
|
|
33
|
+
money across Africa over a single unified API (mobile money, bank and card
|
|
34
|
+
rails).
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install payhero
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Getting started
|
|
41
|
+
|
|
42
|
+
Generate an API key (username + password) on your PayHero dashboard.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from payhero import (
|
|
46
|
+
Client,
|
|
47
|
+
Customer,
|
|
48
|
+
GlobalPaymentRequest,
|
|
49
|
+
PaymentConfig,
|
|
50
|
+
ProviderConfig,
|
|
51
|
+
REQUEST_TYPE_PAYMENT,
|
|
52
|
+
CHANNEL_MOMO,
|
|
53
|
+
VendorConfig,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
client = Client(username="your-username", password="your-password")
|
|
57
|
+
# or: client = Client.from_env() # PH_API_USERNAME / PH_API_PASSWORD / PH_BASE_URL
|
|
58
|
+
|
|
59
|
+
# 1. Discover routing details for the customer's country.
|
|
60
|
+
discovery = client.global_.discovery("KE")
|
|
61
|
+
network = discovery["provider_networks"]["m-pesa"][0]
|
|
62
|
+
|
|
63
|
+
# 2. Create a collection (pay-in).
|
|
64
|
+
resp = client.global_.create_payment(GlobalPaymentRequest(
|
|
65
|
+
request_type=REQUEST_TYPE_PAYMENT,
|
|
66
|
+
transaction_channel=CHANNEL_MOMO,
|
|
67
|
+
provider="yellowcard",
|
|
68
|
+
amount=5500,
|
|
69
|
+
currency="KES",
|
|
70
|
+
country="KE",
|
|
71
|
+
customer=Customer(
|
|
72
|
+
first_name="John",
|
|
73
|
+
last_name="Doe",
|
|
74
|
+
email="john.doe@example.com",
|
|
75
|
+
phone="+254712345678",
|
|
76
|
+
country="KE",
|
|
77
|
+
),
|
|
78
|
+
vendor_config=VendorConfig(vendor_id=63),
|
|
79
|
+
provider_config=ProviderConfig.from_network(network),
|
|
80
|
+
payment_config=PaymentConfig(
|
|
81
|
+
account_number="+254712345678",
|
|
82
|
+
callback_url="https://your-system.com/webhooks/payhero",
|
|
83
|
+
),
|
|
84
|
+
))
|
|
85
|
+
print(resp)
|
|
86
|
+
# {"status_code": "200", "merchant_reference": "9FD194041588.iI", ...}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A success response only means the request was **accepted** — the final result
|
|
90
|
+
arrives on your `callback_url`.
|
|
91
|
+
|
|
92
|
+
## API surface
|
|
93
|
+
|
|
94
|
+
### `client.global_` — unified `/api/global` payments
|
|
95
|
+
|
|
96
|
+
| Method | Endpoint | Description |
|
|
97
|
+
| ------ | -------- | ----------- |
|
|
98
|
+
| `discovery(country)` | `GET /api/global/discovery/payment-world/country` | Rails, providers & networks per country |
|
|
99
|
+
| `create_payment(request)` | `POST /api/global/payments` | Unified pay-in (`REQUEST_TYPE_PAYMENT`) / pay-out (`REQUEST_TYPE_WITHDRAWAL`) |
|
|
100
|
+
| `transaction_status(request_id)` | `POST /api/global/transaction-status` | Poll a transaction by request ID |
|
|
101
|
+
| `balance(account_id)` | `GET /api/global/accounts/{id}/balance` | Account wallet balance |
|
|
102
|
+
| `convert_currency(from, to)` | `POST /api/currency/convert` | Currency conversion rates |
|
|
103
|
+
|
|
104
|
+
Global payouts use the same request shape as collections with
|
|
105
|
+
`request_type=REQUEST_TYPE_WITHDRAWAL`; the destination goes in
|
|
106
|
+
`payment_config.account_number`. For some corridors (TZ, ZM, NG, …) add the
|
|
107
|
+
funds-owner identity via `Customer(id_type="national_id", id_number="...")`.
|
|
108
|
+
Route part of a payout to savings with `payment_split=PaymentSplit(amount_to_save=100)`.
|
|
109
|
+
|
|
110
|
+
### `client.kenya` — legacy V1 endpoints
|
|
111
|
+
|
|
112
|
+
| Method | Endpoint | Description |
|
|
113
|
+
| ------ | -------- | ----------- |
|
|
114
|
+
| `create_collection(request)` | `POST /api/v2/payments` | Channel-based (`channel_id`) or wallet-based (`network_code`) collections; `is_offline=True` + `merchant_fee` for offline Paybill collections |
|
|
115
|
+
| `list_payment_channels(page)` | `GET /api/v2/payment_channels` | Your external bank/Till/Paybill/wallet channels |
|
|
116
|
+
| `withdraw(request)` | `POST /api/v2/withdraw` | Payouts to phone (`RECIPIENT_MSISDN`) or Paybill/Till (`RECIPIENT_PAYBILL`) |
|
|
117
|
+
|
|
118
|
+
Kenya collection example:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
col = client.kenya.create_collection(CollectionRequest(
|
|
122
|
+
amount=10,
|
|
123
|
+
phone_number="0712345678",
|
|
124
|
+
provider="m-pesa",
|
|
125
|
+
channel_id=8949, # from list_payment_channels (settle to your channel)
|
|
126
|
+
account_id=63,
|
|
127
|
+
external_reference="test_ext",
|
|
128
|
+
callback_url="https://your-system.com/webhooks/payhero",
|
|
129
|
+
))
|
|
130
|
+
print(col["CheckoutRequestID"]) # 29119 — offline: use as Paybill account ref
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Kenya payout example:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
wd = client.kenya.withdraw(WithdrawRequest(
|
|
137
|
+
account_id=63,
|
|
138
|
+
amount=10,
|
|
139
|
+
phone_number="0712345678",
|
|
140
|
+
account_number="0712345678",
|
|
141
|
+
network_code="63902",
|
|
142
|
+
channel=DESTINATION_PHONE,
|
|
143
|
+
recipient_type=RECIPIENT_MSISDN,
|
|
144
|
+
))
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `client.teams` — multi-tenancy
|
|
148
|
+
|
|
149
|
+
Teams are isolated Accounts with their own wallet, payment channels, KYC and
|
|
150
|
+
members — all managed through one organization's API key.
|
|
151
|
+
|
|
152
|
+
| Method | Endpoint | Description |
|
|
153
|
+
| ------ | -------- | ----------- |
|
|
154
|
+
| `user_profile(user_id)` | `GET /api/v2/user_profile/{user_id}` | Discover your Teams, roles & organizations |
|
|
155
|
+
| `create_account(request)` | `POST /api/v2/accounts` | Provision a Team (wallet created automatically) |
|
|
156
|
+
| `get_account(account_id)` | `GET /api/v2/account/{account_id}` | Fetch a Team |
|
|
157
|
+
| `update_account(request)` | `PUT /api/v2/accounts` | Update Team settings |
|
|
158
|
+
| `delete_account(account_id)` | `DELETE /api/v2/accounts/{id}` | Remove a Team (destructive) |
|
|
159
|
+
| `invite_member(request)` | `POST /api/v2/account_invites` | Invite a user into a Team |
|
|
160
|
+
| `remove_member(account_id, user_id)` | `DELETE /api/v2/account_invites` | Remove a member |
|
|
161
|
+
| `submit_kyc(request)` | `POST /api/v2/account_kycs` | Submit Team KYC |
|
|
162
|
+
| `update_kyc(kyc_id, request)` | `PUT /api/v2/account_kycs/{id}` | Amend a KYC submission |
|
|
163
|
+
|
|
164
|
+
## Errors
|
|
165
|
+
|
|
166
|
+
Non-2xx responses raise `payhero.ApiError`:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from payhero import ApiError
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
client.global_.create_payment(req)
|
|
173
|
+
except ApiError as e:
|
|
174
|
+
print(e.status_code, e.error_code, e.error_message)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `400 invalid_argument` — permanent rejection; fix the input before retrying.
|
|
178
|
+
- `401` — missing or wrong Basic-auth credentials.
|
|
179
|
+
- Network/5xx errors can be retried with backoff using the same
|
|
180
|
+
`external_reference` on your side (pass a custom `requests.Session` with a
|
|
181
|
+
`urllib3.Retry` adapter if you want automatic retries).
|
|
182
|
+
|
|
183
|
+
## KYC tiers
|
|
184
|
+
|
|
185
|
+
Payouts are unrestricted once KYC is complete. Collections unlock progressively:
|
|
186
|
+
Level 1–2 first-party only (own number), Level 3 third-party Kenya-only,
|
|
187
|
+
Level 4 global collections across all supported countries.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
python -m venv .venv && source .venv/bin/activate
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
pytest tests/
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The test suite mocks HTTP with [`responses`](https://github.com/getsentry/responses) —
|
|
198
|
+
no network or credentials required.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
payhero-0.1.0/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# payhero (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the **PayHero Africa API** — collect and disburse
|
|
4
|
+
money across Africa over a single unified API (mobile money, bank and card
|
|
5
|
+
rails).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install payhero
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Getting started
|
|
12
|
+
|
|
13
|
+
Generate an API key (username + password) on your PayHero dashboard.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from payhero import (
|
|
17
|
+
Client,
|
|
18
|
+
Customer,
|
|
19
|
+
GlobalPaymentRequest,
|
|
20
|
+
PaymentConfig,
|
|
21
|
+
ProviderConfig,
|
|
22
|
+
REQUEST_TYPE_PAYMENT,
|
|
23
|
+
CHANNEL_MOMO,
|
|
24
|
+
VendorConfig,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
client = Client(username="your-username", password="your-password")
|
|
28
|
+
# or: client = Client.from_env() # PH_API_USERNAME / PH_API_PASSWORD / PH_BASE_URL
|
|
29
|
+
|
|
30
|
+
# 1. Discover routing details for the customer's country.
|
|
31
|
+
discovery = client.global_.discovery("KE")
|
|
32
|
+
network = discovery["provider_networks"]["m-pesa"][0]
|
|
33
|
+
|
|
34
|
+
# 2. Create a collection (pay-in).
|
|
35
|
+
resp = client.global_.create_payment(GlobalPaymentRequest(
|
|
36
|
+
request_type=REQUEST_TYPE_PAYMENT,
|
|
37
|
+
transaction_channel=CHANNEL_MOMO,
|
|
38
|
+
provider="yellowcard",
|
|
39
|
+
amount=5500,
|
|
40
|
+
currency="KES",
|
|
41
|
+
country="KE",
|
|
42
|
+
customer=Customer(
|
|
43
|
+
first_name="John",
|
|
44
|
+
last_name="Doe",
|
|
45
|
+
email="john.doe@example.com",
|
|
46
|
+
phone="+254712345678",
|
|
47
|
+
country="KE",
|
|
48
|
+
),
|
|
49
|
+
vendor_config=VendorConfig(vendor_id=63),
|
|
50
|
+
provider_config=ProviderConfig.from_network(network),
|
|
51
|
+
payment_config=PaymentConfig(
|
|
52
|
+
account_number="+254712345678",
|
|
53
|
+
callback_url="https://your-system.com/webhooks/payhero",
|
|
54
|
+
),
|
|
55
|
+
))
|
|
56
|
+
print(resp)
|
|
57
|
+
# {"status_code": "200", "merchant_reference": "9FD194041588.iI", ...}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A success response only means the request was **accepted** — the final result
|
|
61
|
+
arrives on your `callback_url`.
|
|
62
|
+
|
|
63
|
+
## API surface
|
|
64
|
+
|
|
65
|
+
### `client.global_` — unified `/api/global` payments
|
|
66
|
+
|
|
67
|
+
| Method | Endpoint | Description |
|
|
68
|
+
| ------ | -------- | ----------- |
|
|
69
|
+
| `discovery(country)` | `GET /api/global/discovery/payment-world/country` | Rails, providers & networks per country |
|
|
70
|
+
| `create_payment(request)` | `POST /api/global/payments` | Unified pay-in (`REQUEST_TYPE_PAYMENT`) / pay-out (`REQUEST_TYPE_WITHDRAWAL`) |
|
|
71
|
+
| `transaction_status(request_id)` | `POST /api/global/transaction-status` | Poll a transaction by request ID |
|
|
72
|
+
| `balance(account_id)` | `GET /api/global/accounts/{id}/balance` | Account wallet balance |
|
|
73
|
+
| `convert_currency(from, to)` | `POST /api/currency/convert` | Currency conversion rates |
|
|
74
|
+
|
|
75
|
+
Global payouts use the same request shape as collections with
|
|
76
|
+
`request_type=REQUEST_TYPE_WITHDRAWAL`; the destination goes in
|
|
77
|
+
`payment_config.account_number`. For some corridors (TZ, ZM, NG, …) add the
|
|
78
|
+
funds-owner identity via `Customer(id_type="national_id", id_number="...")`.
|
|
79
|
+
Route part of a payout to savings with `payment_split=PaymentSplit(amount_to_save=100)`.
|
|
80
|
+
|
|
81
|
+
### `client.kenya` — legacy V1 endpoints
|
|
82
|
+
|
|
83
|
+
| Method | Endpoint | Description |
|
|
84
|
+
| ------ | -------- | ----------- |
|
|
85
|
+
| `create_collection(request)` | `POST /api/v2/payments` | Channel-based (`channel_id`) or wallet-based (`network_code`) collections; `is_offline=True` + `merchant_fee` for offline Paybill collections |
|
|
86
|
+
| `list_payment_channels(page)` | `GET /api/v2/payment_channels` | Your external bank/Till/Paybill/wallet channels |
|
|
87
|
+
| `withdraw(request)` | `POST /api/v2/withdraw` | Payouts to phone (`RECIPIENT_MSISDN`) or Paybill/Till (`RECIPIENT_PAYBILL`) |
|
|
88
|
+
|
|
89
|
+
Kenya collection example:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
col = client.kenya.create_collection(CollectionRequest(
|
|
93
|
+
amount=10,
|
|
94
|
+
phone_number="0712345678",
|
|
95
|
+
provider="m-pesa",
|
|
96
|
+
channel_id=8949, # from list_payment_channels (settle to your channel)
|
|
97
|
+
account_id=63,
|
|
98
|
+
external_reference="test_ext",
|
|
99
|
+
callback_url="https://your-system.com/webhooks/payhero",
|
|
100
|
+
))
|
|
101
|
+
print(col["CheckoutRequestID"]) # 29119 — offline: use as Paybill account ref
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Kenya payout example:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
wd = client.kenya.withdraw(WithdrawRequest(
|
|
108
|
+
account_id=63,
|
|
109
|
+
amount=10,
|
|
110
|
+
phone_number="0712345678",
|
|
111
|
+
account_number="0712345678",
|
|
112
|
+
network_code="63902",
|
|
113
|
+
channel=DESTINATION_PHONE,
|
|
114
|
+
recipient_type=RECIPIENT_MSISDN,
|
|
115
|
+
))
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `client.teams` — multi-tenancy
|
|
119
|
+
|
|
120
|
+
Teams are isolated Accounts with their own wallet, payment channels, KYC and
|
|
121
|
+
members — all managed through one organization's API key.
|
|
122
|
+
|
|
123
|
+
| Method | Endpoint | Description |
|
|
124
|
+
| ------ | -------- | ----------- |
|
|
125
|
+
| `user_profile(user_id)` | `GET /api/v2/user_profile/{user_id}` | Discover your Teams, roles & organizations |
|
|
126
|
+
| `create_account(request)` | `POST /api/v2/accounts` | Provision a Team (wallet created automatically) |
|
|
127
|
+
| `get_account(account_id)` | `GET /api/v2/account/{account_id}` | Fetch a Team |
|
|
128
|
+
| `update_account(request)` | `PUT /api/v2/accounts` | Update Team settings |
|
|
129
|
+
| `delete_account(account_id)` | `DELETE /api/v2/accounts/{id}` | Remove a Team (destructive) |
|
|
130
|
+
| `invite_member(request)` | `POST /api/v2/account_invites` | Invite a user into a Team |
|
|
131
|
+
| `remove_member(account_id, user_id)` | `DELETE /api/v2/account_invites` | Remove a member |
|
|
132
|
+
| `submit_kyc(request)` | `POST /api/v2/account_kycs` | Submit Team KYC |
|
|
133
|
+
| `update_kyc(kyc_id, request)` | `PUT /api/v2/account_kycs/{id}` | Amend a KYC submission |
|
|
134
|
+
|
|
135
|
+
## Errors
|
|
136
|
+
|
|
137
|
+
Non-2xx responses raise `payhero.ApiError`:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from payhero import ApiError
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
client.global_.create_payment(req)
|
|
144
|
+
except ApiError as e:
|
|
145
|
+
print(e.status_code, e.error_code, e.error_message)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- `400 invalid_argument` — permanent rejection; fix the input before retrying.
|
|
149
|
+
- `401` — missing or wrong Basic-auth credentials.
|
|
150
|
+
- Network/5xx errors can be retried with backoff using the same
|
|
151
|
+
`external_reference` on your side (pass a custom `requests.Session` with a
|
|
152
|
+
`urllib3.Retry` adapter if you want automatic retries).
|
|
153
|
+
|
|
154
|
+
## KYC tiers
|
|
155
|
+
|
|
156
|
+
Payouts are unrestricted once KYC is complete. Collections unlock progressively:
|
|
157
|
+
Level 1–2 first-party only (own number), Level 3 third-party Kenya-only,
|
|
158
|
+
Level 4 global collections across all supported countries.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
python -m venv .venv && source .venv/bin/activate
|
|
164
|
+
pip install -e ".[dev]"
|
|
165
|
+
pytest tests/
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The test suite mocks HTTP with [`responses`](https://github.com/getsentry/responses) —
|
|
169
|
+
no network or credentials required.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "payhero"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the PayHero Africa API — collect and disburse money across Africa."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "PayHero" }]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
dependencies = ["requests>=2.25"]
|
|
14
|
+
keywords = ["payhero", "payments", "africa", "mpesa", "mobile-money", "collections", "payouts"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Office/Business :: Financial",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://payhero.africa"
|
|
25
|
+
Documentation = "https://docs.payhero.africa"
|
|
26
|
+
Repository = "https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk"
|
|
27
|
+
Issues = "https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/issues"
|
|
28
|
+
Changelog = "https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/blob/main/CHANGELOG.md"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=7.0",
|
|
33
|
+
"responses>=0.23",
|
|
34
|
+
"ruff>=0.4",
|
|
35
|
+
"build>=1.0",
|
|
36
|
+
"twine>=5.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.dynamic]
|
|
40
|
+
version = { attr = "payhero.__version__" }
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
addopts = "-q"
|
|
48
|
+
pythonpath = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py38"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
# Pinned explicitly so a newer ruff in CI cannot widen the rule set under us.
|
|
56
|
+
select = ["E", "F", "W", "I", "UP", "B", "RUF"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.isort]
|
|
59
|
+
known-first-party = ["payhero"]
|
|
60
|
+
known-local-folder = ["conftest"]
|
payhero-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""payhero — the official Python SDK for the PayHero Africa API.
|
|
2
|
+
|
|
3
|
+
Collect and disburse money across Africa over a single unified API (mobile
|
|
4
|
+
money, bank and card rails). All requests use HTTP Basic auth with the
|
|
5
|
+
username/password pair from your PayHero dashboard API key.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from payhero import Client, GlobalPaymentRequest, Customer, ...
|
|
9
|
+
|
|
10
|
+
client = Client(username="...", password="...")
|
|
11
|
+
discovery = client.global_.discovery("KE")
|
|
12
|
+
network = discovery["provider_networks"]["m-pesa"][0]
|
|
13
|
+
|
|
14
|
+
resp = client.global_.create_payment(GlobalPaymentRequest(
|
|
15
|
+
request_type=REQUEST_TYPE_PAYMENT,
|
|
16
|
+
transaction_channel=CHANNEL_MOMO,
|
|
17
|
+
provider="yellowcard",
|
|
18
|
+
amount=5500,
|
|
19
|
+
currency="KES",
|
|
20
|
+
country="KE",
|
|
21
|
+
customer=Customer(first_name="John", last_name="Doe", phone="+254712345678"),
|
|
22
|
+
vendor_config=VendorConfig(vendor_id=63),
|
|
23
|
+
provider_config=ProviderConfig.from_network(network),
|
|
24
|
+
payment_config=PaymentConfig(account_number="+254712345678"),
|
|
25
|
+
))
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .client import Client
|
|
29
|
+
from .errors import ApiError, PayHeroError
|
|
30
|
+
from .models import (
|
|
31
|
+
CHANNEL_BANK,
|
|
32
|
+
CHANNEL_CARD,
|
|
33
|
+
CHANNEL_MOMO,
|
|
34
|
+
DESTINATION_PAYBILL,
|
|
35
|
+
DESTINATION_PHONE,
|
|
36
|
+
DESTINATION_TILL,
|
|
37
|
+
RECIPIENT_MSISDN,
|
|
38
|
+
RECIPIENT_PAYBILL,
|
|
39
|
+
REQUEST_TYPE_PAYMENT,
|
|
40
|
+
REQUEST_TYPE_WITHDRAWAL,
|
|
41
|
+
AccountInformation,
|
|
42
|
+
AccountKYCRequest,
|
|
43
|
+
CollectionRequest,
|
|
44
|
+
ContactInformation,
|
|
45
|
+
CreateAccountRequest,
|
|
46
|
+
Customer,
|
|
47
|
+
EntityInformation,
|
|
48
|
+
GlobalPaymentRequest,
|
|
49
|
+
InviteMemberRequest,
|
|
50
|
+
PaymentConfig,
|
|
51
|
+
PaymentSplit,
|
|
52
|
+
ProviderConfig,
|
|
53
|
+
UpdateAccountRequest,
|
|
54
|
+
VendorConfig,
|
|
55
|
+
WithdrawRequest,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__version__ = "0.1.0"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"CHANNEL_BANK",
|
|
62
|
+
"CHANNEL_CARD",
|
|
63
|
+
"CHANNEL_MOMO",
|
|
64
|
+
"DESTINATION_PAYBILL",
|
|
65
|
+
"DESTINATION_PHONE",
|
|
66
|
+
"DESTINATION_TILL",
|
|
67
|
+
"RECIPIENT_MSISDN",
|
|
68
|
+
"RECIPIENT_PAYBILL",
|
|
69
|
+
"REQUEST_TYPE_PAYMENT",
|
|
70
|
+
"REQUEST_TYPE_WITHDRAWAL",
|
|
71
|
+
"AccountInformation",
|
|
72
|
+
"AccountKYCRequest",
|
|
73
|
+
"ApiError",
|
|
74
|
+
"Client",
|
|
75
|
+
"CollectionRequest",
|
|
76
|
+
"ContactInformation",
|
|
77
|
+
"CreateAccountRequest",
|
|
78
|
+
"Customer",
|
|
79
|
+
"EntityInformation",
|
|
80
|
+
"GlobalPaymentRequest",
|
|
81
|
+
"InviteMemberRequest",
|
|
82
|
+
"PayHeroError",
|
|
83
|
+
"PaymentConfig",
|
|
84
|
+
"PaymentSplit",
|
|
85
|
+
"ProviderConfig",
|
|
86
|
+
"UpdateAccountRequest",
|
|
87
|
+
"VendorConfig",
|
|
88
|
+
"WithdrawRequest",
|
|
89
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""HTTP transport for the PayHero Africa API (requests + Basic auth)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .errors import ApiError
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.payhero.africa"
|
|
13
|
+
DEFAULT_TIMEOUT = 60.0 # seconds
|
|
14
|
+
DEFAULT_MAX_RETRIES = 0
|
|
15
|
+
RETRY_BASE_DELAY = 0.5 # seconds; doubled per attempt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Http:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
username: str,
|
|
22
|
+
password: str,
|
|
23
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
24
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
25
|
+
session: requests.Session | None = None,
|
|
26
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
27
|
+
) -> None:
|
|
28
|
+
if not username or not password:
|
|
29
|
+
raise ValueError("PayHero requires an API username and password")
|
|
30
|
+
self._auth = (username, password)
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self.session = session or requests.Session()
|
|
34
|
+
self.max_retries = max(0, max_retries)
|
|
35
|
+
|
|
36
|
+
def request(
|
|
37
|
+
self,
|
|
38
|
+
method: str,
|
|
39
|
+
path: str,
|
|
40
|
+
params: dict[str, Any] | None = None,
|
|
41
|
+
json_body: Any = None,
|
|
42
|
+
) -> Any:
|
|
43
|
+
body = None
|
|
44
|
+
if isinstance(json_body, dict):
|
|
45
|
+
body = {k: v for k, v in json_body.items() if v is not None}
|
|
46
|
+
else:
|
|
47
|
+
to_dict = getattr(json_body, "to_dict", None)
|
|
48
|
+
body = to_dict() if callable(to_dict) else json_body
|
|
49
|
+
|
|
50
|
+
query = {k: v for k, v in (params or {}).items() if v is not None}
|
|
51
|
+
url = self.base_url + path
|
|
52
|
+
last_response: requests.Response | None = None
|
|
53
|
+
|
|
54
|
+
for attempt in range(self.max_retries + 1):
|
|
55
|
+
response = self.session.request(
|
|
56
|
+
method,
|
|
57
|
+
url,
|
|
58
|
+
params=query,
|
|
59
|
+
json=body,
|
|
60
|
+
auth=self._auth,
|
|
61
|
+
timeout=self.timeout,
|
|
62
|
+
headers={"Accept": "application/json"},
|
|
63
|
+
)
|
|
64
|
+
if not self._should_retry(response):
|
|
65
|
+
last_response = response
|
|
66
|
+
break
|
|
67
|
+
last_response = response
|
|
68
|
+
if attempt < self.max_retries:
|
|
69
|
+
time.sleep(RETRY_BASE_DELAY * (2**attempt))
|
|
70
|
+
|
|
71
|
+
assert last_response is not None # loop always runs at least once
|
|
72
|
+
return self._parse(last_response)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _should_retry(response: requests.Response) -> bool:
|
|
76
|
+
"""Retry network-level failures and transient statuses (429/5xx)."""
|
|
77
|
+
return response.status_code == 429 or response.status_code >= 500
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _parse(response: requests.Response) -> Any:
|
|
81
|
+
try:
|
|
82
|
+
payload = response.json()
|
|
83
|
+
except ValueError:
|
|
84
|
+
payload = None
|
|
85
|
+
|
|
86
|
+
if not response.ok:
|
|
87
|
+
error_code = ""
|
|
88
|
+
message = response.reason or "request failed"
|
|
89
|
+
status_code = response.status_code
|
|
90
|
+
if isinstance(payload, dict):
|
|
91
|
+
error_code = payload.get("error_code", "") or ""
|
|
92
|
+
message = payload.get("error_message", message) or message
|
|
93
|
+
raw_status = payload.get("status_code")
|
|
94
|
+
if isinstance(raw_status, int):
|
|
95
|
+
status_code = raw_status
|
|
96
|
+
raise ApiError(
|
|
97
|
+
status_code=status_code,
|
|
98
|
+
error_code=error_code,
|
|
99
|
+
error_message=message,
|
|
100
|
+
http_status=response.status_code,
|
|
101
|
+
body=response.text,
|
|
102
|
+
)
|
|
103
|
+
return payload
|