STK Push
Initiate secure M-Pesa payments using STK Push (Lipa Na M-Pesa Online). This triggers a payment prompt on the user's phone to enter their M-Pesa PIN.
User Story
As an eβcommerce merchant, I want to prompt customers to approve payments on their phones so they can complete purchases quickly and securely using MβPesa STK Push. When a customer checks out, my backend triggers an STK Push request; the customer receives a prompt to enter their MβPesa PIN; once the payment is confirmed via the webhook callback, the order is processed.
- Actors: Merchant, Customer, Developer (integrator)
- When: At checkout or when accepting a customer-initiated payment
- Why: Fast, secure, and familiar payment flow for users with MβPesa
- Outcome: Customer authorizes payment on their phone; merchant receives confirmation through the callback and completes the order.
Parameters Definition
| Parameter | Type | Description |
|---|---|---|
business_short_coderequired int | Integer | Your PayBill or BuyGoods number. Use 174379 in sandbox environment. |
amountrequired int | Integer | Transaction amount in KES. Minimum: 1. |
phone_numberrequired str | String | Customer's phone number that receives the STK PIN prompt (usually same as party_a). |
callback_urlrequired str | String | HTTPS endpoint to receive payment results. Must be publicly accessible. |
account_referencerequired str | String | Transaction identifier like order ID. Maximum 12 characters. |
transaction_descrequired str | String | Description displayed to user. Maximum 13 characters. |
transaction_typerequired str | String | Type of transaction. Use 'CustomerPayBillOnline' or 'CustomerBuyGoodsOnline'. |
party_arequired int | Integer | Phone number of the customer initiating the payment (payer). |
party_brequired int | Integer | Receiving shortcode (merchant/organization). Usually same as business_short_code. |
passkey str | String | Passkey used in STK Push / password generation. Optional if Password and Timestamp are provided. |
password str | String | Base64-encoded password (shortcode+passkey+timestamp). Optional if Passkey is provided. |
timestamp str | String | Timestamp used when generating the password. Format: YYYYMMDDHHMMSS. |
M-Pesa STK Push Integration
Easily integrate M-Pesa STK Push (Lipa Na M-Pesa Online) into your Python applications using mpesakit. This guide covers the recommended MpesaClient approach (sync and async) and direct API usage for advanced control.
- You can choose
MpesaClientfor a simple facade that handlestoken management,defaultsanderror handlingfor you, or use the Direct API (StkPush,TokenManager,MpesaHttpClient) when you need full control overrequest construction,middleware, andcustom workflows. - Every option is available in both sync and async flavors. Reach for the async variants (
AsyncMpesaClient,AsyncStkPushService) when you're integrating inside an async framework like FastAPI, or when you need to fire off multiple STK requests concurrently without blocking. - Use
MpesaClientfor fast integration and theDirect APIfor advanced customization.
For quick and easy integration, use the MpesaClient which abstracts away token management and error handling.
Quick Setup (Sync)
import osfrom dotenv import load_dotenvfrom mpesakit import MpesaClientfrom mpesakit.mpesa_express import TransactionType # Enum for transaction types (eg. CUSTOMER_PAYBILL_ONLINE, CUSTOMER_BUYGOODS_ONLINE)
load_dotenv()
client = MpesaClient( consumer_key=os.getenv("MPESA_CONSUMER_KEY"), consumer_secret=os.getenv("MPESA_CONSUMER_SECRET"), environment="sandbox",)
# Send STK Pushresponse = client.stk_push( business_short_code=int(os.getenv("MPESA_SHORTCODE")), passkey=os.getenv("MPESA_PASSKEY"), # It can be used instead of Password and Timestamp fields (optional) transaction_type=TransactionType.CUSTOMER_PAYBILL_ONLINE, amount=1, party_a=os.getenv("MPESA_PHONE_NUMBER"), party_b=os.getenv("MPESA_SHORTCODE"), phone_number=os.getenv("MPESA_PHONE_NUMBER"), callback_url="https://example.com/callback", account_reference="Test123", transaction_desc="Test Payment", timestamp="20231201120000", # it exists together with the Password field (optional) password="custom_generated_password" # it exists together with the Timestamp field (optional))- You can provide either the
Passkeyor bothPasswordandTimestamp. If you provide thePasskey, the SDK will generate thePasswordandTimestampfor you. - The
PasswordandTimestampfields must coexist since thePasswordis derived from theTimestamp(Password = shortcode + "your_passkey" + Timestamp).
Quick Setup (Async)
AsyncMpesaClient mirrors MpesaClient field-for-field but is built on httpx's async client under the hood. Use it inside async def route handlers (FastAPI, Starlette) or anywhere you're already running an event loop, so the STK request doesn't block other requests while waiting on Safaricom's API.
import osimport asynciofrom dotenv import load_dotenvfrom mpesakit import AsyncMpesaClientfrom mpesakit.mpesa_express import TransactionType
load_dotenv()
async def main(): # Use as an async context manager so the connection pool is closed for you async with AsyncMpesaClient( consumer_key=os.getenv("MPESA_CONSUMER_KEY"), consumer_secret=os.getenv("MPESA_CONSUMER_SECRET"), environment="sandbox", ) as client: response = await client.stk_push( business_short_code=int(os.getenv("MPESA_SHORTCODE")), passkey=os.getenv("MPESA_PASSKEY"), transaction_type=TransactionType.CUSTOMER_PAYBILL_ONLINE, amount=1, party_a=os.getenv("MPESA_PHONE_NUMBER"), party_b=os.getenv("MPESA_SHORTCODE"), phone_number=os.getenv("MPESA_PHONE_NUMBER"), callback_url="https://example.com/callback", account_reference="Test123", transaction_desc="Test Payment", ) print(response)
asyncio.run(main())If you're wiring AsyncMpesaClient into a long-lived app (e.g. as a FastAPI dependency), construct it once at startup and call await client.aclose() on shutdown instead of using async with on every request:
client = AsyncMpesaClient(
consumer_key=os.getenv("MPESA_CONSUMER_KEY"),
consumer_secret=os.getenv("MPESA_CONSUMER_SECRET"),
environment="sandbox",
)
# ... use client.stk_push(...) across requests ...
# on shutdown
await client.aclose()
Response
Response is an StkPushSimulateResponse Pydantic object (the async client returns the exact same schema, just via await). If you prefer it as a dictionary, you can convert it using:
response_dict = response.model_dump(mode="json") # Convert to dictionaryFrom here on we are going to use it as an object due to the rich methods it provides. We can check if the request was successful by:
if response.is_successful: print("Request accepted")else: print(f"Error: {response.ResponseDescription}") # Get error messageAny error in the request will raise an MpesaError exception, which we can catch and handle appropriately. We will see how to utilize this later on.
Success Response
Response Schema
StkPushSimulateResponse β the synchronous acknowledgement returned by client.stk_push(...)
| Parameter | Type | Description |
|---|---|---|
MerchantRequestIDrequired str | String | Global unique identifier for the submitted payment request. |
CheckoutRequestIDrequired str | String | Global unique identifier for the processed checkout transaction request. Use this to correlate with the later callback or an StkQuery. |
ResponseCoderequired int | Integer | 0 means the request was accepted for processing. |
ResponseDescriptionrequired str | String | Acknowledgment message about the request submission status. |
CustomerMessagerequired str | String | Message that can be shown to the customer, e.g. 'Enter your PIN to complete payment'. |
{"MerchantRequestID": "29115-34620561-1","CheckoutRequestID": "ws_CO_191220191020363925","ResponseCode": "0","ResponseDescription": "Success. Request accepted for processing","CustomerMessage": "Enter your PIN to complete payment"}- While using the
StkPushSimulateResponseobject, we can access the fields with ease:
print(response.MerchantRequestID) # e.g 29115-34620561-1print(response.CheckoutRequestID) # e.g ws_CO_191220191020363925print(response.ResponseCode) # e.g 0print(response.ResponseDescription) # e.g Success. Request accepted for processingprint(response.CustomerMessage) # e.g Enter your PIN to complete payment- This is a much cleaner way of accessing the response fields, since we avoid dealing with raw dictionaries that may lead to typos when accessing keys.
A successful response doesn't guarantee payment completion. Monitor your callback URL for the final payment status.
Error Handling
from mpesakit.errors import MpesaApiException
try: response = client.stk_push( business_short_code=int(os.getenv("MPESA_SHORTCODE")), passkey=os.getenv("MPESA_PASSKEY"), transaction_type=TransactionType.CUSTOMER_PAYBILL_ONLINE, amount=1, party_a=os.getenv("MPESA_PHONE_NUMBER"), party_b=os.getenv("MPESA_SHORTCODE"), phone_number=os.getenv("MPESA_PHONE_NUMBER"), callback_url="https://example.com/callback", account_reference="Test123", transaction_desc="Test Payment", ) data = response.model_dump(mode="json")except MpesaApiException as e: err = e.error print("M-Pesa API error:") print(f" Code: {err.error_code}") # e.g AUTH_INVALID_CREDENTIALS print(f" Message: {err.error_message}") # e.g Invalid credentials provided. Please check your consumer key and secret. print(f" HTTP status: {err.status_code}") # e.g 400 print(f" Request ID: {err.request_id}")except Exception as exc: print(f"Unexpected error: {exc}")- The
MpesaApiExceptionprovides detailed error information including error code, message, HTTP status, and raw response for effective debugging. - Use what you need from the error object to log or handle errors appropriately. This behaves identically whether you're on
MpesaClientorAsyncMpesaClient.
Easier Error Handling with MpesaClientβ
- We can do this to simplify and have efficient error handling and logging with
MpesaClient(orawaitthe same call onAsyncMpesaClient):
from mpesakit.errors import MpesaApiException
try: response = client.stk_push( YOUR_PARAMETERS=HERE ) data = response.model_dump(mode="json")except MpesaApiException as e: print(f"M-Pesa API error: {str(e)}")except Exception as exc: print(f"Unexpected error: {exc}")Handling the Callback
A successful stk_push response only means Safaricom accepted the request for processing β it does not mean the customer has paid. The actual payment outcome (success, cancellation, insufficient funds, timeout) is delivered asynchronously to the callback_url you supplied, as an HTTP POST. You should expose an endpoint there and use the client's built-in process_stk_callback helper to validate and parse the payload into a typed object rather than working with a raw dict.
- Must be a publicly reachable HTTPS endpoint (Safaricom cannot reach
localhost; use a tunnel likengrokin sandbox). - Should respond quickly with a
200 OKacknowledging receipt β do your heavier processing (updating orders, notifying users) after acknowledging, or in a background task. - Design the handler to be idempotent. Safaricom may retry delivery, so the same
CheckoutRequestIDcan arrive more than once.
from fastapi import FastAPI, Requestfrom mpesakit import MpesaClient
app = FastAPI()client = MpesaClient( consumer_key="...", consumer_secret="...", environment="sandbox",)
@app.post("/mpesa/stk-callback")async def stk_callback(request: Request): payload = await request.json()
# Validates and parses the payload into an StkPushSimulateCallback object callback = client.process_stk_callback(payload)
if callback.is_successful: amount = callback.amount receipt = callback.mpesa_receipt_number phone = callback.phone_number # e.g. mark the matching order as paid using callback.Body.stkCallback.CheckoutRequestID print(f"Payment of {amount} received from {phone}, receipt {receipt}") else: # e.g. customer cancelled the prompt or had insufficient funds print(f"Payment failed: {callback.Body.stkCallback.ResultDesc}")
# Acknowledge receipt so Safaricom stops retrying return {"ResultCode": 0, "ResultDesc": "Accepted"}The raw payload passed to process_stk_callback looks like this on success:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{ "Name": "Amount", "Value": 1 },
{ "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
{ "Name": "TransactionDate", "Value": 20231201120000 },
{ "Name": "PhoneNumber", "Value": 254712345678 }
]
}
}
}
}
Callback Schema
StkPushSimulateCallback β the payload posted to your callback_url, exposed via Body.stkCallback
| Parameter | Type | Description |
|---|---|---|
Body.stkCallback.MerchantRequestIDrequired str | String | Matches the MerchantRequestID from the initial response. |
Body.stkCallback.CheckoutRequestIDrequired str | String | Matches the CheckoutRequestID from the initial response. |
Body.stkCallback.ResultCoderequired int | str | Integer/String | 0 means the customer completed payment. Any other value means cancellation, insufficient funds, timeout, etc. |
Body.stkCallback.ResultDescrequired str | String | Human readable description of the result, e.g. 'The service request is processed successfully.' |
Body.stkCallback.CallbackMetadata.Item list[{Name, Value}] | null | Array | Present only on success. Contains Amount, MpesaReceiptNumber, TransactionDate, PhoneNumber and (occasionally) Balance. |
Rather than digging through CallbackMetadata.Item yourself, use these properties (note: properties, not methods β no ()) exposed directly on the object returned by process_stk_callback:
callback.amountβ the transacted amount, from the 'Amount' item.callback.mpesa_receipt_numberβ the M-Pesa receipt number, from 'MpesaReceiptNumber'.callback.balanceβ account balance, from 'Balance', when present.callback.transaction_dateβ from 'TransactionDate'.callback.phone_numberβ payer's phone number, from 'PhoneNumber'.callback.is_successfulβTruewhenBody.stkCallback.ResultCode == 0.
All of these return None if CallbackMetadata is absent (i.e. the transaction failed) β always check is_successful first, and fall back to callback.Body.stkCallback.ResultDesc for the failure reason.
Callback Acknowledgement Schema
StkPushSimulateCallbackResponse β what your callback handler should return to Safaricom
| Parameter | Type | Description |
|---|---|---|
ResultCode int | str | Integer/String | Defaults to 0 (success). |
ResultDesc str | String | Defaults to 'Success'. |
Querying Status Instead of (or In Addition to) Waiting
If the callback hasn't arrived yet (e.g. the customer hasn't responded to the prompt), you can actively query the transaction status with stk_query, then validate the response with process_stk_query_callback:
raw_response = client.stk_query( business_short_code=174379, passkey=os.getenv("MPESA_PASSKEY"), checkout_request_id="ws_CO_191220191020363925",)
query_result = client.process_stk_query_callback(raw_response.model_dump(mode="json"))print(query_result.ResultDesc)process_stk_callback and process_stk_query_callback run the raw dict through a Pydantic schema (StkPushSimulateCallback / StkPushQueryResponse). This catches malformed or unexpected payloads early, gives you typed attribute access instead of nested dict lookups, and is available on both MpesaClient and AsyncMpesaClient since it's plain validation logic with no I/O.
Next Steps
Now that you can initiate STK Push and process both the callback and query responses, harden your webhook endpoint and prepare for production traffic.
Related Documentationβ
- π‘ Webhook Setup Guide - Handle payment callbacks
- ποΈ Production Setup - Go live checklist