Python SDK
The official Python client for the SendAfrica REST API. It provides SendAfrica (sync, requests-based) and AsyncSendAfrica (async, httpx-based) clients with typed dataclass models, full phone normalization, local SMS part analysis, and a thin CLI.
- Package:
sendafrica— Python 3.9+ - Dependencies:
requests(always);httpxfor async - Source:
github.com/SendAfrica/Python-SDK
Install
pip install sendafricaFor async support (FastAPI, asyncio workers):
pip install sendafrica[async]For the full SDK guide, see the official docs at sdk.sendafrica.online/sdks/python .
Quickstart
import os
from sendafrica import SendAfrica
client = SendAfrica(api_key=os.environ["SENDAFRICA_API_KEY"])
result = client.sms.send(
to="0712345678",
message="Your verification code is 4921.",
)
print(result.message_id, result.status, result.credits_used)Place the API key in SENDAFRICA_API_KEY or pass it directly to the constructor. Keep credentials server-side and out of version control.
Client setup
from sendafrica import SendAfrica, AsyncSendAfrica
client = SendAfrica(
api_key="SA-...", # or omit to read SENDAFRICA_API_KEY
base_url="https://api.sendafrica.online/v1",
timeout=10, # request timeout in seconds
max_retries=3, # retry on 429 / 5xx / network errors
debug=True, # prints request/response summaries
webhook_secret="whsec_...", # used by client.webhooks for signature verification
)
# Async variant — same resource API, but every call is awaitable
async_client = AsyncSendAfrica(api_key="SA-...")
await async_client.sms.send(to="0712345678", message="Async send")
await async_client.aclose() # close httpx transport when doneThe async client requires httpx (pip install sendafrica[async]). Without it, AsyncSendAfrica raises ImportError at construction.
Sending SMS
Single message
Phone numbers are normalized to E.164 automatically; invalid numbers raise InvalidPhoneError before any network call:
result = client.sms.send(
to="0712345678",
message="Welcome to SendAfrica!",
sender="SENDAFRICA", # optional registered sender ID
)
print(result.message_id, result.credits_used)| Parameter | Type | Required | Notes |
|---|---|---|---|
to | string | Yes | Accepted formats: 0712345678, 255..., +255... |
message | string | Yes | Required |
sender | string | No | Registered sender ID; omits from from the payload when absent |
Returns an SMSResult dataclass: message_id, status, credits_used, cost, to.
Client-side send many
N distinct messages, rate limited, with per-message failure collection:
results = client.sms.send_many(
messages=[
{"to": "0712345678", "message": "Hi Ali"},
{"to": "0765432109", "message": "Hi Zainab"},
],
rate_limit_per_sec=10.0, # default 10
sender="MYBRAND", # applied to all unless per-message overrides
)
print(results.sent_count, results.failed_count)
for r in results.results:
print(r.message_id, r.to)
for f in results.failed:
print(f["index"], f["to"], f["error"])Server-side bulk SMS
Send one message to many recipients in a single API call. The SDK caps at 100 recipients; the server returns partial failures per recipient and refunds them individually.
bulk = client.sms.bulk(
to=["0712345678", "0765432109"],
message="Black Friday starts now!",
sender="MYBRAND",
)
print(bulk.total, bulk.sent, bulk.failed)
for r in bulk.results:
print(r.to, r.status, r.message_id, r.error)Message logs
logs = client.sms.logs(
page=1, per_page=25,
status="delivered", # pending | sent | delivered | failed
search="0712345678",
date_from="2026-09-01T00:00:00Z",
)
print(logs.total, logs.page, logs.total_pages)
for entry in logs.items:
print(entry.to_phone, entry.status, entry.credits_used, entry.sent_at)Client-side SMS analysis
analysis = client.sms.analyze("Hello world from SendAfrica 🇹🇿")
print(analysis.encoding, analysis.parts, analysis.credits)
# UCS-2 1 1 (emoji forces Unicode encoding)Returns an SMSAnalysis dataclass: encoding (“GSM-7” or “UCS-2”), characters, septets, parts, credits. No network call — runs entirely locally.
Credits
balance = client.credits.balance()
print(balance.balance, balance.account_id)
history = client.credits.history(page=1, per_page=25)
for txn in history:
print(txn.type, txn.amount, txn.balance_after, txn.description)Rates
The rate card is public (no auth required).
rates = client.rates.list()
for r in rates:
print(r.name, r.iso2, r.rate_tzs)
tz = client.rates.get("TZ")
print(tz.name, tz.rate_tzs)RateInfo dataclasses carry name, iso2, dial_code, rate_tzs, and an optional provider_bulk_sms snapshot of Africa’s Talking reference pricing.
Sender IDs
reqs = client.sender_ids.requirements()
print(reqs.eligibility.can_request)
print([r.name for r in reqs.countries])
usable = client.sender_ids.usable()
for sid in usable:
print(sid.name, sid.type, sid.is_default)
list = client.sender_ids.list()
new_id = client.sender_ids.create(
name="MYBRAND",
country="TZ",
purpose="Marketing",
sample_message="Welcome to MYBRAND",
documents=[{"requirement_uid": "...", "filename": "doc.pdf", "content_base64": "..."}],
)
print(new_id.id, new_id.status)
one = client.sender_ids.get(new_id.id)
client.sender_ids.set_default(new_id.id)Payments (vouchers)
rate = client.payments.rate()
print(rate.min_amount_tzs, [(t.max_amount_tzs, t.rate_tzs_per_credit) for t in rate.tiers])
payment = client.payments.create(
amount=10000, # TZS — converted to credits server-side
provider="snippe", # or "manual"
phone="0712345678", # required for mobile-money providers
)
print(payment.id, payment.status, payment.credit_amount)Declared-payer OTP flow (for payer numbers different from the account phone):
client.payments.send_declared_phone_otp("0765432109")
client.payments.verify_declared_phone_otp("0765432109", "4921")Webhooks
Parse and optionally verify an incoming delivery event. The signature is an HMAC-SHA256 hex digest of the raw body in the X-SendAfrica-Signature header.
from flask import request, jsonify
@app.route("/webhooks/sms", methods=["POST"])
def handle_webhook():
payload = request.get_data(as_text=True)
signature = request.headers.get("X-SendAfrica-Signature", "")
try:
event = client.webhooks.parse(payload, signature=signature)
except Exception:
return jsonify(error="invalid signature"), 401
print(event.type, event.message_id, event.data)
return jsonify(ok=True), 200When a webhook_secret was provided at client construction, passing secret= to parse is optional. You can verify a raw payload without parsing with:
client.webhooks._verify_signature(raw_body, signature, secret)CLI
After install, a sendafrica command is available:
export SENDAFRICA_API_KEY="SA-..."
sendafrica balance
sendafrica sms send --to 0712345678 --message "Hello from CLI" --sender MYBRAND
sendafrica sms logs --status delivered --per-page 50
sendafrica rates
sendafrica sender-ids list
sendafrica sender-ids requirements
sendafrica sender-ids usableThe CLI reads the API key from the environment. Errors are reported to stderr and exit with code 1.
Async (FastAPI example)
from fastapi import FastAPI
from sendafrica import AsyncSendAfrica
app = FastAPI()
client = AsyncSendAfrica(api_key="SA-...")
@app.on_event("shutdown")
async def shutdown():
await client.aclose()
@app.post("/send")
async def send():
result = await client.sms.send(to="0712345678", message="Async hello")
return {"message_id": result.message_id}Error handling
All SDK errors inherit from SendAfricaError, so a broad catch is always safe:
from sendafrica import SendAfricaError
try:
client.sms.send(to="invalid", message="test")
except SendAfricaError as e:
print(e.status_code, e.request_id, e.message)| Exception | HTTP | Meaning |
|---|---|---|
AuthenticationError | 401 | Missing or invalid API key |
ValidationError | 400/422 | Bad payload, missing fields |
InvalidPhoneError | — | Fails phone validation before network call |
InsufficientCreditsError | 402 | Not enough SMS credits |
RateLimitError | 429 | Rate limited; has retry_after attribute |
NotFoundError | 404 | Resource does not exist |
ServerError | 5xx | Transient API failure |
APIConnectionError | — | Network failure or timeout (requests transport) |
WebhookSignatureError | — | Signature verification failed |
Every exception carries .status_code, .request_id, and .message (available on all; .status_code is None for local transport errors).
Retries and backoff
Both transports retry 429/5xx responses and network-level requests.RequestError up to max_retries times (default 3). Backoff starts at 500 ms and doubles to a maximum of 8 s. The transport honors Retry-After on 429 responses. Default timeout is 10 seconds.
Reference
| Resource / Method | Endpoint | Returns |
|---|---|---|
sms.send(to, message, *, sender) | POST /sms | SMSResult |
sms.send_many(messages, *, sender, rate_limit_per_sec) | Loops POST /sms | BulkSMSResult |
sms.bulk(to, message, *, sender) | POST /sms/bulk | BulkSendResult |
sms.logs(page, per_page, status, search, date_from) | GET /sms/logs | MessageLogListResponse |
sms.analyze(message) | — (local) | SMSAnalysis |
credits.balance() | GET /credits/balance | CreditBalance |
credits.history(page, per_page) | GET /credits/history | List[CreditTransaction] |
rates.list() | GET /rates | List[RateInfo] |
rates.get(country) | GET /rates/{country} | RateInfo |
sender_ids.requirements() | GET /sender-ids/requirements | SenderIDRequirements |
sender_ids.usable(*, provider) | GET /sender-ids/usable | List[UsableSenderID] |
sender_ids.list() | GET /sender-ids | List[SenderID] |
sender_ids.create(...) | POST /sender-ids | SenderID |
sender_ids.get(sender_id) | GET /sender-ids/{id} | SenderID |
sender_ids.set_default(sender_id) | PUT /sender-ids/default | SenderID |
payments.create(amount, provider, ...) | POST /vouchers | Payment |
payments.rate() | GET /vouchers/rate | VoucherRate |
webhooks.parse(payload, *, signature, secret) | — (verify + parse) | WebhookEvent |
See the webhooks and SMS API references for the underlying REST contract.