Django
Singularity Payments can be integrated into your Django application with ease.
Install the packages
pip install singularity-payments django python-dotenvIf you use uv:
uv add singularity-payments django python-dotenvInstall 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/timeoutpython-dotenv loads this file automatically if you call load_dotenv()
before importing the SDK — do it at the top of the client module below so it
works no matter how the server is started.
Configure the SDK
Create an app that builds a single, shared client. The client is async, so you drive it from your views.
python manage.py startapp mpesa_appimport os
from dotenv import load_dotenv
load_dotenv() # load ./.env before reading any MPESA_* variables
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,
),
),
)Wire up the routes
Django is a synchronous (WSGI) framework. Run each coroutine with
asyncio.run(...), the same pattern as the Flask
integration.
Register the app and allow ngrok's host in mpesa_proj/settings.py:
INSTALLED_APPS = [
"mpesa_app",
]
ALLOWED_HOSTS = ["*"] # ngrok uses a random subdomainThe views are plain sync functions. Mark them csrf_exempt — M-Pesa does not
send a CSRF token — and require_POST:
import asyncio
import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from singularity_payments import STKCallback, STKPushRequest
from .mpesa import mpesa
@csrf_exempt
@require_POST
def stk_push(request):
"""Initiate an STK Push. Needs real M-Pesa credentials + a public URL."""
payload = json.loads(request.body)
response = asyncio.run(
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))
@csrf_exempt
@require_POST
def stk_callback(request):
"""Receive the M-Pesa STK Push callback and parse it with the SDK."""
callback = STKCallback.model_validate(json.loads(request.body))
response = asyncio.run(mpesa.handle_stk_callback(callback))
return JsonResponse(response.model_dump(by_alias=True))Route the endpoints inside the app:
from django.urls import path
from . import views
urlpatterns = [
path("api/mpesa/stk-push", views.stk_push),
path("api/mpesa/callback", views.stk_callback),
]And include them from the project:
from django.urls import include, path
urlpatterns = [
path("", include("mpesa_app.urls")),
]Callback responses
Always return the callback response with .model_dump(by_alias=True). That
produces the ResultCode / ResultDesc keys M-Pesa expects. Returning the
model alone would emit snake_case field names.
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:
python manage.py runserverThen 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"}'Check your phone for the M-Pesa prompt and enter your PIN to complete the payment.
Prefer async?
Django 4.1+ also supports async def views, so you can await the SDK
directly. Every async view still gets its own event loop per request though —
keep a shared client, and the same Redis rate-limiter caveat below applies.
Redis rate limiting under WSGI
Each asyncio.run(...) call creates a new event loop. The in-memory rate
limiter is safe because its state lives in a plain dict, but a
RedisRateLimiter connection is bound to a single loop. If you use Redis,
run a persistent event loop (or move to an ASGI server such as
Uvicorn/Gunicorn with uvicorn.workers) instead of calling asyncio.run
per request.