Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

tukupay

Official Python library for the TukuPay API: register users, give them wallets, collect and send money over M-Pesa, and send SMS, from your own server.

The library handles authentication, errors and safe retries for you, and returns TukuPay's responses unchanged, as plain dicts.

Beta. The interface may still change before 1.0. Pin the version you install.

Contents

Install

pip install --pre tukupay

Requires Python 3.9 or later. Its only dependency is httpx.

--pre is needed while the library is in beta; without it pip skips pre-release versions.

Before you start

TukuPay runs two separate environments. They share nothing: each has its own apps, users, wallets and API keys.

Environment Server Keys it issues Use it for Limit
Test https://test.api.tuku.money sk_test_... Building and testing your integration 60 requests a minute
Production https://api.tuku.money sk_live_... Real customers 600 requests a minute

To use an environment you need, on that environment:

  1. A TukuPay app. Your integration is an app. Users you register belong to it, and payments made through it are recorded against it.
  2. An API key for that app. Keys are issued per app from your TukuPay account; if you do not have one yet, ask your TukuPay contact. The full key is shown once, so store it in an environment variable or secret manager.

A key only exists on the environment that issued it. The library reads the prefix and talks to the matching server, so going live is a matter of creating a sk_live_ key on production and changing one environment variable:

TUKUPAY_API_KEY=sk_test_...   # testing and development
TUKUPAY_API_KEY=sk_live_...   # production

Users, wallets and ids do not carry over: a user registered on the test environment does not exist on production.

The test environment is not a sandbox. A payout made with a test key moves real money. When testing, only pay phone numbers you control, and use small amounts.

How it works

Every request carries up to two credentials. Knowing which is which explains the whole library.

Credential Answers Sent as Who holds it
API key Which app is calling? X-API-Key header Your server, and nobody else
User token Which user is acting? Authorization: Bearer header Your server, one per signed-in user
  • With the key alone you can read things about your app as a whole: its wallets, transactions and users, and send SMS. These live under tuku.app.
  • To act as a user (open a wallet, pay, upload KYC) you need the key and that user's token, plus their PIN whenever money leaves a wallet. These live on the object returned by tuku.as_user(tokens).

Your users never see the API key. They register and sign in with their phone number and password as on TukuPay itself; your server passes those to TukuPay and keeps the tokens it gets back.

Never put the API key in a web page, a mobile app or a public repository. Anyone holding it can act as your app.

Quick start

Check that your key works:

import os
from tukupay import TukuPay

tuku = TukuPay(os.environ["TUKUPAY_API_KEY"])

app = tuku.app.retrieve()
print(app["name"], app["status"], app["environment"])
# My Shop  active  test

If this prints your app's name, the key is valid and you are talking to the right environment.

Guide

The steps below take one user from nothing to a completed payment. Each builds on the one before.

1. Register a user

tuku.auth.register(
    phone_number="0712345678",
    password="a-strong-password",
    accepted_terms=True,
)
  • Leave app_id out. The user is placed under your app because the request carries your key.
  • phone_number accepts 07XXXXXXXX, 01XXXXXXXX or 2547XXXXXXXX.
  • accepted_terms must be true; the user has to have agreed to TukuPay's terms.

TukuPay texts the user a one-time code. Ask them for it and confirm their phone:

tuku.auth.verify_phone(phone_number="0712345678", otp_code="123456")

A user cannot sign in until their phone is verified. If the code did not arrive, send another with tuku.auth.send_phone_verification(phone_number).

2. Sign in and keep the session

tokens = tuku.auth.login(phone_number="0712345678", password="a-strong-password")

def save_tokens(fresh):
    # Called whenever the library renews the tokens. Save them.
    db.save_tokens(user_id, fresh["access_token"], fresh["refresh_token"])

user = tuku.as_user(tokens, on_token_refresh=save_tokens)

me = user.account.me()

login returns two tokens:

Token Lifetime Use
access_token short sent with every call made as the user
refresh_token longer, single use exchanged for a new pair when the access token expires

You do not manage this by hand. When a call fails because the access token has expired, the library exchanges the refresh token, repeats the call once, and gives you the new pair through the callback. Store the new pair every time: the old refresh token no longer works, so a stale one means the user has to sign in again.

Sign in once and reuse the tokens; signing a user in on every request will hit TukuPay's limit on sign-ins per hour. On a later request, rebuild the user from the tokens you stored:

saved = db.load_tokens(user_id)

user = tuku.as_user(
    access_token=saved.access_token,
    refresh_token=saved.refresh_token,
    on_token_refresh=save_tokens,
)

