sendafrica 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.
- sendafrica-1.0.0/PKG-INFO +621 -0
- sendafrica-1.0.0/README.md +604 -0
- sendafrica-1.0.0/pyproject.toml +26 -0
- sendafrica-1.0.0/sendafrica/__init__.py +59 -0
- sendafrica-1.0.0/sendafrica/auth.py +21 -0
- sendafrica-1.0.0/sendafrica/cli.py +51 -0
- sendafrica-1.0.0/sendafrica/client.py +94 -0
- sendafrica-1.0.0/sendafrica/exceptions.py +101 -0
- sendafrica-1.0.0/sendafrica/http.py +270 -0
- sendafrica-1.0.0/sendafrica/models.py +129 -0
- sendafrica-1.0.0/sendafrica/resources/__init__.py +11 -0
- sendafrica-1.0.0/sendafrica/resources/credits.py +29 -0
- sendafrica-1.0.0/sendafrica/resources/payments.py +51 -0
- sendafrica-1.0.0/sendafrica/resources/sms.py +70 -0
- sendafrica-1.0.0/sendafrica/resources/webhooks.py +57 -0
- sendafrica-1.0.0/sendafrica/utils/__init__.py +12 -0
- sendafrica-1.0.0/sendafrica/utils/phone.py +64 -0
- sendafrica-1.0.0/sendafrica/utils/sms.py +66 -0
- sendafrica-1.0.0/sendafrica/utils/validators.py +35 -0
- sendafrica-1.0.0/sendafrica.egg-info/PKG-INFO +621 -0
- sendafrica-1.0.0/sendafrica.egg-info/SOURCES.txt +25 -0
- sendafrica-1.0.0/sendafrica.egg-info/dependency_links.txt +1 -0
- sendafrica-1.0.0/sendafrica.egg-info/entry_points.txt +2 -0
- sendafrica-1.0.0/sendafrica.egg-info/requires.txt +9 -0
- sendafrica-1.0.0/sendafrica.egg-info/top_level.txt +1 -0
- sendafrica-1.0.0/setup.cfg +4 -0
- sendafrica-1.0.0/tests/test_local.py +87 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sendafrica
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the SendAfrica SMS Infrastructure-as-a-Service API
|
|
5
|
+
Author: CamelTech
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: sms,sendafrica,africa,tanzania,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: requests>=2.28
|
|
11
|
+
Provides-Extra: async
|
|
12
|
+
Requires-Dist: httpx>=0.24; extra == "async"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
16
|
+
Requires-Dist: responses; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# SendAfrica Python SDK
|
|
19
|
+
|
|
20
|
+
Official Python client for the SendAfrica SMS Infrastructure-as-a-Service
|
|
21
|
+
API. Designed to feel like Stripe's Python library: a couple of lines to
|
|
22
|
+
send your first message, enough control (retries, timeouts, async, typed
|
|
23
|
+
errors) to run in production.
|
|
24
|
+
|
|
25
|
+
- **SMS** — send single or bulk messages, with local phone/encoding
|
|
26
|
+
validation before any network call
|
|
27
|
+
- **Credits** — check balance, list transaction history
|
|
28
|
+
- **Payments** — top up credits pay-as-you-go, for any amount
|
|
29
|
+
- **Webhooks** — signature-verified event parsing (see the note in
|
|
30
|
+
[Webhooks](#webhooks) about current backend support)
|
|
31
|
+
- Sync client (`requests`) and async client (`httpx`, optional)
|
|
32
|
+
- A typed exception hierarchy so you can catch exactly the failure you care
|
|
33
|
+
about (insufficient credits, rate limit, bad phone number, ...)
|
|
34
|
+
- A small `sendafrica` CLI for one-off sends from a shell/CI script
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Table of contents
|
|
39
|
+
|
|
40
|
+
- [Install](#install)
|
|
41
|
+
- [Authentication](#authentication)
|
|
42
|
+
- [Quickstart](#quickstart)
|
|
43
|
+
- [Configuration](#configuration)
|
|
44
|
+
- [Resources](#resources)
|
|
45
|
+
- [SMS](#sms)
|
|
46
|
+
- [Credits](#credits)
|
|
47
|
+
- [Payments](#payments)
|
|
48
|
+
- [Webhooks](#webhooks)
|
|
49
|
+
- [Full example](#full-example)
|
|
50
|
+
- [Errors](#errors)
|
|
51
|
+
- [Retries and timeouts](#retries-and-timeouts)
|
|
52
|
+
- [Response envelope (internals)](#response-envelope-internals)
|
|
53
|
+
- [CLI](#cli)
|
|
54
|
+
- [Async](#async)
|
|
55
|
+
- [FAQ / Troubleshooting](#faq--troubleshooting)
|
|
56
|
+
- [Security](#security)
|
|
57
|
+
- [Project layout](#project-layout)
|
|
58
|
+
- [Development](#development)
|
|
59
|
+
- [Roadmap](#roadmap)
|
|
60
|
+
- [License](#license)
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install sendafrica
|
|
68
|
+
|
|
69
|
+
# with the async client (adds httpx):
|
|
70
|
+
pip install "sendafrica[async]"
|
|
71
|
+
|
|
72
|
+
# for contributing (adds pytest, coverage, responses):
|
|
73
|
+
pip install "sendafrica[dev]"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Requires Python 3.9+.
|
|
77
|
+
|
|
78
|
+
## Authentication
|
|
79
|
+
|
|
80
|
+
SendAfrica uses a single API key per integration, created from the
|
|
81
|
+
dashboard (`POST /v1/auth/api-keys`, which itself requires a logged-in JWT
|
|
82
|
+
session — the API key is what your server/script uses afterwards).
|
|
83
|
+
|
|
84
|
+
Keys look like:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
SA-xxxxx
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`SA-` + 64 hex characters. (Keys issued before this format shipped are bare
|
|
91
|
+
hex with no `SA-` prefix — both keep working; the API only ever compares a
|
|
92
|
+
SHA-256 hash of whatever string you send, it doesn't parse the format.)
|
|
93
|
+
|
|
94
|
+
The key is sent as either header — the SDK uses `Authorization: Bearer`:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
Authorization: Bearer SA-xxxxxxxx...
|
|
98
|
+
X-API-Key: SA-xxxxxxxx... # equivalent, API accepts either
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**The raw key is shown exactly once**, at creation time. If you lose it,
|
|
102
|
+
delete it (`DELETE /v1/auth/api-keys/{keyId}`) and create a new one — there
|
|
103
|
+
is no recovery endpoint.
|
|
104
|
+
|
|
105
|
+
Pass it explicitly, or set it as an environment variable and construct the
|
|
106
|
+
client with no arguments:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from sendafrica import SendAfrica
|
|
110
|
+
|
|
111
|
+
client = SendAfrica(api_key="SA-xxxxx")
|
|
112
|
+
# or:
|
|
113
|
+
# export SENDAFRICA_API_KEY="SA-xxxxx"
|
|
114
|
+
# client = SendAfrica()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Resolution order (`sendafrica/auth.py`): explicit `api_key=...` argument,
|
|
118
|
+
then the `SENDAFRICA_API_KEY` environment variable, then
|
|
119
|
+
`AuthenticationError` if neither is set.
|
|
120
|
+
|
|
121
|
+
> **Never commit a real key.** Keep it in an environment variable, a
|
|
122
|
+
> secrets manager, or a `.env` file that's excluded from version control
|
|
123
|
+
> (this repo's `.gitignore` already excludes `.env*`). If a key ever leaks
|
|
124
|
+
> — a screenshot, a public repo, a log line — delete it immediately via the
|
|
125
|
+
> dashboard or `DELETE /v1/auth/api-keys/{keyId}` and issue a new one.
|
|
126
|
+
|
|
127
|
+
## Quickstart
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from sendafrica import SendAfrica
|
|
131
|
+
|
|
132
|
+
client = SendAfrica(api_key="SA-xxxxx")
|
|
133
|
+
|
|
134
|
+
result = client.sms.send(to="0712345678", message="Welcome to SendAfrica")
|
|
135
|
+
print(result.message_id, result.status, result.credits_used)
|
|
136
|
+
# SA-3f9a2b7c-1d4e-4f60-8192-a3b4c5d6e7f8 Success 1
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`to` accepts any of `0712345678`, `712345678`, `255712345678`, or
|
|
140
|
+
`+255712345678` — it's normalized to E.164 locally before the request is
|
|
141
|
+
sent. Invalid numbers raise `InvalidPhoneError` without making a network
|
|
142
|
+
call.
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
client = SendAfrica(
|
|
148
|
+
api_key="SA-xxxxx", # or omit and set SENDAFRICA_API_KEY
|
|
149
|
+
base_url="https://api.sendafrica.co/v1", # default; override for a private/staging deployment
|
|
150
|
+
timeout=10, # seconds, per request
|
|
151
|
+
max_retries=3, # retries on 429 and 500/502/503/504, plus connection errors
|
|
152
|
+
environment="production", # cosmetic label, shown in repr(client) and useful for your own logging
|
|
153
|
+
debug=False, # True logs "[sendafrica] METHOD /path attempt=N" and the response status to stdout
|
|
154
|
+
webhook_secret=None, # default secret used by client.webhooks.parse() if you don't pass one per-call
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Parameter | Type | Default | Notes |
|
|
159
|
+
|---|---|---|---|
|
|
160
|
+
| `api_key` | `str \| None` | `None` | Falls back to `SENDAFRICA_API_KEY` env var |
|
|
161
|
+
| `base_url` | `str` | `https://api.sendafrica.co/v1` | Trailing slash is stripped automatically |
|
|
162
|
+
| `timeout` | `float` | `10` | Per-request timeout in seconds |
|
|
163
|
+
| `max_retries` | `int` | `3` | Exponential backoff (`0.5 * 2^attempt`, capped at 8s), or `Retry-After` on 429 if the server sends one |
|
|
164
|
+
| `environment` | `str` | `"production"` | Not sent to the API; purely a local label |
|
|
165
|
+
| `debug` | `bool` | `False` | Prints request/response tracing to stdout |
|
|
166
|
+
| `webhook_secret` | `str \| None` | `None` | See [Webhooks](#webhooks) |
|
|
167
|
+
|
|
168
|
+
`AsyncSendAfrica` takes the same arguments (no `environment` restriction
|
|
169
|
+
either) and additionally exposes `await client.aclose()` to close the
|
|
170
|
+
underlying `httpx.AsyncClient` — call it when you're done with the client
|
|
171
|
+
(e.g. in a FastAPI shutdown handler).
|
|
172
|
+
|
|
173
|
+
## Resources
|
|
174
|
+
|
|
175
|
+
| Resource | Methods |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `client.sms` | `send`, `send_many`, `analyze` |
|
|
178
|
+
| `client.credits` | `balance`, `history` |
|
|
179
|
+
| `client.payments` | `create`, `rate` |
|
|
180
|
+
| `client.webhooks` | `parse` |
|
|
181
|
+
|
|
182
|
+
There is intentionally no `client.packages` or `client.payments.get/list`:
|
|
183
|
+
see [Payments](#payments) and [the note below](#why-no-packages-or-paymentsgetlist).
|
|
184
|
+
|
|
185
|
+
### SMS
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
result = client.sms.send(
|
|
189
|
+
to="0712345678",
|
|
190
|
+
message="Your OTP is 123456",
|
|
191
|
+
sender="MyBrand", # optional; registered sender ID, max 11 chars
|
|
192
|
+
)
|
|
193
|
+
# SMSResult(message_id="SA-...", status="Success", credits_used=1, cost="TZS 22.0000", to=None)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
| Field | Required | Notes |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `to` | yes | Any of `0712345678` / `712345678` / `255712345678` / `+255712345678`; normalized locally, invalid input raises `InvalidPhoneError` before any request |
|
|
199
|
+
| `message` | yes | Plain text. GSM-7 charset = 160 chars/segment (153 concatenated); anything outside GSM-7 (emoji, some accents) forces UCS-2 = 70 chars/segment (67 concatenated) |
|
|
200
|
+
| `sender` | no | Sender ID, max 11 characters (GSM alphanumeric sender ID convention). Must be pre-registered with SendAfrica or the send is rejected server-side. Omit to use the account/platform default |
|
|
201
|
+
|
|
202
|
+
Bulk sending loops `send()` client-side with local pacing — it does **not**
|
|
203
|
+
call the server's `POST /v1/sms/bulk` endpoint, so partial failures are
|
|
204
|
+
collected per-message instead of aborting the batch:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
results = client.sms.send_many(
|
|
208
|
+
[
|
|
209
|
+
{"to": "0711111111", "message": "Hello John"},
|
|
210
|
+
{"to": "0722222222", "message": "Hello Mary", "sender": "MyBrand"},
|
|
211
|
+
],
|
|
212
|
+
rate_limit_per_sec=10, # default; set lower if you're hitting 429s
|
|
213
|
+
)
|
|
214
|
+
print(results.sent_count, results.failed_count)
|
|
215
|
+
for failure in results.failed:
|
|
216
|
+
print(failure["index"], failure["to"], failure["error"])
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Estimate cost/segmentation before sending anything (no network call):
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
analysis = client.sms.analyze("Habari 😊")
|
|
223
|
+
# SMSAnalysis(encoding="UCS-2", characters=8, parts=1, credits=1)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`credits` is estimated as 1 credit per segment/part, matching typical SMS
|
|
227
|
+
aggregator billing — treat it as an estimate for UI display, not a
|
|
228
|
+
guarantee; the actual `credits_used` on the response from `send()` is
|
|
229
|
+
authoritative.
|
|
230
|
+
|
|
231
|
+
### Credits
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
balance = client.credits.balance()
|
|
235
|
+
# CreditBalance(account_id="...", balance=500)
|
|
236
|
+
|
|
237
|
+
history = client.credits.history(page=1, per_page=25) # per_page max is 200 server-side
|
|
238
|
+
for tx in history:
|
|
239
|
+
print(tx.type, tx.amount, tx.balance_after, tx.description)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`CreditTransaction.type` is one of `purchase`, `charge`, `refund`, `grant`,
|
|
243
|
+
`deduct`. `history()` paginates with `page`/`per_page` (not cursor-based) —
|
|
244
|
+
this mirrors the API directly rather than emulating Stripe-style
|
|
245
|
+
`limit`/`starting_after` pagination.
|
|
246
|
+
|
|
247
|
+
### Payments
|
|
248
|
+
|
|
249
|
+
Credit top-ups are **pay-as-you-go**: you choose any TZS amount (above a
|
|
250
|
+
server-configured floor) and the API converts it to credits at a tiered
|
|
251
|
+
rate — cheaper per credit at higher amounts. There is no fixed "package" to
|
|
252
|
+
pick, so there's no `package_id` anywhere in this SDK.
|
|
253
|
+
|
|
254
|
+
Check the current rate before charging a user, so your UI can show real
|
|
255
|
+
numbers instead of hardcoding them (they're configurable per environment):
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
rate = client.payments.rate()
|
|
259
|
+
print(rate.min_amount_tzs) # e.g. 1000
|
|
260
|
+
for tier in rate.tiers:
|
|
261
|
+
print(tier.max_amount_tzs, tier.rate_tzs_per_credit)
|
|
262
|
+
# 49999 35
|
|
263
|
+
# 149999 32
|
|
264
|
+
# 0 30 <- max_amount_tzs == 0 means "unbounded top tier"
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Then initiate the top-up:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
payment = client.payments.create(
|
|
271
|
+
amount=20000, # TZS; must be >= rate.min_amount_tzs
|
|
272
|
+
provider="manual", # "manual" (default, admin-confirmed) or "snippe" (mobile money, needs `phone`)
|
|
273
|
+
phone="+255712345678", # required for "snippe", ignored for "manual"
|
|
274
|
+
)
|
|
275
|
+
print(payment.status, payment.credit_amount)
|
|
276
|
+
# pending 571
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- **`provider="manual"`** stays `status="pending"` until an admin confirms
|
|
280
|
+
it — appropriate for bank transfer / manual reconciliation flows.
|
|
281
|
+
- **`provider="snippe"`** pushes a mobile-money (USSD) prompt to the
|
|
282
|
+
account's own *verified* phone number and confirms automatically via a
|
|
283
|
+
server-side webhook — you cannot specify an arbitrary phone for
|
|
284
|
+
`snippe`; the API always uses the account's verified number regardless
|
|
285
|
+
of what you pass.
|
|
286
|
+
- Amounts below `rate.min_amount_tzs`, or too small to buy even a single
|
|
287
|
+
credit at the applicable rate, raise a `ValidationError`
|
|
288
|
+
(`amount_below_minimum`) before an order is created.
|
|
289
|
+
|
|
290
|
+
#### Why no `packages` or `payments.get`/`list`
|
|
291
|
+
|
|
292
|
+
Earlier versions of this SDK had a `client.packages` resource and a
|
|
293
|
+
`payments.create(package_id=...)` flow for buying fixed credit packages.
|
|
294
|
+
That's been removed for simplicity in favor of the pay-as-you-go voucher
|
|
295
|
+
flow above — one mental model (pick an amount) instead of two (pick an
|
|
296
|
+
amount *or* pick a package). The fixed-package endpoint
|
|
297
|
+
(`POST /v1/payments/`) still exists API-side if you need it directly, but
|
|
298
|
+
this SDK only wraps the voucher flow now.
|
|
299
|
+
|
|
300
|
+
`payments.get(id)`/`payments.list()` were also removed: looking up or
|
|
301
|
+
listing individual payment orders is an admin-console feature gated on a
|
|
302
|
+
JWT with `is_admin = true` — an API key can never satisfy that check, so
|
|
303
|
+
there is no programmatic-access route for this SDK to wrap. If you need
|
|
304
|
+
order status, track it via the `id` returned from `create()` and your own
|
|
305
|
+
webhook/polling against your dashboard session, not this SDK.
|
|
306
|
+
|
|
307
|
+
### Webhooks
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
from fastapi import Request
|
|
311
|
+
|
|
312
|
+
@app.post("/webhooks/sendafrica")
|
|
313
|
+
async def webhook(request: Request):
|
|
314
|
+
event = client.webhooks.parse(
|
|
315
|
+
await request.body(),
|
|
316
|
+
signature=request.headers.get("X-SendAfrica-Signature"),
|
|
317
|
+
# or rely on webhook_secret=... passed to SendAfrica(...) at construction
|
|
318
|
+
)
|
|
319
|
+
if event.type == "sms.delivered":
|
|
320
|
+
print(event.message_id)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`parse()` verifies an HMAC-SHA256 signature over the raw body (if a
|
|
324
|
+
signature and secret are both available) using `hmac.compare_digest`, then
|
|
325
|
+
parses the JSON into a `WebhookEvent`. A mismatch raises
|
|
326
|
+
`WebhookSignatureError` — always check for that before touching the event.
|
|
327
|
+
|
|
328
|
+
> **Status: speculative.** As of this writing, the SendAfrica API does not
|
|
329
|
+
> forward signed events to a customer-supplied endpoint — the only
|
|
330
|
+
> webhooks in the API today are *inbound to SendAfrica itself* (Africa's
|
|
331
|
+
> Talking delivery-status callbacks at `/v1/sms/callback`, and the Snippe
|
|
332
|
+
> payment-confirmation webhook), neither of which uses this signature
|
|
333
|
+
> scheme. This resource exists so client code is ready the day
|
|
334
|
+
> SendAfrica ships outbound event delivery; there is nothing to point it
|
|
335
|
+
> at yet. Adjust the header name/scheme here if the eventual spec differs.
|
|
336
|
+
|
|
337
|
+
## Full example
|
|
338
|
+
|
|
339
|
+
A single script that checks the balance, tops up if it's low, and sends an
|
|
340
|
+
SMS — showing typed-error handling for the failure modes you'll actually
|
|
341
|
+
hit in production:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
from sendafrica import SendAfrica
|
|
345
|
+
from sendafrica.exceptions import (
|
|
346
|
+
InsufficientCreditsError,
|
|
347
|
+
InvalidPhoneError,
|
|
348
|
+
RateLimitError,
|
|
349
|
+
SendAfricaError,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
client = SendAfrica() # reads SENDAFRICA_API_KEY from the environment
|
|
353
|
+
|
|
354
|
+
LOW_BALANCE_THRESHOLD = 50
|
|
355
|
+
TOP_UP_AMOUNT_TZS = 20_000
|
|
356
|
+
|
|
357
|
+
balance = client.credits.balance()
|
|
358
|
+
if balance.balance < LOW_BALANCE_THRESHOLD:
|
|
359
|
+
rate = client.payments.rate()
|
|
360
|
+
if TOP_UP_AMOUNT_TZS < rate.min_amount_tzs:
|
|
361
|
+
TOP_UP_AMOUNT_TZS = rate.min_amount_tzs
|
|
362
|
+
payment = client.payments.create(amount=TOP_UP_AMOUNT_TZS, provider="manual")
|
|
363
|
+
print(f"Top-up order {payment.id} created ({payment.status}) — "
|
|
364
|
+
f"will be worth {payment.credit_amount} credits once confirmed")
|
|
365
|
+
# "manual" orders need an admin to confirm before credits land — this
|
|
366
|
+
# script doesn't block waiting for that. Poll your own systems/webhook
|
|
367
|
+
# for the actual balance change; there's no order-status GET in this SDK.
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
result = client.sms.send(
|
|
371
|
+
to="0712345678",
|
|
372
|
+
message="Your OTP is 123456",
|
|
373
|
+
sender="MyBrand",
|
|
374
|
+
)
|
|
375
|
+
print(f"Sent {result.message_id}: {result.status}, {result.credits_used} credit(s)")
|
|
376
|
+
except InvalidPhoneError as e:
|
|
377
|
+
print(f"Bad phone number, didn't even try to send: {e}")
|
|
378
|
+
except InsufficientCreditsError:
|
|
379
|
+
print("Still out of credits — the top-up above is probably still pending")
|
|
380
|
+
except RateLimitError as e:
|
|
381
|
+
# The SDK already retried internally (see Retries and timeouts below) —
|
|
382
|
+
# seeing this means it exhausted max_retries and still got a 429.
|
|
383
|
+
print(f"Still rate limited after retries; server says wait {e.retry_after}s")
|
|
384
|
+
except SendAfricaError as e:
|
|
385
|
+
print(f"Send failed: {e.message} (request_id={e.request_id})")
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Errors
|
|
389
|
+
|
|
390
|
+
All SDK errors inherit from `SendAfricaError`, which carries `.message`,
|
|
391
|
+
`.status_code`, `.request_id` (echoes the API's `request_id` for support
|
|
392
|
+
tickets), and `.response_body` (the raw decoded JSON body, if any):
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
from sendafrica.exceptions import InsufficientCreditsError
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
client.sms.send(to="0712345678", message="hello")
|
|
399
|
+
except InsufficientCreditsError:
|
|
400
|
+
print("Please buy credits")
|
|
401
|
+
except SendAfricaError as e:
|
|
402
|
+
print(e.message, e.status_code, e.request_id)
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
| Exception | HTTP status | Meaning |
|
|
406
|
+
|---|---|---|
|
|
407
|
+
| `AuthenticationError` | 401 | invalid/missing/expired API key |
|
|
408
|
+
| `ValidationError` (`InvalidPhoneError` is a subclass) | 400, 422 | bad request payload, or a phone number that failed local/server validation |
|
|
409
|
+
| `InsufficientCreditsError` | 402 | not enough SMS credits to complete the send |
|
|
410
|
+
| `NotFoundError` | 404 | resource does not exist |
|
|
411
|
+
| `RateLimitError` | 429 | too many requests; `.retry_after` (seconds) is set if the server sent a `Retry-After` header |
|
|
412
|
+
| `ServerError` | 5xx | failure on SendAfrica's side |
|
|
413
|
+
| `APIConnectionError` | — | network/timeout/DNS failure — never reached the API |
|
|
414
|
+
| `WebhookSignatureError` | — | local-only: an incoming webhook's signature didn't match |
|
|
415
|
+
|
|
416
|
+
Any other status code raises the base `SendAfricaError` directly.
|
|
417
|
+
|
|
418
|
+
## Retries and timeouts
|
|
419
|
+
|
|
420
|
+
Both `HTTPTransport` (sync, `requests`) and `AsyncHTTPTransport` (async,
|
|
421
|
+
`httpx`) share the same policy:
|
|
422
|
+
|
|
423
|
+
- Retries on `429`, `500`, `502`, `503`, `504`, and connection-level
|
|
424
|
+
errors, up to `max_retries` times (default 3).
|
|
425
|
+
- Backoff is `min(0.5 * 2^attempt, 8)` seconds between attempts, except on
|
|
426
|
+
`429` where the server's `Retry-After` header is honored exactly if
|
|
427
|
+
present.
|
|
428
|
+
- Every request carries a fresh `X-Request-Id` header and the response's
|
|
429
|
+
`X-Request-Id` is attached to any raised exception — include it when
|
|
430
|
+
filing a support ticket.
|
|
431
|
+
- Retries are transparent to your code — you get back a result or a
|
|
432
|
+
raised `SendAfricaError`/`APIConnectionError` after retries are
|
|
433
|
+
exhausted, never a partial/ambiguous state.
|
|
434
|
+
|
|
435
|
+
## Response envelope (internals)
|
|
436
|
+
|
|
437
|
+
Every SendAfrica API response is wrapped in the same envelope:
|
|
438
|
+
|
|
439
|
+
```json
|
|
440
|
+
{"success": true, "data": {...}, "error": null, "meta": null, "request_id": "...", "timestamp": "..."}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`HTTPTransport`/`AsyncHTTPTransport` unwrap `data` once, centrally, before
|
|
444
|
+
handing it to resource classes — you never see the envelope yourself
|
|
445
|
+
unless you're reading `exception.response_body` after a failure (which
|
|
446
|
+
contains the *full*, un-unwrapped error envelope: `{success: false, error:
|
|
447
|
+
{code, message, details}}`).
|
|
448
|
+
|
|
449
|
+
## CLI
|
|
450
|
+
|
|
451
|
+
Installed as the `sendafrica` console script:
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
export SENDAFRICA_API_KEY="SA-xxxxx"
|
|
455
|
+
|
|
456
|
+
sendafrica balance
|
|
457
|
+
# Credits: 500
|
|
458
|
+
|
|
459
|
+
sendafrica sms send --to 0712345678 --message "Hello"
|
|
460
|
+
# Sent: SA-3f9a2b7c-... (status=Success, credits=1)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Currently supports `balance` and `sms send --to ... --message ... [--sender ...]`.
|
|
464
|
+
It's a thin wrapper (`sendafrica/cli.py`) over the same `SendAfrica` client
|
|
465
|
+
— useful for shell scripts, cron jobs, or CI smoke tests, not a full
|
|
466
|
+
replacement for the dashboard.
|
|
467
|
+
|
|
468
|
+
## Async
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
from sendafrica import AsyncSendAfrica
|
|
472
|
+
|
|
473
|
+
client = AsyncSendAfrica(api_key="SA-xxxxx")
|
|
474
|
+
result = await client.sms.send(to="0712345678", message="Hello")
|
|
475
|
+
await client.aclose()
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Requires the `async` extra (`pip install "sendafrica[async]"`), which adds
|
|
479
|
+
`httpx`. `AsyncSendAfrica` builds its own `httpx.AsyncClient` lazily on
|
|
480
|
+
first request and reuses it across calls — always call `await
|
|
481
|
+
client.aclose()` when you're done (e.g. in a FastAPI lifespan/shutdown
|
|
482
|
+
hook) so the underlying connection pool is released.
|
|
483
|
+
|
|
484
|
+
> **Status:** the async transport (`AsyncHTTPTransport`, httpx-based, with
|
|
485
|
+
> the same retry/backoff policy as the sync client) is fully implemented.
|
|
486
|
+
> Resource classes (`SMSResource`, `CreditsResource`, etc.) are shared
|
|
487
|
+
> between both clients and internally call `self._transport.request(...)`
|
|
488
|
+
> — this works correctly today because `await`ing the *resource method*
|
|
489
|
+
> (e.g. `await client.sms.send(...)`) awaits the coroutine `request(...)`
|
|
490
|
+
> returns, whether or not the resource method itself uses the `await`
|
|
491
|
+
> keyword internally. If you're extending a resource with new async-only
|
|
492
|
+
> behavior (e.g. concurrent bulk sends via `asyncio.gather`), be
|
|
493
|
+
> deliberate about where you `await`.
|
|
494
|
+
|
|
495
|
+
## FAQ / Troubleshooting
|
|
496
|
+
|
|
497
|
+
**My SMS send raised `ValidationError` about the sender ID.**
|
|
498
|
+
Sender IDs must be pre-registered with SendAfrica and are capped at 11
|
|
499
|
+
characters (the GSM alphanumeric sender ID convention) — check both. Omit
|
|
500
|
+
`sender` entirely to use your account's/the platform's default instead of
|
|
501
|
+
troubleshooting a custom one.
|
|
502
|
+
|
|
503
|
+
**`client.sms.analyze()` says N credits, but `send()` charged something
|
|
504
|
+
else.** `analyze()` is a local, pre-flight estimate (1 credit per
|
|
505
|
+
segment). The number on the `send()` response (`result.credits_used`) is
|
|
506
|
+
computed server-side and is authoritative — trust that one for billing
|
|
507
|
+
logic, use `analyze()` only for UI/estimation before the user hits send.
|
|
508
|
+
|
|
509
|
+
**I created a payment but have no way to check if it's confirmed.** By
|
|
510
|
+
design — this SDK doesn't wrap `payments.get`/`list` (see
|
|
511
|
+
[Why no packages or payments.get/list](#why-no-packages-or-paymentsgetlist)).
|
|
512
|
+
For `manual` orders, track confirmation via your own admin/dashboard
|
|
513
|
+
workflow. For `snippe` orders, the API confirms automatically server-side
|
|
514
|
+
via its own webhook — your integration finds out via the credit balance
|
|
515
|
+
increasing, not via this SDK.
|
|
516
|
+
|
|
517
|
+
**`client.webhooks.parse()` — where do I point SendAfrica to send
|
|
518
|
+
webhooks?** Nowhere yet — see the note in [Webhooks](#webhooks). This
|
|
519
|
+
resource has nothing live to verify against today.
|
|
520
|
+
|
|
521
|
+
**My async script hangs / warns about an unclosed client.** Call `await
|
|
522
|
+
client.aclose()` when you're done with an `AsyncSendAfrica` — it lazily
|
|
523
|
+
opens an `httpx.AsyncClient` on first request and doesn't close it for
|
|
524
|
+
you. In a FastAPI app, do this in a shutdown/lifespan hook, not per-request.
|
|
525
|
+
|
|
526
|
+
**How do I test my integration without hitting the real API?** Use the
|
|
527
|
+
`responses` library (already a `dev` extra) to mock the HTTP layer — see
|
|
528
|
+
[`tests/README.md`](tests/README.md) for the exact envelope shape to mock
|
|
529
|
+
and worked examples. Don't mock at the resource-method level; mocking the
|
|
530
|
+
actual HTTP response is what would have caught the envelope-unwrapping bug
|
|
531
|
+
this SDK once shipped with.
|
|
532
|
+
|
|
533
|
+
**Where's the changelog?** There isn't a separate one yet — check
|
|
534
|
+
`git log` for this repo, and the version in `pyproject.toml` /
|
|
535
|
+
`sendafrica.__version__`.
|
|
536
|
+
|
|
537
|
+
## Security
|
|
538
|
+
|
|
539
|
+
This is an open-source project — a few ground rules for anyone
|
|
540
|
+
contributing or forking it:
|
|
541
|
+
|
|
542
|
+
- **Never commit real API keys, `.env` files, or credentials.** The
|
|
543
|
+
repo's `.gitignore` excludes `.env*`, key/cert files, and common
|
|
544
|
+
secrets filenames — keep it that way in forks.
|
|
545
|
+
- **Rotate immediately if a key leaks.** `DELETE /v1/auth/api-keys/{keyId}`
|
|
546
|
+
(or the dashboard) revokes a key instantly; there's no grace period, so
|
|
547
|
+
do this the moment you suspect exposure, not after investigating.
|
|
548
|
+
- **Webhook signature checks use `hmac.compare_digest`**, not `==`, to
|
|
549
|
+
avoid timing side-channels — keep it that way if you touch
|
|
550
|
+
`resources/webhooks.py`.
|
|
551
|
+
- **Don't log full API keys or webhook secrets.** `debug=True` logs
|
|
552
|
+
method/path/status/request-id, never the `Authorization` header value.
|
|
553
|
+
- Found a vulnerability? Please open a private report rather than a
|
|
554
|
+
public issue if it involves a live-key or account-takeover scenario.
|
|
555
|
+
|
|
556
|
+
## Project layout
|
|
557
|
+
|
|
558
|
+
```
|
|
559
|
+
sendafrica/
|
|
560
|
+
├── client.py # SendAfrica, AsyncSendAfrica — construction, resource wiring
|
|
561
|
+
├── http.py # HTTPTransport, AsyncHTTPTransport — retries, auth headers, envelope unwrapping, error mapping
|
|
562
|
+
├── auth.py # API key resolution (explicit arg > SENDAFRICA_API_KEY env var)
|
|
563
|
+
├── exceptions.py # SendAfricaError hierarchy + HTTP-status -> exception mapping
|
|
564
|
+
├── models.py # Response dataclasses (SMSResult, CreditBalance, Payment, VoucherRate, ...)
|
|
565
|
+
├── cli.py # `sendafrica` console script
|
|
566
|
+
├── resources/ # see resources/README.md for implementation notes per module
|
|
567
|
+
│ ├── sms.py # send, send_many, analyze
|
|
568
|
+
│ ├── credits.py # balance, history
|
|
569
|
+
│ ├── payments.py # create (voucher top-up), rate
|
|
570
|
+
│ └── webhooks.py # parse (signature-verified event parsing)
|
|
571
|
+
└── utils/ # see utils/README.md for implementation notes per module
|
|
572
|
+
├── phone.py # E.164 normalization, Tanzanian-mobile validation
|
|
573
|
+
├── sms.py # GSM-7/UCS-2 encoding detection + segment/credit estimation
|
|
574
|
+
└── validators.py # require(), validate_sender_id(), validate_positive_amount()
|
|
575
|
+
|
|
576
|
+
tests/ # see tests/README.md for what's covered and how to add more
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
This README covers usage; the per-folder READMEs
|
|
580
|
+
([`sendafrica/resources/README.md`](sendafrica/resources/README.md),
|
|
581
|
+
[`sendafrica/utils/README.md`](sendafrica/utils/README.md),
|
|
582
|
+
[`tests/README.md`](tests/README.md)) cover implementation notes,
|
|
583
|
+
invariants, and gotchas for anyone modifying that code — read the
|
|
584
|
+
relevant one before touching a file in that folder.
|
|
585
|
+
|
|
586
|
+
## Development
|
|
587
|
+
|
|
588
|
+
```bash
|
|
589
|
+
git clone <this repo>
|
|
590
|
+
cd sendafrica-python_sdk
|
|
591
|
+
python -m venv .venv && source .venv/bin/activate
|
|
592
|
+
pip install -e ".[dev,async]"
|
|
593
|
+
|
|
594
|
+
pytest -q
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
The test suite (`tests/test_local.py`) covers the dependency-free local
|
|
598
|
+
helpers directly (phone normalization, SMS segmentation, validators, error
|
|
599
|
+
mapping). If you're changing `http.py` or a `resources/*.py` file, add
|
|
600
|
+
coverage using the `responses` library to mock the actual HTTP envelope —
|
|
601
|
+
see the docstrings in `http.py` for the exact envelope shape to mock
|
|
602
|
+
(`{"success": true, "data": {...}}` on success, `{"success": false, "error":
|
|
603
|
+
{"code": ..., "message": ..., "details": ...}}` on failure).
|
|
604
|
+
|
|
605
|
+
## Roadmap
|
|
606
|
+
|
|
607
|
+
- **Done:** client, auth, SMS send/bulk/analyze, credits balance/history,
|
|
608
|
+
pay-as-you-go payments (voucher rate + create), typed errors, sync +
|
|
609
|
+
async transports, CLI.
|
|
610
|
+
- **Next:** wire `client.sms.send_many` (or a new method) to the server's
|
|
611
|
+
native `POST /v1/sms/bulk` endpoint instead of looping `send()`
|
|
612
|
+
client-side; CLI expansion (payments, credits history); async-specific
|
|
613
|
+
resource variants if the shared-resource-class approach ever needs
|
|
614
|
+
genuinely different behavior per transport.
|
|
615
|
+
- **Later:** campaigns, contact lists, templates, scheduling — once/if
|
|
616
|
+
those are exposed to API-key auth server-side (today they're
|
|
617
|
+
dashboard/JWT-only).
|
|
618
|
+
|
|
619
|
+
## License
|
|
620
|
+
|
|
621
|
+
MIT — see `pyproject.toml`.
|