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.
@@ -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`.