3. Give the user a wallet

A new user has no wallet. Opening one is a three-step identity check.

Step 1: send their details. TukuPay texts the user an OTP.

user.onboarding.initial(
    first_name="John",
    middle_name="K",
    last_name="Doe",
    country_code="254",
    document_type="1",
    document_number="12345678",
    email="john@example.com",
)

document_type is "1" for a national ID, "2" for a passport and "3" for an alien ID.

Step 2: confirm with the OTP.

user.onboarding.confirm(otp="123456")

A wrong or expired OTP raises GatewayError; the message says why.

Step 3: upload the identity documents.

with open("photo.jpg", "rb") as photo, open("id-front.jpg", "rb") as front, open("id-back.jpg", "rb") as back:
    user.onboarding.kyc(files={
        "passport_size_photo": ("photo.jpg", photo, "image/jpeg"),
        "document_image_front": ("id-front.jpg", front, "image/jpeg"),
        "document_image_back": ("id-back.jpg", back, "image/jpeg"),
    })

Check progress at any time:

status = user.onboarding.status()["status"]
status Meaning
not_started No onboarding yet
pending_otp Step 1 done; waiting for the OTP
wallet_created Step 2 done; the wallet exists, documents still needed
kyc_uploaded Documents sent; verification in progress
verified Approved; the wallet is fully usable
rejected Verification failed; the user must correct their documents

Once the wallet exists you can read it:

wallets = user.wallets.list()
wallet = wallets[0]
print(wallet["id"], wallet["alias"], wallet["balance"])

4. Set the payment PIN

Every payment that takes money out of a wallet needs the user's PIN (above KES 50,000, a one-time code sent to their phone instead; see step 6). Set it once:

user.account.setup_pin(pin="1234", confirm_pin="1234")

The library passes PINs straight to TukuPay and never stores or logs them. Do not store them either; ask the user each time.

5. Collect money by M-Pesa

This sends an M-Pesa prompt to a phone and puts the money in the user's wallet.

payment = user.payments.direct({
    "transaction_data": {
        "transaction_type": "WALLET_TOPUP",
        "amount": 150,
        "destination_wallet_id": wallet["id"],
    },
    "payment_source": {
        "payment_method": "mpesa",
        "phone_number": "0712345678",      # the phone that receives the M-Pesa prompt
    },
    "description": "Order 1042",
})

print(payment["transaction_id"], payment["status"])
instructions = (payment.get("type_result") or {}).get("customer_message")   # show this to the payer

Two things to do with the response:

  • Show the payer type_result.customer_message word for word, if present. Some payers do not receive the M-Pesa prompt, and this text gives them the PayBill steps to pay manually.
  • Do not treat this as paid. A successful response means the request was accepted. The payer still has to enter their M-Pesa PIN. Go to step 7 for the outcome.

No TukuPay PIN is needed here, because money is coming in.

6. Send money

This pays from the user's wallet to an M-Pesa number. Money is leaving a wallet, so the PIN is required.

payout = user.payments.direct({
    "transaction_data": {
        "transaction_type": "USER_TRANSFER",
        "amount": 500,
        "transfer_method": "mpesa",
        "destination_phone": "0798765432",
    },
    "payment_source": {
        "payment_method": "tukupay_wallet",
        "source_wallet_id": wallet["id"],
    },
    "pin": "1234",                         # asked from the user, never stored
})

Check first, if you want to show fees or catch problems early. validate takes the same payment and reports fees, limits and whether the balance covers it, without moving money:

check = user.payments.validate(payment_body)

Above KES 50,000 a one-time code replaces the PIN. Request the code, collect it from the user, and send the payment with otp_code and the payment_hash you were given, in place of pin:

otp = user.payments.request_otp(amount=75_000, transaction_type="USER_TRANSFER", source_wallet_id=wallet["id"])

user.payments.direct({**payment_body, "otp_code": "654321", "payment_hash": otp["payment_hash"]})

Other payment types use the same call with a different transaction_data:

transaction_type Does Key fields
WALLET_TOPUP M-Pesa into the user's own wallet amount, destination_wallet_id
STK_POS M-Pesa prompt to any payer, into a wallet amount, destination_wallet_id
USER_TRANSFER Wallet to an M-Pesa number or a TukuPay wallet alias amount, transfer_method, destination_phone or destination_alias
WALLET_TRANSFER Between two TukuPay wallets amount, destination_wallet_id
BANK_TRANSFER Wallet to a bank account amount, bank_name, account_number, account_name
BILL_PAYMENT Pay a Paybill or Till amount, receiver_merchant_code, account_reference, reason

