Python SDK for Airtel Money Tanzania — Collection, Disbursement & Refunds
What's included
Automatic token acquisition and refresh. Your credentials stay safe — tokens are managed internally and renewed 60 seconds before expiry.
Request payments from subscribers. A prompt is sent to their phone — they enter their Airtel Money PIN to approve. No redirects needed.
Poll transaction status with typed responses: is_successful, is_pending, is_failed. No raw string parsing required.
Transfer money directly to any Airtel Money wallet. Includes payee validation before sending so you never send to an invalid number.
Reverse a completed collection using Airtel's internal airtel_money_id. One line of code — no extra configuration needed.
Disbursements require your merchant PIN encrypted with Airtel's RSA public key. Handled automatically via pycryptodome.
Pass numbers in any format — +255…, 0…, or 255…. Automatically normalised to the 12-digit format Airtel expects.
All 9 real Airtel Tanzania ESB error codes mapped to human-readable messages. CollectionError.esb_code and .esb_message on every failure.
Toggle between UAT sandbox and live production with a single sandbox=True/False flag. Same code, different environment.
Credentials are never hardcoded. Use a .env file locally and environment variables in production — a .env.example template is included in the repo.
Code examples
from pyairtel import AirtelMoney # Initialise — sandbox=True for testing, False for production airtel = AirtelMoney( client_id=os.environ["AIRTEL_CLIENT_ID"], client_secret=os.environ["AIRTEL_CLIENT_SECRET"], sandbox=os.getenv("AIRTEL_SANDBOX", "true").lower() == "true, ) # Send a USSD push — subscriber gets a PIN prompt on their phone resp = airtel.collect( phone="+255681219610", # +255, 0, or 255 formats all accepted amount=5000, # Tanzanian Shillings reference="invoice-42", ) print(resp.transaction_id) # TXN-20240101120000-AB12CD34 print(resp.is_initiated) # True
import time # Wait for the subscriber to approve on their phone time.sleep(15) status = airtel.get_collection_status(resp.transaction_id) if status.is_successful: print("✅ Payment confirmed!", status.airtel_money_id) elif status.is_pending: print("⏳ Subscriber hasn't approved yet...") elif status.is_failed: print("❌ Payment failed:", status.message) # Transaction status codes: # TS — Transaction Successful # TIP — Transaction In Progress # TF — Transaction Failed # TA — Transaction Ambiguous
# Use airtel_money_id from a successful collection status status = airtel.get_collection_status(transaction_id) if status.is_successful: refund = airtel.refund(status.airtel_money_id) if refund.is_successful: print("↩️ Refund initiated:", refund.message) else: print("Refund failed:", refund.message)
# Step 1 — check the payee can receive money check = airtel.validate_payee("+255754123456") if not check.is_valid: print("Cannot receive money:", check.message) else: # Step 2 — transfer money (PIN is RSA-encrypted automatically) result = airtel.transfer( phone="+255681219610", amount=2000, pin="1234", # merchant PIN — encrypted before sending public_key_pem=open("airtel_pub.pem").read(), payer_first_name="Ronald", payer_last_name="Gosso", reference="payout-001", ) print(result.is_successful, result.airtel_money_id)
from pyairtel import AirtelMoney, decode_esb_error from pyairtel.exceptions import ( AuthenticationError, CollectionError, DisbursementError, EncryptionError, ) try: resp = airtel.collect(phone="+255754123456", amount=1000, reference="ref-1") except AuthenticationError as e: print("Bad credentials:", e) except CollectionError as e: print("ESB code:", e.esb_code) # e.g. "ESB000014" print("Reason:", e.esb_message) # "Insufficient funds..." # Decode any ESB code manually print(decode_esb_error("ESB000039")) # → "Transaction timed out. The subscriber did not respond..."
# Step 1 — copy the template and fill in your values # cp .env.example .env # .env (never commit this file — it's in .gitignore) AIRTEL_CLIENT_ID=your-client-id-here AIRTEL_CLIENT_SECRET=your-client-secret-here AIRTEL_SANDBOX=true AIRTEL_PUBLIC_KEY_PATH=airtel_pub.pem # disbursement only # Step 2 — install python-dotenv # pip install python-dotenv # Step 3 — load in your app from dotenv import load_dotenv import os from pyairtel import AirtelMoney load_dotenv() # reads .env into environment variables airtel = AirtelMoney( client_id=os.environ["AIRTEL_CLIENT_ID"], client_secret=os.environ["AIRTEL_CLIENT_SECRET"], sandbox=os.getenv("AIRTEL_SANDBOX", "true").lower() == "true", )
API Reference
Security
# ❌ Don't do this airtel = AirtelMoney(client_id="abc123", client_secret="secret") # ✅ Do this instead — load from environment airtel = AirtelMoney( client_id=os.environ["AIRTEL_CLIENT_ID"], client_secret=os.environ["AIRTEL_CLIENT_SECRET"], )
.env.example → .envcp .env.example .env
# .env — fill in your real values from the Airtel developer portal AIRTEL_CLIENT_ID=your-client-id-here AIRTEL_CLIENT_SECRET=your-client-secret-here AIRTEL_SANDBOX=true AIRTEL_PUBLIC_KEY_PATH=airtel_pub.pem # disbursement only
python-dotenvpip install python-dotenv
from dotenv import load_dotenv import os from pyairtel import AirtelMoney load_dotenv() airtel = AirtelMoney( client_id=os.environ["AIRTEL_CLIENT_ID"], client_secret=os.environ["AIRTEL_CLIENT_SECRET"], sandbox=os.getenv("AIRTEL_SANDBOX", "true").lower() == "true", )
# .gitignore — already included in the repo .env *.pem # your RSA public key for disbursement
Production deployments
On servers and CI/CD pipelines, skip .env files entirely. Set AIRTEL_CLIENT_ID and AIRTEL_CLIENT_SECRET as native environment variables or secrets (e.g. GitHub Actions Secrets, Railway variables, Render environment). os.environ[...] picks them up automatically — no code changes needed.
Local Development
git clone https://github.com/ronaldgosso/pyairtel.git cd pyairtel
# Create venv python -m venv venv # Activate — Linux / Mac source venv/bin/activate # Activate — Windows venv\Scripts\activate # Confirm — should show venv path which python
pip install -e ".[dev]"
# For disbursement with RSA PIN encryption
pip install -e ".[dev,encryption]"
# Auto-fix ruff issues (imports, whitespace, deprecated types) ruff check pyairtel tests --fix --unsafe-fixes # Format with black black pyairtel tests
# Linting — should print: All checks passed! ruff check pyairtel tests # Formatting — should print: X files would be left unchanged black --check pyairtel tests # Type checking — should print: Success: no issues found mypy pyairtel
# Run all 25 tests with verbose output pytest tests/ -v # Run a specific test class pytest tests/ -v -k "TestCollection" # Run a single test pytest tests/ -v -k "test_collect_success"
deactivate
Error Codes