Mojo SDK (preview)
Accept payments, create hosted checkouts, send payouts and verify webhooks from Mojo. Preview release.
- Package
- teepay (Mojo package in src/)
- Install
- git clone, then mojo run -I vendor/teepay-mojo/src
- Runtime
- Mojo 1.2 or newer on Linux or macOS, CPython 3.10+ (via pixi)
- Status
- Preview: not yet compiled by the maintainers
Preview
Version 0.1.0 is written for Mojo 1.2 but has not been compiled yet. HTTP, JSON and webhook signatures go through Mojo's Python interop and Python's standard library, and that part is tested. Please report any compile errors on GitHub.
Install
Mojo runs on Linux and macOS. With pixi:
pixi init my-app -c https://conda.modular.com/max -c conda-forge
cd my-app
pixi add "mojo>=1.2.0,<2" "python>=3.10"
git clone https://github.com/kraftonnotgar-spec/teepay-mojo vendor/teepay-mojo
pixi run mojo run -I vendor/teepay-mojo/src main.mojo-I points the compiler at the teepay package in src/.
Create a client
Create a secret API key in the TeePay dashboard (Developers → API keys) and keep it on your server.
from teepay import TeePay
def main() raises:
var teepay = TeePay() # reads TEEPAY_API_KEY; or TeePay("tp_test_...")
var caps = teepay.capabilities()
print(caps.string("environment"), caps.boolean("features.collection_create"))Mobile money push
from teepay import CollectionCustomer, Money, TeePay
def main() raises:
var teepay = TeePay()
var payment = teepay.collections.create(
Money("500000", "TZS"), # TZS 5,000.00 in minor units
CollectionCustomer("TZ", "+255712345678", name="Asha Juma"),
description="Order 1001",
idempotency_key="order-1001",
)
print(payment.string("public_reference"), payment.string("status")) # TP8K2M4QZ1 pending
# 202 means the prompt was sent, not that the customer paid.
# Wait for the payment.completed webhook, or poll:
var latest = teepay.collections.retrieve(payment.string("public_reference"))
if latest.string("status") == "succeeded":
print("paid")Minor-unit amounts are strings of digits, as the API expects. Convert an Int with String(n).
Hosted checkout session
from std.collections import Dict
from teepay import TeePay
def main() raises:
var teepay = TeePay()
var metadata = Dict[String, String]()
metadata["cart"] = "c-77"
var session = teepay.checkout_sessions.create(
"Order 1001", "500000", "TZS",
description="2 x Coffee bag",
reference="order-1001",
metadata=metadata,
idempotency_key="checkout-order-1001",
)
print(session.string("checkout_url")) # redirect the customer here
# Later: status becomes "paid" only after TeePay verified the payment.
var latest = teepay.checkout_sessions.retrieve(session.string("id"))
if latest.string("status") == "paid":
print(latest.string("payment.reference"))Control Numbers and payouts
from teepay import ControlNumberCustomer, PayoutDestination, PayoutMoney, TeePay
def main() raises:
var teepay = TeePay()
var number = teepay.control_numbers.create(
"bill",
ControlNumberCustomer(name="Asha Juma", phone="0712345678"),
description="School fees, term 1",
amount_minor="5000000",
idempotency_key="invoice-2026-0077",
)
if number.last_response.status == 202: # still being issued
pass # call create again later with the SAME idempotency_key
var quote = teepay.disbursements.quote(
PayoutMoney(2500000, "TZS"), # payout quotes take an integer amount
PayoutDestination.mobile("Juma Ali", "255712345678"),
"Supplier payment October",
)
var payout = teepay.disbursements.create(quote.string("id"), idempotency_key="payout-2026-10-0001")
print(payout.string("status"))Bank payouts use PayoutDestination.bank(name, bank, account).
Reading results
Every call returns a TeePayObject: the API's JSON (field names unchanged) plus last_response.
The typed helpers take dotted paths and never raise for a missing field:
payment.string("money.currency") # "TZS" ("" if missing)
payment.integer("money.amount_minor") # 500000 (JSON numbers or digit strings)
caps.boolean("features.checkout_sessions") # True only for JSON true
page.string("items.0.public_reference") # list indexes work too
payment.has("fee") # present and not null
payment["status"] # the raw PythonObject
payment.get("money") # raw value at a path
payment.to_json() # the whole object as JSON textLists and pagination
var page = teepay.collections.list(limit=50, status="succeeded")
for item in page.items(): # `items`, or `data` for Control Numbers
print(item["public_reference"])
var cursor = page.next_cursor() # "" on the last page
for row in teepay.collections.list_all(status="succeeded"): # follows next_cursor
print(row["public_reference"])list_all loads every page into a List[PythonObject]; cap it with max_pages=.
Verify webhooks
TeePay signs every webhook with your signing secret (whsec_..., from Developers → Webhooks).
Verify the raw request body before trusting it. verify_webhook checks the X-TeePay-Signature
HMAC in constant time and rejects timestamps more than 5 minutes away.
from std.collections import Dict
from teepay import verify_webhook
def handle(raw_body: String, headers: Dict[String, String], secret: String) -> Int:
try:
var event = verify_webhook(raw_body, headers, secret)
if event.event == "payment.completed":
print("paid:", event.string("data.reference")) # de-duplicate on event.id
return 200
except e:
return 400 # e.kind == "webhook_verification_error"If your HTTP server is a Python framework driven through Mojo's Python interop, pass its objects
directly: verify_webhook_python(request_body_bytes, request.headers, secret). Passing parsed JSON
instead of the raw body raises an invalid_argument error.
teepay.webhooks.verify(...), teepay.webhooks.is_valid(...) and teepay.webhooks.compute_signature(...)
do the same on a client. Use generate_test_headers(payload, secret) to build signed requests in your
own tests.
Errors
Mojo has no exception classes, so every SDK call is declared raises TeePayError, and the error
carries a kind. Catching it gives you the typed struct:
try:
_ = teepay.collections.create(...)
except e: # e is a TeePayError
if e.is_validation(): # 400/422: e.code is invalid_request, unsupported_market, ...
print(e.code, e.message)
elif e.is_account_restricted():
print("contact TeePay support")
elif e.is_permission(): # 403 scope_denied / access_denied
...
elif e.is_rate_limit(): # 429 after retries; wait e.retry_after seconds
...
elif e.is_connection(): # network error or timeout: the payment may still exist
... # retry with the SAME idempotency key, or read it
else:
print(e.status, e.code, e.request_id)kind | Helper | When |
|---|---|---|
authentication_error | is_authentication() | 401 invalid, revoked, expired or wrong-environment key |
permission_error | is_permission() | 403 scope_denied, access_denied, developer_access_denied |
account_restricted_error | is_account_restricted() (and is_permission()) | 403 account_restricted |
not_found_error | is_not_found() | 404 |
conflict_error | is_conflict() | 409 idempotency_conflict, payout quote expired |
validation_error | is_validation() | 400/422 |
rate_limit_error | is_rate_limit() | 429 (retry_after) |
api_error | is_api_error() (true for all of the above) | other statuses, e.g. 5xx |
connection_error / timeout_error | is_connection() / is_timeout() | no response |
webhook_verification_error | is_webhook_verification() | webhook signature or timestamp invalid |
configuration_error | is_configuration() | missing or malformed key, bad options |
invalid_argument | is_invalid_argument() | empty path parameter, non-lowercase JSON key, parsed webhook payload |
internal_error | unexpected failure inside the SDK or CPython |
Every error has kind, status (0 when there was no response), code, message, request_id,
retry_after (-1 when absent) and body. Quote request_id when you contact support. Messages
never contain your API key.
TeePay rejects request JSON whose object keys are not all lowercase (metadata keys included); the SDK checks this before sending.
Idempotency and retries
collections.create,checkout_sessions.createandcontrol_numbers.createsend anIdempotency-Keyheader;disbursements.createsendsidempotency_keyin the body.- If you do not pass
idempotency_key=, the SDK generates one (sdk-<uuid>) per call and reuses it for that call's retries. Prefer keys derived from your own records (order-1001) so a retry after your process restarts cannot create a second payment. - Same key + same body returns the original result (
last_response.idempotent_replayedisTruefor replayed checkout sessions). Same key + different body raises aconflict_error. - The SDK retries (default 2 times, exponential backoff with full jitter capped at 8 s, honouring
Retry-Afterup to 60 s) only when it is safe:GETs and keyed creates on connection errors, timeouts, 408, 429 and 5xx (500/502/503/504). A payout quote is retried only on 429. Other 4xx errors are never retried.
var teepay = TeePay(timeout=20.0, max_retries=3)
_ = teepay.collections.retrieve("TP8K2M4QZ1", timeout=5.0, max_retries=0) # per requestResponse metadata
var info = payment.last_response
info.status # 202
info.request_id # for support
info.has_rate_limit # True when RateLimit-* headers were sent
info.rate_limit.remaining # e.g. 117 (-1 when a header was absent)
info.idempotent_replayed # True when TeePay replayed an earlier result
info.api_version # TeePay-Version response header
info.headers # all headers (Python dict, lowercase names)Sandbox and live
| Key prefix | teepay.environment | Use |
|---|---|---|
tp_test_ | sandbox | Authentication, scopes, capability discovery and error handling |
tp_live_ | live | Real money |
Both use https://api.teepay.me; the key decides the environment. Money movement (mobile money push,
checkout, Control Numbers, payouts) currently runs in live only, so check teepay.capabilities() to see
what a key can do. Pass api_version="2026-10-18" to send a TeePay-Version header and have requests
rejected if that version is no longer served.
Never put a secret key in a browser, mobile app or public repository. The client never logs, and
print(teepay) shows only the environment and base URL.
Configuration
| Argument | Default | |
|---|---|---|
api_key | TEEPAY_API_KEY env | tp_test_... or tp_live_... |
base_url | https://api.teepay.me | |
timeout | 30.0 | seconds per attempt |
max_retries | 2 | |
api_version | "" | sends TeePay-Version when set |
transport | urllib | a Python callable (method, url, headers, body, timeout) -> (status, headers, body), for tests or proxies |
Optional text parameters use "" for "not sent" and optional integers use 0, because they map
one-to-one to fields the API treats as absent.