Metadata-Version: 2.4
Name: sendafrica
Version: 1.0.0
Summary: Official Python SDK for the SendAfrica SMS Infrastructure-as-a-Service API
Author: CamelTech
License: MIT
Keywords: sms,sendafrica,africa,tanzania,sdk
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.28
Provides-Extra: async
Requires-Dist: httpx>=0.24; extra == "async"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: responses; extra == "dev"

# 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](#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](#install)
- [Authentication](#authentication)
- [Quickstart](#quickstart)
- [Configuration](#configuration)
- [Resources](#resources)
  - [SMS](#sms)
  - [Credits](#credits)
  - [Payments](#payments)
  - [Webhooks](#webhooks)
- [Full example](#full-example)
- [Errors](#errors)
- [Retries and timeouts](#retries-and-timeouts)
- [Response envelope (internals)](#response-envelope-internals)
- [CLI](#cli)
- [Async](#async)
- [FAQ / Troubleshooting](#faq--troubleshooting)
- [Security](#security)
- [Project layout](#project-layout)
- [Development](#development)
- [Roadmap](#roadmap)
- [License](#license)

---

## Install

```bash
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:

```python
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

```python
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

```python
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](#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](#payments) and [the note below](#why-no-packages-or-paymentsgetlist).

### SMS

```python
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:

```python
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):

```python
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

```python
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):

```python
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:

```python
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

```python
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:

```python
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):

```python
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:

```json
{"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:

```bash
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

```python
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 `await`ing 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](#why-no-packages-or-paymentsgetlist)).
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](#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`](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/resources/README.md),
[`sendafrica/utils/README.md`](sendafrica/utils/README.md),
[`tests/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

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