Python SDK
Accept payments, create hosted checkouts, send payouts and verify webhooks from Python with sync and async clients.
- Package
- teepay
- Install
- pip install teepay
- Runtime
- Python 3.9 or newer; httpx is the only dependency
- Clients
- TeePay (sync) and AsyncTeePay (async), fully typed
Install
pip install teepayCreate a client
import os
from teepay import TeePay
teepay = TeePay(os.environ["TEEPAY_API_KEY"]) # or TeePay() to read TEEPAY_API_KEY
print(teepay.environment) # "sandbox" or "live"
caps = teepay.capabilities()
print(caps.features.collection_create, caps.supported_countries)Options: timeout (seconds, default 30), max_retries (default 2), base_url (default
https://api.teepay.me), api_version (pins the TeePay-Version header, e.g. "2026-10-18") and
http_client (your own httpx.Client). Every method also accepts timeout= and max_retries=.
Mobile money push
Sends a USSD prompt to the customer's phone. See Mobile money push.
payment = teepay.collections.create(
money={"amount_minor": "500000", "currency": "TZS"}, # TZS 5,000.00
customer={"country": "TZ", "phone": "+255712345678", "name": "Asha Juma"},
description="Order 1001",
idempotency_key="order-1001",
)
print(payment.public_reference, payment.status) # "TP8K2M4QZ1" "pending"
# 202 means the prompt was sent, not that the customer paid.
latest = teepay.collections.retrieve(payment.public_reference)
if latest.status == "succeeded":
fulfil_order()Hosted checkout session
See Checkout sessions.
session = teepay.checkout_sessions.create(
title="Order 1001",
description="2 x Coffee bag",
amount_minor="500000",
currency="TZS",
reference="order-1001",
metadata={"cart": "c-77"}, # keys must be lowercase
idempotency_key="checkout-order-1001",
)
redirect(session.checkout_url)
# Later, on your server:
session = teepay.checkout_sessions.retrieve(session.id)
if session.status == "paid":
print(session.payment.reference)Control Numbers and payouts
number = teepay.control_numbers.create(
kind="bill",
customer={"name": "Asha Juma", "phone": "0712345678"},
description="School fees, term 1",
amount_minor="5000000",
idempotency_key="invoice-2026-0077",
)
if number.last_response.status == 202:
... # not issued yet: call create again later with the SAME idempotency_key
quote = teepay.disbursements.quote(
money={"amount": 2500000, "currency": "TZS"}, # payout quotes take an integer
destination={"channel": "mobile", "name": "Juma Ali", "phone": "255712345678"},
narration="Supplier payment October",
)
payout = teepay.disbursements.create(quote_id=quote.id, idempotency_key="payout-2026-10-0001")Lists
page = teepay.collections.list(limit=50, status="succeeded")
for item in page["items"]:
print(item.public_reference)
for payment in teepay.collections.list_all(status="succeeded"): # follows next_cursor
print(payment.public_reference)Results are dicts with attribute access (obj.status == obj["status"]). Use brackets for items,
which is also a dict method. Control Number lists put their rows in page["data"].
Async
import asyncio
from teepay import AsyncTeePay
async def main() -> None:
async with AsyncTeePay() as teepay:
session = await teepay.checkout_sessions.create(
title="Order 1001", amount_minor="500000", currency="TZS", idempotency_key="checkout-order-1001",
)
print(session.checkout_url)
async for payment in teepay.collections.list_all():
print(payment.public_reference)
asyncio.run(main())Verify webhooks
Pass the raw body bytes, before any JSON parsing.
import os
from flask import Flask, request
from teepay import WebhookVerificationError, verify_webhook
app = Flask(__name__)
@app.post("/webhooks/teepay")
def teepay_webhook():
try:
event = verify_webhook(request.get_data(), request.headers, os.environ["TEEPAY_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "", 400
if event.event == "payment.completed":
mark_paid(event.data.reference) # process each event.id once
return "", 200verify_webhook(payload, headers, secret, tolerance=300) checks the signature in constant time and
returns the event. client.webhooks.verify(...) is the same; client.webhooks.is_valid(...) returns a
bool, and generate_test_headers(payload, secret) signs a payload for your tests.
Errors
from teepay import AccountRestrictedError, ApiError, APIConnectionError, RateLimitError, ValidationError
try:
teepay.collections.create(money=money, customer=customer, idempotency_key="order-1001")
except ValidationError as e: # 422: invalid_request, unsupported_market, invalid_amount, ...
print(e.code, e.message)
except AccountRestrictedError: # 403 account_restricted: contact TeePay support
...
except RateLimitError as e: # 429: wait e.retry_after seconds
...
except APIConnectionError: # no response: retry with the SAME key, or read the payment
...
except ApiError as e: # anything else, e.g. 503
print(e.status, e.code, e.request_id)| Exception | When |
|---|---|
AuthenticationError | 401 invalid_credential |
PermissionError (alias PermissionDeniedError) | 403 scope_denied, access_denied, developer_access_denied |
AccountRestrictedError | 403 account_restricted (subclass of PermissionError) |
NotFoundError | 404 not_found |
ConflictError | 409 idempotency_conflict, expired payout quote |
ValidationError | 422 codes such as invalid_request and unsupported_market |
RateLimitError | 429 rate_limited, with retry_after |
ApiError | Any other HTTP error; base class of all of the above |
ConnectionError, TimeoutError (aliases APIConnectionError, APITimeoutError) | No HTTP response |
WebhookVerificationError | Bad webhook signature or timestamp |
PermissionError, ConnectionError and TimeoutError share names with Python built-ins; import the
aliases if you prefer. Request keys that are not lowercase raise ValueError before anything is sent.
Idempotency and retries
Collections, checkout sessions and Control Numbers send an Idempotency-Key header;
disbursements.create sends idempotency_key in the body. Without one, the SDK generates a key and
reuses it for its own retries. Derive keys from your own records so a retry after a restart cannot
create a second payment. See Idempotency.
Only GET requests and keyed creates are retried, on connection errors, timeouts, 408, 429 and
5xx, using Retry-After when present.
Response metadata
info = payment.last_response
info.status # 202
info.request_id # quote to support
info.rate_limit # RateLimit(limit=120, remaining=117, reset=42)
info.idempotent_replayed # True when an earlier result was returned