user.payments.transaction_types() lists every type available to you with a short description. For a type or field not covered here, ask your TukuPay contact. To pay many M-Pesa numbers from one wallet in one call, use user.payments.bulk_transfer().

Paying twice by accident

Networks fail at the worst moment: your request may reach TukuPay while the reply never reaches you. To make a repeat safe, every payment carries an idempotency key. TukuPay remembers it, and a second request with the same key returns the first result without paying again.

  • The library generates one per call and reuses it for its own automatic retries. You get this for free.
  • If your code may retry (a queue job that can run twice, a user double-clicking), pass your own key built from something stable such as your order id:
user.payments.direct(payment_body, idempotency_key=f"order-{order.id}-attempt-1")
  • A deliberate new attempt after a failure is a new payment and needs a new key. Reusing the old key returns the old failure.

7. Find out what happened

A payment starts as pending and ends as completed or failed. wait reads it until it settles:

from tukupay import WaitTimeoutError, failure_of

try:
    transaction = user.transactions.wait(
        payment["transaction_id"],
        timeout=120,     # give up after 2 minutes
        interval=3,      # check every 3 seconds
    )
except WaitTimeoutError:
    ...  # Still pending. Keep the id and check again later.
else:
    if transaction["status"] == "completed":
        mark_order_paid(order.id)
    else:
        failure = failure_of(transaction) or {}
        print(failure.get("category"), failure.get("hint"))
        # cancelled_by_payer  The payer cancelled the STK prompt on their handset.

wait returns for both outcomes, so always check status. If time runs out it raises WaitTimeoutError; the payment may still complete later, so keep the transaction id and check again rather than assuming failure.

wait blocks while it polls. In a web handler it is usually better to return to the user straight away and check the outcome from a background task or when they reload the page, using transactions.get(id).

When a payment fails, failure_of(transaction) gives the reason in a form you can act on:

Field Meaning
category A stable label, e.g. cancelled_by_payer
gateway_code The gateway's specific code, e.g. 1032. Use this, not code
hint A plain-language explanation
retryable Whether trying again could succeed
code The gateway's general code; nearly always the same generic value

8. See everything your app has done

These calls need only the API key and cover every user of your app. They suit back-office screens, reconciliation and reports.

from datetime import date

wallets = tuku.app.wallets.list(limit=50)
wallet = tuku.app.wallets.get(28)

page = tuku.app.transactions.list(status="completed", start_date=date(2026, 10, 1), limit=100)
print(page["total"], page["has_more"])

transaction = tuku.app.transactions.get(1042)
users = tuku.app.users.list(limit=20)["users"]

Lists return at most 100 rows per call. To walk a whole list without handling pages yourself:

for transaction in tuku.app.transactions.iterate(status="completed"):
    print(transaction["id"], transaction["amount"])

Transaction filters: transaction_type, status (pending, completed, failed), wallet_id, start_date, end_date, limit, offset.

Your key only ever sees your own app. Asking for another app's wallet or transaction gives NotFoundError, the same as one that does not exist.

9. Send SMS

Messages go out on the app owner's TukuPay account and use the owner's SMS credits.

queued = tuku.app.sms.send(
    phone_numbers=["0712345678", "0798765432"],
    message="Your order has shipped.",
)

outbox = tuku.app.sms.outbox(limit=20)
one = tuku.app.sms.status(outbox[0]["id"])
print(one["phone_number"], one["status"])
  • send queues the message. The message_id it returns identifies the queued batch, not the individual texts.
  • To see what was delivered, read the outbox. Each row is one recipient, with a status of success, failed or pending.
  • Credits are taken when the message is actually sent, not when it is queued.
  • A custom sender_id must be one approved on the owner's account.

Errors

Every failure raises a TukuPayError or one of its subclasses. Each carries:

  • str(error): a readable message.
  • error.status: the HTTP status, or None if no response arrived.
  • error.errors: exactly what the API sent in errors. For a validation failure this is a list with one entry per bad field.
  • error.body: the whole response body.
Class HTTP When What to do
AuthenticationError 401 Key or token missing, wrong, revoked, expired, or for the other environment Check the key; sign the user in again
PermissionDeniedError 403 Not allowed: a wrong or locked PIN, an unverified phone, an app that is suspended or pending Read the message. For a wrong PIN, ask the user again; never retry on your own
NotFoundError 404 Does not exist, or belongs to another app Check the id
ValidationError 400, 422 The request was refused as sent Fix the fields named in errors
RateLimitError 429 Too many requests this minute Wait error.retry_after seconds
GatewayError 200 The payment gateway refused, e.g. a wrong onboarding OTP Show the message; let the user retry
APIError 5xx A fault on TukuPay's side Retry later; contact TukuPay if it persists
APIConnectionError none No response: network failure or timeout Retry; for a payment, reuse the same idempotency key
WaitTimeoutError none wait ran out of time Check the transaction again later
from tukupay import RateLimitError, TukuPayError, ValidationError

