Generic ASGI
Use Singularity Payments with any ASGI framework - Starlette, Litestar, Quart and more.
Install the packages
pip install singularity-paymentsIf you use uv:
uv add singularity-paymentsInstall Ngrok
The API requires a publically accessible url for callbacks. You can use ngrok to expose your local development server to the internet.
# using npm
npm install -g ngrok
# or download from https://ngrok.com/downloadConfigure your environmental variables
MPESA_CONSUMER_KEY=your_consumer_key
MPESA_CONSUMER_SECRET=your_consumer_secret
MPESA_SHORTCODE=174379 #For STK Push Testing in Sandbox(Replace with your shortcode in production)
MPESA_PASSKEY=your_passkey
MPESA_ENVIRONMENT=sandbox # or 'production'
MPESA_CALLBACK_URL=https://your-app-url.com/api/mpesa/callback
# For STK Push you can just include the 6 above
# Required for B2C, B2B, Reversal, Transaction Status, and Account Balance
MPESA_INITIATOR_NAME=testapi #Initiator Name for Sandbox
MPESA_SECURITY_CREDENTIAL=your_security_credential
MPESA_RESULT_URL=https://your-app-url.com/api/mpesa/result
MPESA_TIMEOUT_URL=https://your-app-url.com/api/mpesa/timeoutConfigure the SDK
import os
from singularity_payments import (
CallbackHandlerOptions,
MpesaClient,
MpesaClientOptions,
MpesaConfig,
ParsedCallbackData,
)
async def on_success(data: ParsedCallbackData) -> None:
# Persist the payment against your own order/record.
print("Payment received", data.mpesa_receipt_number, data.amount)
async def on_failure(data: ParsedCallbackData) -> None:
print("Payment failed", data.result_code, data.result_description)
mpesa = MpesaClient(
MpesaConfig(
consumer_key=os.environ["MPESA_CONSUMER_KEY"],
consumer_secret=os.environ["MPESA_CONSUMER_SECRET"],
passkey=os.environ["MPESA_PASSKEY"],
shortcode=os.environ["MPESA_SHORTCODE"],
environment=os.environ.get("MPESA_ENVIRONMENT", "sandbox"),
callback_url=os.environ["MPESA_CALLBACK_URL"],
# Required for B2C, B2B, Reversal, Transaction Status and Account Balance
initiator_name=os.environ.get("MPESA_INITIATOR_NAME"),
security_credential=os.environ.get("MPESA_SECURITY_CREDENTIAL"),
result_url=os.environ.get("MPESA_RESULT_URL"),
timeout_url=os.environ.get("MPESA_TIMEOUT_URL"),
),
MpesaClientOptions(
callback_options=CallbackHandlerOptions(
on_success=on_success,
on_failure=on_failure,
),
),
)A plain ASGI app
Because the SDK already speaks async, you can call it directly from any
async def ASGI callable.
import json
from singularity_payments import STKCallback, STKPushRequest
from .mpesa import mpesa
async def read_json(receive) -> dict:
body = b""
while True:
message = await receive()
body += message.get("body", b"")
if not message.get("more_body", False):
break
return json.loads(body or b"{}")
async def send_json(send, data: dict, status: int = 200) -> None:
payload = json.dumps(data).encode()
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
],
}
)
await send({"type": "http.response.body", "body": payload})
async def app(scope, receive, send):
if scope["type"] != "http":
return
path, method = scope["path"], scope["method"]
if path == "/api/mpesa/stk-push" and method == "POST":
payload = await read_json(receive)
response = await mpesa.stk_push(
STKPushRequest(
amount=payload["amount"],
phone_number=payload["phone_number"],
account_reference=payload.get("account_reference", "INV-001"),
transaction_desc=payload.get("transaction_desc", "Payment for order"),
)
)
return await send_json(send, response.model_dump(by_alias=True))
if path == "/api/mpesa/callback" and method == "POST":
body = await read_json(receive)
response = await mpesa.handle_stk_callback(STKCallback.model_validate(body))
return await send_json(send, response.model_dump(by_alias=True))
await send_json(send, {"error": "Not found"}, status=404)Run it with any ASGI server:
uvicorn app.asgi:app --reloadStarlette
from contextlib import asynccontextmanager
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from singularity_payments import STKCallback, STKPushRequest
from .mpesa import mpesa
async def stk_push(request: Request) -> JSONResponse:
payload = await request.json()
response = await mpesa.stk_push(
STKPushRequest(
amount=payload["amount"],
phone_number=payload["phone_number"],
account_reference=payload.get("account_reference", "INV-001"),
transaction_desc=payload.get("transaction_desc", "Payment for order"),
)
)
return JSONResponse(response.model_dump(by_alias=True))
async def stk_callback(request: Request) -> JSONResponse:
body = await request.json()
response = await mpesa.handle_stk_callback(STKCallback.model_validate(body))
return JSONResponse(response.model_dump(by_alias=True))
@asynccontextmanager
async def lifespan(app: Starlette):
yield
mpesa.destroy() # stop the background rate-limiter cleanup task
routes = [
Route("/api/mpesa/stk-push", stk_push, methods=["POST"]),
Route("/api/mpesa/callback", stk_callback, methods=["POST"]),
]
app = Starlette(routes=routes, lifespan=lifespan)Litestar
Litestar validates the request body against the pydantic model for you.
from litestar import Litestar, post
from singularity_payments import STKCallback, STKPushRequest
from .mpesa import mpesa
@post("/api/mpesa/stk-push")
async def stk_push(data: dict) -> dict:
response = await mpesa.stk_push(
STKPushRequest(
amount=data["amount"],
phone_number=data["phone_number"],
account_reference=data.get("account_reference", "INV-001"),
transaction_desc=data.get("transaction_desc", "Payment for order"),
)
)
return response.model_dump(by_alias=True)
@post("/api/mpesa/callback")
async def stk_callback(data: STKCallback) -> dict:
response = await mpesa.handle_stk_callback(data)
return response.model_dump(by_alias=True)
app = Litestar(route_handlers=[stk_push, stk_callback])Quart
Quart is Flask's async twin. Routes are coroutines, so you can await the SDK
directly.
from quart import Quart, jsonify, request
from singularity_payments import STKCallback, STKPushRequest
from .mpesa import mpesa
app = Quart(__name__)
@app.post("/api/mpesa/stk-push")
async def stk_push():
payload = await request.get_json()
response = await mpesa.stk_push(
STKPushRequest(
amount=payload["amount"],
phone_number=payload["phone_number"],
account_reference=payload.get("account_reference", "INV-001"),
transaction_desc=payload.get("transaction_desc", "Payment for order"),
)
)
return jsonify(response.model_dump(by_alias=True))
@app.post("/api/mpesa/callback")
async def stk_callback():
body = await request.get_json()
response = await mpesa.handle_stk_callback(STKCallback.model_validate(body))
return jsonify(response.model_dump(by_alias=True))Callback responses
Returning .model_dump(by_alias=True) keeps the ResultCode / ResultDesc
keys M-Pesa expects. If your framework serialises pydantic models by alias
(FastAPI does), you can return the model directly instead.
Start Ngrok
ngrok http 8000 # whatever port your server is running onCopy paste the url from ngrok into the env variable MPESA_CALLBACK_URL.
MPESA_CALLBACK_URL=https://your-ngrok-url.ngrok.free/api/mpesa/callback # make sure you include /api/mpesa/callbackTest your integration
Start the development server:
uvicorn app.main:app --reloadThen initiate an STK Push:
curl -X POST http://localhost:8000/api/mpesa/stk-push \
-H "Content-Type: application/json" \
-d '{"amount": 1, "phone_number": "254712345678", "account_reference": "INV-001"}'