Skip to main content

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 sendafrica CLI for one-off sends from a shell/CI script

Table of contents


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 .env file that's excluded from version control (this repo's .gitignore already excludes .env*). If a key ever leaks — a screenshot, a public repo, a log line — delete it immediately via the dashboard or DELETE /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" stays status="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 for snippe; 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 a ValidationError (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 to max_retries times (default 3).
  • Backoff is min(0.5 * 2^attempt, 8) seconds between attempts, except on 429 where the server's Retry-After header is honored exactly if present.
  • Every request carries a fresh X-Request-Id header and the response's X-Request-Id is 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/APIConnectionError after 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 call self._transport.request(...) — this works correctly today because awaiting the resource method (e.g. await client.sms.send(...)) awaits the coroutine request(...) returns, whether or not the resource method itself uses the await keyword internally. If you're extending a resource with new async-only behavior (e.g. concurrent bulk sends via asyncio.gather), be deliberate about where you await.

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, .env files, or credentials. The repo's .gitignore excludes .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 touch resources/webhooks.py.
  • Don't log full API keys or webhook secrets. debug=True logs method/path/status/request-id, never the Authorization header 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 native POST /v1/sms/bulk endpoint instead of looping send() 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sendafrica-1.0.0.tar.gz (32.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sendafrica-1.0.0-py3-none-any.whl (26.6 kB view details)

Uploaded Python 3

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

Hashes for sendafrica-1.0.0.tar.gz
Algorithm Hash digest
SHA256 4cc2fceb58ac836118ea90228e53be8bedc5c1e4ed02c0c457ae8e3fda4443b5
MD5 069b5672ebbf325d8f1444d7ea460db3
BLAKE2b-256 21ca2aa091e27df3f16f0488fce05daa643d927126c67a5641cf834fe52d9c19

See more details on using hashes here.

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

Hashes for sendafrica-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c63512543821cb6b263bc53f1d9426b2799b834fbeea5083c119d86dbffac0ed
MD5 910bb3768595de1da52640b9334cb215
BLAKE2b-256 a92ce0cfebd1a765bba04a8499dd7b5d4aa57e866550b02d185302e07c576d02

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page