try:
    user.payments.direct(payment_body)
except ValidationError as error:
    show_to_user(str(error))               # "amount: Input should be greater than 0"
except RateLimitError as error:
    retry_in(error.retry_after)
except TukuPayError as error:
    log(error.status, error.errors)

Retries, timeouts and rate limits

Retries. A failed call is retried up to twice, waiting a little longer each time.

Situation Retried?
A read (GET) fails with a network error or 502/503/504 Yes
Any call gets 429 Yes, after the wait the API asks for, if that is 20 seconds or less
A payment (direct, bulkTransfer) fails with a network error or 502/503/504 Yes, with the same idempotency key
Any other write (register, SMS, onboarding) fails No. It is sent once, because repeating it could do the thing twice

Timeouts. Each request is given 30 seconds by default.

Rate limits. Limits are per key, per minute. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. If you are regularly limited, spread the work out or ask TukuPay to raise the limit on your key.

Configuration

tuku = TukuPay(
    os.environ["TUKUPAY_API_KEY"],
    timeout=30.0,
    max_retries=2,
    debug=True,
)
Option Default Meaning
api_key required Your app's key (the first argument)
base_url from the key Override the server, e.g. http://localhost:8000 for local development
timeout 30.0 Seconds allowed for each request
max_retries 2 Retries after the first attempt; 0 turns retrying off
max_retry_delay 20.0 A rate-limit wait longer than this many seconds is raised instead of waited out
http_client a new one Your own httpx.Client, for a proxy or for tests
debug False True logs each call's method, path and status to the tukupay logger at DEBUG level; or pass a function to receive the line. Keys, tokens, PINs and bodies are never logged

The client holds a connection pool. Create it once and reuse it; call tuku.close() on shutdown, or use it as a context manager (with TukuPay(...) as tuku:).

Reference

Every call returns the API's JSON as a dict or list. The client is synchronous and safe to share between threads; an async client is planned.

tuku.app: the API key alone

Call Returns
tuku.app.retrieve() The app this key belongs to: id, name, status, app_type, key_type, environment
tuku.app.wallets.list(limit=, offset=) List of the app's wallets
tuku.app.wallets.get(wallet_id) One wallet
tuku.app.wallets.iterate(page_size=100) Iterator over every wallet
tuku.app.transactions.list(**filters) {transactions, total, size, page, has_more}
tuku.app.transactions.get(transaction_id) One transaction; never cached
tuku.app.transactions.wait(transaction_id, timeout=120, interval=3) The transaction once it is completed or failed
tuku.app.transactions.iterate(page_size=100, **filters) Iterator over every matching transaction
tuku.app.users.list(limit=, offset=) {app_id, users, total, size, page, has_more}
tuku.app.users.iterate(page_size=100) Iterator over every user
tuku.app.sms.send(phone_numbers=, message=, sender_id=None) {success, message, message_id, recipient_count, queued_at}
tuku.app.sms.outbox(limit=, offset=) List of sent messages, newest first
tuku.app.sms.status(sms_id) One message from the outbox

tuku.auth: the API key alone

Call Returns
tuku.auth.register(phone_number=, password=, accepted_terms=) {message}; the user is texted an OTP
tuku.auth.verify_phone(phone_number=, otp_code=) {message}
tuku.auth.send_phone_verification(phone_number) {message}; sends the OTP again
tuku.auth.login(phone_number=, password=) {access_token, refresh_token, token_type}
tuku.auth.refresh(refresh_token) A new token pair. Normally the library does this for you
tuku.as_user(tokens, on_token_refresh=) A client that acts as that user. Also accepts access_token= and refresh_token=

As a user: key and token

Obtained from tuku.as_user(tokens).

