Official Python SDK for the SendAfrica SMS Infrastructure-as-a-Service API
Project description
SendAfrica Python SDK
Official Python client for the SendAfrica SMS Infrastructure-as-a-Service API. Designed to feel like Stripe's Python library: a couple of lines to send your first message, enough control (retries, timeouts, async, typed errors) to run in production.
- SMS — send single or bulk messages, with local phone/encoding validation before any network call
- Credits — check balance, list transaction history
- Payments — top up credits pay-as-you-go, for any amount
- Webhooks — signature-verified event parsing (see the note in Webhooks about current backend support)
- Sync client (
requests) and async client (httpx, optional) - A typed exception hierarchy so you can catch exactly the failure you care about (insufficient credits, rate limit, bad phone number, ...)
- A small
sendafricaCLI for one-off sends from a shell/CI script
Table of contents
- Install
- Authentication
- Quickstart
- Configuration
- Resources
- Full example
- Errors
- Retries and timeouts
- Response envelope (internals)
- CLI
- Async
- FAQ / Troubleshooting
- Security
- Project layout
- Development
- Roadmap
- License
Install
pip install sendafrica
# with the async client (adds httpx):
pip install "sendafrica[async]"
# for contributing (adds pytest, coverage, responses):
pip install "sendafrica[dev]"
Requires Python 3.9+.
Authentication
SendAfrica uses a single API key per integration, created from the
dashboard (POST /v1/auth/api-keys, which itself requires a logged-in JWT
session — the API key is what your server/script uses afterwards).
Keys look like:
SA-xxxxx
SA- + 64 hex characters. (Keys issued before this format shipped are bare
hex with no SA- prefix — both keep working; the API only ever compares a
SHA-256 hash of whatever string you send, it doesn't parse the format.)
The key is sent as either header — the SDK uses Authorization: Bearer:
Authorization: Bearer SA-xxxxxxxx...
X-API-Key: SA-xxxxxxxx... # equivalent, API accepts either
The raw key is shown exactly once, at creation time. If you lose it,
delete it (DELETE /v1/auth/api-keys/{keyId}) and create a new one — there
is no recovery endpoint.
Pass it explicitly, or set it as an environment variable and construct the client with no arguments:
from sendafrica import SendAfrica
client = SendAfrica(api_key="SA-xxxxx")
# or:
# export SENDAFRICA_API_KEY="SA-xxxxx"
# client = SendAfrica()
Resolution order (sendafrica/auth.py): explicit api_key=... argument,
then the SENDAFRICA_API_KEY environment variable, then
AuthenticationError if neither is set.
Never commit a real key. Keep it in an environment variable, a secrets manager, or a
.envfile that's excluded from version control (this repo's.gitignorealready excludes.env*). If a key ever leaks — a screenshot, a public repo, a log line — delete it immediately via the dashboard orDELETE /v1/auth/api-keys/{keyId}and issue a new one.
Quickstart
from sendafrica import SendAfrica
client = SendAfrica(api_key="SA-xxxxx")
result = client.sms.send(to="0712345678", message="Welcome to SendAfrica")
print(result.message_id, result.status, result.credits_used)
# SA-3f9a2b7c-1d4e-4f60-8192-a3b4c5d6e7f8 Success 1
to accepts any of 0712345678, 712345678, 255712345678, or
+255712345678 — it's normalized to E.164 locally before the request is
sent. Invalid numbers raise InvalidPhoneError without making a network
call.
Configuration
client = SendAfrica(
api_key="SA-xxxxx", # or omit and set SENDAFRICA_API_KEY
base_url="https://api.sendafrica.co/v1", # default; override for a private/staging deployment
timeout=10, # seconds, per request
max_retries=3, # retries on 429 and 500/502/503/504, plus connection errors
environment="production", # cosmetic label, shown in repr(client) and useful for your own logging
debug=False, # True logs "[sendafrica] METHOD /path attempt=N" and the response status to stdout
webhook_secret=None, # default secret used by client.webhooks.parse() if you don't pass one per-call
)
| Parameter | Type | Default | Notes |
|---|---|---|---|
api_key |
str | None |
None |
Falls back to SENDAFRICA_API_KEY env var |
base_url |
str |
https://api.sendafrica.co/v1 |
Trailing slash is stripped automatically |
timeout |
float |
10 |
Per-request timeout in seconds |
max_retries |
int |
3 |
Exponential backoff (0.5 * 2^attempt, capped at 8s), or Retry-After on 429 if the server sends one |
environment |
str |
"production" |
Not sent to the API; purely a local label |
debug |
bool |
False |
Prints request/response tracing to stdout |
webhook_secret |
str | None |
None |
See Webhooks |
AsyncSendAfrica takes the same arguments (no environment restriction
either) and additionally exposes await client.aclose() to close the
underlying httpx.AsyncClient — call it when you're done with the client
(e.g. in a FastAPI shutdown handler).
Resources
| Resource | Methods |
|---|---|
client.sms |
send, send_many, analyze |
client.credits |
balance, history |
client.payments |
create, rate |
client.webhooks |
parse |
There is intentionally no client.packages or client.payments.get/list:
see Payments and the note below.
SMS
result = client.sms.send(
to="0712345678",
message="Your OTP is 123456",
sender="MyBrand", # optional; registered sender ID, max 11 chars
)
# SMSResult(message_id="SA-...", status="Success", credits_used=1, cost="TZS 22.0000", to=None)
| Field | Required | Notes |
|---|---|---|
to |
yes | Any of 0712345678 / 712345678 / 255712345678 / +255712345678; normalized locally, invalid input raises InvalidPhoneError before any request |
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) |
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 |
Bulk sending loops send() client-side with local pacing — it does not
call the server's POST /v1/sms/bulk endpoint, so partial failures are
collected per-message instead of aborting the batch:
results = client.sms.send_many(
[
{"to": "0711111111", "message": "Hello John"},
{"to": "0722222222", "message": "Hello Mary", "sender": "MyBrand"},
],
rate_limit_per_sec=10, # default; set lower if you're hitting 429s
)
print(results.sent_count, results.failed_count)
for failure in results.failed:
print(failure["index"], failure["to"], failure["error"])
Estimate cost/segmentation before sending anything (no network call):
analysis = client.sms.analyze("Habari 😊")
# SMSAnalysis(encoding="UCS-2", characters=8, parts=1, credits=1)
credits is estimated as 1 credit per segment/part, matching typical SMS
aggregator billing — treat it as an estimate for UI display, not a
guarantee; the actual credits_used on the response from send() is
authoritative.
Credits
balance = client.credits.balance()
# CreditBalance(account_id="...", balance=500)
history = client.credits.history(page=1, per_page=25) # per_page max is 200 server-side
for tx in history:
print(tx.type, tx.amount, tx.balance_after, tx.description)
CreditTransaction.type is one of purchase, charge, refund, grant,
deduct. history() paginates with page/per_page (not cursor-based) —
this mirrors the API directly rather than emulating Stripe-style
limit/starting_after pagination.
Payments
Credit top-ups are pay-as-you-go: you choose any TZS amount (above a
server-configured floor) and the API converts it to credits at a tiered
rate — cheaper per credit at higher amounts. There is no fixed "package" to
pick, so there's no package_id anywhere in this SDK.
Check the current rate before charging a user, so your UI can show real numbers instead of hardcoding them (they're configurable per environment):
rate = client.payments.rate()
print(rate.min_amount_tzs) # e.g. 1000
for tier in rate.tiers:
print(tier.max_amount_tzs, tier.rate_tzs_per_credit)
# 49999 35
# 149999 32
# 0 30 <- max_amount_tzs == 0 means "unbounded top tier"
Then initiate the top-up:
payment = client.payments.create(
amount=20000, # TZS; must be >= rate.min_amount_tzs
provider="manual", # "manual" (default, admin-confirmed) or "snippe" (mobile money, needs `phone`)
phone="+255712345678", # required for "snippe", ignored for "manual"
)
print(payment.status, payment.credit_amount)
# pending 571
provider="manual"staysstatus="pending"until an admin confirms it — appropriate for bank transfer / manual reconciliation flows.provider="snippe"pushes a mobile-money (USSD) prompt to the account's own verified phone number and confirms automatically via a server-side webhook — you cannot specify an arbitrary phone forsnippe; the API always uses the account's verified number regardless of what you pass.- Amounts below
rate.min_amount_tzs, or too small to buy even a single credit at the applicable rate, raise aValidationError(amount_below_minimum) before an order is created.
Why no packages or payments.get/list
Earlier versions of this SDK had a client.packages resource and a
payments.create(package_id=...) flow for buying fixed credit packages.
That's been removed for simplicity in favor of the pay-as-you-go voucher
flow above — one mental model (pick an amount) instead of two (pick an
amount or pick a package). The fixed-package endpoint
(POST /v1/payments/) still exists API-side if you need it directly, but
this SDK only wraps the voucher flow now.
payments.get(id)/payments.list() were also removed: looking up or
listing individual payment orders is an admin-console feature gated on a
JWT with is_admin = true — an API key can never satisfy that check, so
there is no programmatic-access route for this SDK to wrap. If you need
order status, track it via the id returned from create() and your own
webhook/polling against your dashboard session, not this SDK.
Webhooks
from fastapi import Request
@app.post("/webhooks/sendafrica")
async def webhook(request: Request):
event = client.webhooks.parse(
await request.body(),
signature=request.headers.get("X-SendAfrica-Signature"),
# or rely on webhook_secret=... passed to SendAfrica(...) at construction
)
if event.type == "sms.delivered":
print(event.message_id)
parse() verifies an HMAC-SHA256 signature over the raw body (if a
signature and secret are both available) using hmac.compare_digest, then
parses the JSON into a WebhookEvent. A mismatch raises
WebhookSignatureError — always check for that before touching the event.
Status: speculative. As of this writing, the SendAfrica API does not forward signed events to a customer-supplied endpoint — the only webhooks in the API today are inbound to SendAfrica itself (Africa's Talking delivery-status callbacks at
/v1/sms/callback, and the Snippe payment-confirmation webhook), neither of which uses this signature scheme. This resource exists so client code is ready the day SendAfrica ships outbound event delivery; there is nothing to point it at yet. Adjust the header name/scheme here if the eventual spec differs.
Full example
A single script that checks the balance, tops up if it's low, and sends an SMS — showing typed-error handling for the failure modes you'll actually hit in production:
from sendafrica import SendAfrica
from sendafrica.exceptions import (
InsufficientCreditsError,
InvalidPhoneError,
RateLimitError,
SendAfricaError,
)
client = SendAfrica() # reads SENDAFRICA_API_KEY from the environment
LOW_BALANCE_THRESHOLD = 50
TOP_UP_AMOUNT_TZS = 20_000
balance = client.credits.balance()
if balance.balance < LOW_BALANCE_THRESHOLD:
rate = client.payments.rate()
if TOP_UP_AMOUNT_TZS < rate.min_amount_tzs:
TOP_UP_AMOUNT_TZS = rate.min_amount_tzs
payment = client.payments.create(amount=TOP_UP_AMOUNT_TZS, provider="manual")
print(f"Top-up order {payment.id} created ({payment.status}) — "
f"will be worth {payment.credit_amount} credits once confirmed")
# "manual" orders need an admin to confirm before credits land — this
# script doesn't block waiting for that. Poll your own systems/webhook
# for the actual balance change; there's no order-status GET in this SDK.
try:
result = client.sms.send(
to="0712345678",
message="Your OTP is 123456",
sender="MyBrand",
)
print(f"Sent {result.message_id}: {result.status}, {result.credits_used} credit(s)")
except InvalidPhoneError as e:
print(f"Bad phone number, didn't even try to send: {e}")
except InsufficientCreditsError:
print("Still out of credits — the top-up above is probably still pending")
except RateLimitError as e:
# The SDK already retried internally (see Retries and timeouts below) —
# seeing this means it exhausted max_retries and still got a 429.
print(f"Still rate limited after retries; server says wait {e.retry_after}s")
except SendAfricaError as e:
print(f"Send failed: {e.message} (request_id={e.request_id})")
Errors
All SDK errors inherit from SendAfricaError, which carries .message,
.status_code, .request_id (echoes the API's request_id for support
tickets), and .response_body (the raw decoded JSON body, if any):
from sendafrica.exceptions import InsufficientCreditsError
try:
client.sms.send(to="0712345678", message="hello")
except InsufficientCreditsError:
print("Please buy credits")
except SendAfricaError as e:
print(e.message, e.status_code, e.request_id)
| Exception | HTTP status | Meaning |
|---|---|---|
AuthenticationError |
401 | invalid/missing/expired API key |
ValidationError (InvalidPhoneError is a subclass) |
400, 422 | bad request payload, or a phone number that failed local/server validation |
InsufficientCreditsError |
402 | not enough SMS credits to complete the send |
NotFoundError |
404 | resource does not exist |
RateLimitError |
429 | too many requests; .retry_after (seconds) is set if the server sent a Retry-After header |
ServerError |
5xx | failure on SendAfrica's side |
APIConnectionError |
— | network/timeout/DNS failure — never reached the API |
WebhookSignatureError |
— | local-only: an incoming webhook's signature didn't match |
Any other status code raises the base SendAfricaError directly.
Retries and timeouts
Both HTTPTransport (sync, requests) and AsyncHTTPTransport (async,
httpx) share the same policy:
- Retries on
429,500,502,503,504, and connection-level errors, up tomax_retriestimes (default 3). - Backoff is
min(0.5 * 2^attempt, 8)seconds between attempts, except on429where the server'sRetry-Afterheader is honored exactly if present. - Every request carries a fresh
X-Request-Idheader and the response'sX-Request-Idis attached to any raised exception — include it when filing a support ticket. - Retries are transparent to your code — you get back a result or a
raised
SendAfricaError/APIConnectionErrorafter retries are exhausted, never a partial/ambiguous state.
Response envelope (internals)
Every SendAfrica API response is wrapped in the same envelope:
{"success": true, "data": {...}, "error": null, "meta": null, "request_id": "...", "timestamp": "..."}
HTTPTransport/AsyncHTTPTransport unwrap data once, centrally, before
handing it to resource classes — you never see the envelope yourself
unless you're reading exception.response_body after a failure (which
contains the full, un-unwrapped error envelope: {success: false, error: {code, message, details}}).
CLI
Installed as the sendafrica console script:
export SENDAFRICA_API_KEY="SA-xxxxx"
sendafrica balance
# Credits: 500
sendafrica sms send --to 0712345678 --message "Hello"
# Sent: SA-3f9a2b7c-... (status=Success, credits=1)
Currently supports balance and sms send --to ... --message ... [--sender ...].
It's a thin wrapper (sendafrica/cli.py) over the same SendAfrica client
— useful for shell scripts, cron jobs, or CI smoke tests, not a full
replacement for the dashboard.
Async
from sendafrica import AsyncSendAfrica
client = AsyncSendAfrica(api_key="SA-xxxxx")
result = await client.sms.send(to="0712345678", message="Hello")
await client.aclose()
Requires the async extra (pip install "sendafrica[async]"), which adds
httpx. AsyncSendAfrica builds its own httpx.AsyncClient lazily on
first request and reuses it across calls — always call await client.aclose() when you're done (e.g. in a FastAPI lifespan/shutdown
hook) so the underlying connection pool is released.
Status: the async transport (
AsyncHTTPTransport, httpx-based, with the same retry/backoff policy as the sync client) is fully implemented. Resource classes (SMSResource,CreditsResource, etc.) are shared between both clients and internally callself._transport.request(...)— this works correctly today becauseawaiting the resource method (e.g.await client.sms.send(...)) awaits the coroutinerequest(...)returns, whether or not the resource method itself uses theawaitkeyword internally. If you're extending a resource with new async-only behavior (e.g. concurrent bulk sends viaasyncio.gather), be deliberate about where youawait.
FAQ / Troubleshooting
My SMS send raised ValidationError about the sender ID.
Sender IDs must be pre-registered with SendAfrica and are capped at 11
characters (the GSM alphanumeric sender ID convention) — check both. Omit
sender entirely to use your account's/the platform's default instead of
troubleshooting a custom one.
client.sms.analyze() says N credits, but send() charged something
else. analyze() is a local, pre-flight estimate (1 credit per
segment). The number on the send() response (result.credits_used) is
computed server-side and is authoritative — trust that one for billing
logic, use analyze() only for UI/estimation before the user hits send.
I created a payment but have no way to check if it's confirmed. By
design — this SDK doesn't wrap payments.get/list (see
Why no packages or payments.get/list).
For manual orders, track confirmation via your own admin/dashboard
workflow. For snippe orders, the API confirms automatically server-side
via its own webhook — your integration finds out via the credit balance
increasing, not via this SDK.
client.webhooks.parse() — where do I point SendAfrica to send
webhooks? Nowhere yet — see the note in Webhooks. This
resource has nothing live to verify against today.
My async script hangs / warns about an unclosed client. Call await client.aclose() when you're done with an AsyncSendAfrica — it lazily
opens an httpx.AsyncClient on first request and doesn't close it for
you. In a FastAPI app, do this in a shutdown/lifespan hook, not per-request.
How do I test my integration without hitting the real API? Use the
responses library (already a dev extra) to mock the HTTP layer — see
tests/README.md for the exact envelope shape to mock
and worked examples. Don't mock at the resource-method level; mocking the
actual HTTP response is what would have caught the envelope-unwrapping bug
this SDK once shipped with.
Where's the changelog? There isn't a separate one yet — check
git log for this repo, and the version in pyproject.toml /
sendafrica.__version__.
Security
This is an open-source project — a few ground rules for anyone contributing or forking it:
- Never commit real API keys,
.envfiles, or credentials. The repo's.gitignoreexcludes.env*, key/cert files, and common secrets filenames — keep it that way in forks. - Rotate immediately if a key leaks.
DELETE /v1/auth/api-keys/{keyId}(or the dashboard) revokes a key instantly; there's no grace period, so do this the moment you suspect exposure, not after investigating. - Webhook signature checks use
hmac.compare_digest, not==, to avoid timing side-channels — keep it that way if you touchresources/webhooks.py. - Don't log full API keys or webhook secrets.
debug=Truelogs method/path/status/request-id, never theAuthorizationheader value. - Found a vulnerability? Please open a private report rather than a public issue if it involves a live-key or account-takeover scenario.
Project layout
sendafrica/
├── client.py # SendAfrica, AsyncSendAfrica — construction, resource wiring
├── http.py # HTTPTransport, AsyncHTTPTransport — retries, auth headers, envelope unwrapping, error mapping
├── auth.py # API key resolution (explicit arg > SENDAFRICA_API_KEY env var)
├── exceptions.py # SendAfricaError hierarchy + HTTP-status -> exception mapping
├── models.py # Response dataclasses (SMSResult, CreditBalance, Payment, VoucherRate, ...)
├── cli.py # `sendafrica` console script
├── resources/ # see resources/README.md for implementation notes per module
│ ├── sms.py # send, send_many, analyze
│ ├── credits.py # balance, history
│ ├── payments.py # create (voucher top-up), rate
│ └── webhooks.py # parse (signature-verified event parsing)
└── utils/ # see utils/README.md for implementation notes per module
├── phone.py # E.164 normalization, Tanzanian-mobile validation
├── sms.py # GSM-7/UCS-2 encoding detection + segment/credit estimation
└── validators.py # require(), validate_sender_id(), validate_positive_amount()
tests/ # see tests/README.md for what's covered and how to add more
This README covers usage; the per-folder READMEs
(sendafrica/resources/README.md,
sendafrica/utils/README.md,
tests/README.md) cover implementation notes,
invariants, and gotchas for anyone modifying that code — read the
relevant one before touching a file in that folder.
Development
git clone <this repo>
cd sendafrica-python_sdk
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,async]"
pytest -q
The test suite (tests/test_local.py) covers the dependency-free local
helpers directly (phone normalization, SMS segmentation, validators, error
mapping). If you're changing http.py or a resources/*.py file, add
coverage using the responses library to mock the actual HTTP envelope —
see the docstrings in http.py for the exact envelope shape to mock
({"success": true, "data": {...}} on success, {"success": false, "error": {"code": ..., "message": ..., "details": ...}} on failure).
Roadmap
- Done: client, auth, SMS send/bulk/analyze, credits balance/history, pay-as-you-go payments (voucher rate + create), typed errors, sync + async transports, CLI.
- Next: wire
client.sms.send_many(or a new method) to the server's nativePOST /v1/sms/bulkendpoint instead of loopingsend()client-side; CLI expansion (payments, credits history); async-specific resource variants if the shared-resource-class approach ever needs genuinely different behavior per transport. - Later: campaigns, contact lists, templates, scheduling — once/if those are exposed to API-key auth server-side (today they're dashboard/JWT-only).
License
MIT — see pyproject.toml.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sendafrica-1.0.0.tar.gz.
File metadata
- Download URL: sendafrica-1.0.0.tar.gz
- Upload date:
- Size: 32.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4cc2fceb58ac836118ea90228e53be8bedc5c1e4ed02c0c457ae8e3fda4443b5
|
|
| MD5 |
069b5672ebbf325d8f1444d7ea460db3
|
|
| BLAKE2b-256 |
21ca2aa091e27df3f16f0488fce05daa643d927126c67a5641cf834fe52d9c19
|
File details
Details for the file sendafrica-1.0.0-py3-none-any.whl.
File metadata
- Download URL: sendafrica-1.0.0-py3-none-any.whl
- Upload date:
- Size: 26.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c63512543821cb6b263bc53f1d9426b2799b834fbeea5083c119d86dbffac0ed
|
|
| MD5 |
910bb3768595de1da52640b9334cb215
|
|
| BLAKE2b-256 |
a92ce0cfebd1a765bba04a8499dd7b5d4aa57e866550b02d185302e07c576d02
|