TeePayDocs
SDKs

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 text

Lists 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)
kindHelperWhen
authentication_erroris_authentication()401 invalid, revoked, expired or wrong-environment key
permission_erroris_permission()403 scope_denied, access_denied, developer_access_denied
account_restricted_erroris_account_restricted() (and is_permission())403 account_restricted
not_found_erroris_not_found()404
conflict_erroris_conflict()409 idempotency_conflict, payout quote expired
validation_erroris_validation()400/422
rate_limit_erroris_rate_limit()429 (retry_after)
api_erroris_api_error() (true for all of the above)other statuses, e.g. 5xx
connection_error / timeout_erroris_connection() / is_timeout()no response
webhook_verification_erroris_webhook_verification()webhook signature or timestamp invalid
configuration_erroris_configuration()missing or malformed key, bad options
invalid_argumentis_invalid_argument()empty path parameter, non-lowercase JSON key, parsed webhook payload
internal_errorunexpected 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.create and control_numbers.create send an Idempotency-Key header; disbursements.create sends idempotency_key in 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_replayed is True for replayed checkout sessions). Same key + different body raises a conflict_error.
  • The SDK retries (default 2 times, exponential backoff with full jitter capped at 8 s, honouring Retry-After up 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 request

Response 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 prefixteepay.environmentUse
tp_test_sandboxAuthentication, scopes, capability discovery and error handling
tp_live_liveReal 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

ArgumentDefault
api_keyTEEPAY_API_KEY envtp_test_... or tp_live_...
base_urlhttps://api.teepay.me
timeout30.0seconds per attempt
max_retries2
api_version""sends TeePay-Version when set
transporturlliba 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.

On this page