Call Returns
user.tokens The user's current {access_token, refresh_token}
user.account.me() The user's profile
user.account.setup_pin(pin=, confirm_pin=) {message}
user.account.verify_pin(pin) {message}; raises if the PIN is wrong
user.account.logout() {message}; the tokens stop working
user.onboarding.initial(**details) The gateway's reply; the user is texted an OTP
user.onboarding.confirm(otp=, request_id=None) The gateway's reply; the wallet now exists
user.onboarding.kyc(files=, data=None) The gateway's reply
user.onboarding.status() {status, wallet_count, ...}; see the status table in step 3
user.kyc.upload(files=, data=) {document, analysis, message}. data={"doc_type": ...}, files={"file": ...}
user.kyc.list() List of the user's KYC documents
user.kyc.get(document_id) One KYC document
user.wallets.list() List of the user's wallets
user.wallets.get(wallet_id) One wallet
user.wallets.create(**wallet) A further wallet for a user who already has one
user.payments.methods(include_unavailable=None) The payment methods currently enabled
user.payments.transaction_types() The transaction types and their fields
user.payments.validate(payment) Fees, limits and balance check; no money moves
user.payments.request_otp(amount=, transaction_type=, source_wallet_id=None) {message, payment_hash, expires_in}
user.payments.direct(payment, idempotency_key=None) {status, message, transaction_id, transaction_type, amount, payment_method, type_result, ...}
user.payments.bulk_transfer(transfer, idempotency_key=None) {status, transaction_id, total_recipients, successful_transfers, failed_transfers, transfer_results, ...}
user.transactions.list(**filters) {transactions, total, size, page, has_more}
user.transactions.get(transaction_id) One transaction; never cached
user.transactions.wait(transaction_id, timeout=120, interval=3) The transaction once it is completed or failed
user.transactions.iterate(page_size=100, **filters) Iterator over every matching transaction
user.sms.send(phone_numbers=, message=, sender_id=None) Queues an SMS on the user's own credits
user.sms.outbox(**filters) {sms_records, total, size, page, has_more}
user.sms.balance() {sms_balance, total_spent, total_earned, ...}

Helpers

Call Returns
failure_of(transaction) The failure details of a failed transaction, or None
tukupay.__version__ The library version

Troubleshooting

Invalid or revoked API key. One of:

  • The key is mistyped or was revoked.
  • It was rotated more than 24 hours ago, so the old key has expired.
  • It belongs to the other environment. A sk_test_ key is unknown to production and a sk_live_ key is unknown to the test environment. Remove any base URL override so the library picks the server from the key.

App not approved or not found. Your app has not been approved yet or has been suspended. Contact TukuPay.

Please verify your phone number before logging in. Finish step 1: confirm the OTP.

Too many login attempts. TukuPay allows a limited number of sign-ins per user per hour (about 10). Sign a user in once, store the tokens and reuse them; do not sign in on every request.

The user is asked to sign in again unexpectedly. A refresh token was used twice. Make sure the token callback saves the new pair, and that two servers are not refreshing the same user from the same old token.

Incorrect PIN or PIN is locked. The user entered the wrong PIN. Ask them again. After several wrong attempts the PIN locks for a while, so never retry a payment automatically after this error.

A payment stays pending. The payer has not responded to the M-Pesa prompt, or the gateway has not reported back yet. Keep checking the same transaction; do not start a second payment for the same order.

I retried and got the same failure. You reused the idempotency key. A new attempt needs a new key.

Versioning

This library follows semantic versioning. While it is 0.x, a minor release may change the interface; see CHANGELOG.md. It ignores response fields it does not know, so additions to the API do not break it.

Support

Report problems at https://github.com/tukupay/tukupay-sdk/issues. Never include an API key, token or PIN in an issue.

License

MIT

Metadata

Release files for tukupay 0.1.0b1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tukupay 0.1.0b1
File Size Uploaded Provenance
tukupay-0.1.0b1.tar.gz 23.3 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for tukupay 0.1.0b1
File Interpreter ABI Platform
tukupay-0.1.0b1-py3-none-any.whl Python 3 none any Details

Total release size: 47.4 kB

Release files / tukupay-0.1.0b1.tar.gz

Download URL tukupay-0.1.0b1.tar.gz
Size 23.3 kB
Tags Source
SHA-256 checksum
How to use checksums
f9abf671bb58496e39722bbdbbb7c0bb5d309552d49e38a8198a0fd255f0d9f2
BLAKE2b-256 checksum
How to use checksums
b88d0832d34ccf737b5b890dc52fa6f78c3e78d745a7f053af8fc214da587bad
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.

Transparency log

Release files / tukupay-0.1.0b1-py3-none-any.whl

Download URL tukupay-0.1.0b1-py3-none-any.whl
Size 24.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a95d85f9bff57db3836055afeead6be257be910d31b665dd029f083e2042882d
BLAKE2b-256 checksum
How to use checksums
32a0e5200b06e8d26c9a2589102b107350f437c9682967cf085c95ebfdcae16e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.

Transparency log

Security for tukupay 0.1.0b1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for tukupay-0.1.0b1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page