M-Pesa Integration in Django: STK Push & C2B

February 25, 2026
5 min read

NOTE: This is a production-ready integration
Integrating Safaricom's M-Pesa API into a Django application can feel intimidating. You have to handle access tokens, base64 encoding, asynchronous callbacks, and strict validation rules.

In this guide, we will build a robust, production-ready Mpesa integration that supports both STK Push (prompting the user's phone directly) and C2B (Customer-to-Business, where the user manually pays via a Paybill/Till number).

Let's break it down step-by-step.

Step 1: Centralizing Your Configurations

First, we need a clean way to handle credentials and generate passwords. Create a file named mpesa_config.py. We will use standard Django settings to keep our actual keys secure.

# mpesa_config.py
import base64
import json
import requests
from datetime import datetime
from requests.auth import HTTPBasicAuth
from django.conf import settings

class MpesaConfig:
    consumer_key = settings.MPESA_CONSUMER_KEY
    consumer_secret = settings.MPESA_CONSUMER_SECRET
    business_short_code = settings.MPESA_SHORTCODE
    passkey = settings.MPESA_PASSKEY
    
    # Use sandbox URLs for testing, api.safaricom.co.ke for production
    auth_url = 'https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials'
    stk_url = "https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest"
    c2b_register_url = "https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl"
    
    domain = "https://www.yourdomain.com"
    stk_callback_url = f"{domain}/payments/mpesa-callback/"
    c2b_validation_url = f"{domain}/payments/c2b/validation/"
    c2b_confirmation_url = f"{domain}/payments/c2b/confirmation/"

class MpesaAuth:
    @staticmethod
    def get_access_token():
        try:
            r = requests.get(
                MpesaConfig.auth_url,
                auth=HTTPBasicAuth(MpesaConfig.consumer_key, MpesaConfig.consumer_secret),
                timeout=10
            )
            r.raise_for_status()
            return r.json()["access_token"]
        except requests.exceptions.RequestException as e:
            print(f"Error fetching access token: {e}")
            return None

class MpesaPassword:
    @staticmethod
    def generate():
        timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
        data_to_encode = MpesaConfig.business_short_code + MpesaConfig.passkey + timestamp
        encoded_password = base64.b64encode(data_to_encode.encode()).decode('utf-8')
        return encoded_password, timestamp

Step 2: The API Utilities (The Engine)

Next, create mpesa_utils.py. This file handles the actual HTTP requests to Safaricom, one function to trigger the STK push, and another to register our C2B webhooks.
 

# mpesa_utils.py
import requests
from .mpesa_config import MpesaConfig, MpesaAuth, MpesaPassword

def trigger_stk_push(phone_number, amount, order_reference):
    # Clean phone number to start with 254
    if phone_number.startswith('+'):
        phone_number = phone_number[1:]
    if phone_number.startswith('0'):
        phone_number = '254' + phone_number[1:]

    access_token = MpesaAuth.get_access_token()
    password, timestamp = MpesaPassword.generate()
    headers = {"Authorization": f"Bearer {access_token}"}

    payload = {
        "BusinessShortCode": MpesaConfig.business_short_code,
        "Password": password,
        "Timestamp": timestamp,
        "TransactionType": "CustomerPayBillOnline",
        "Amount": int(float(amount)), 
        "PartyA": phone_number,
        "PartyB": MpesaConfig.business_short_code,
        "PhoneNumber": phone_number,
        "CallBackURL": MpesaConfig.stk_callback_url,
        "AccountReference": str(order_reference),
        "TransactionDesc": f"Payment for Order {order_reference}"
    }

    response = requests.post(MpesaConfig.stk_url, json=payload, headers=headers, timeout=30)
    response.raise_for_status() 
    
    return response.json() # Returns CheckoutRequestID and MerchantRequestID

def register_c2b_urls():
    access_token = MpesaAuth.get_access_token()
    if not access_token:
        return {"error": "Failed to get access token"}
    
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }

    payload = {
        "ShortCode": MpesaConfig.business_short_code,
        "ResponseType": "Cancelled", 
        "ConfirmationURL": MpesaConfig.c2b_confirmation_url,
        "ValidationURL": MpesaConfig.c2b_validation_url
    }

    response = requests.post(MpesaConfig.c2b_register_url, json=payload, headers=headers)
    return response.json()

 

Step 3: Handling STK Push in Views

Now we move to views.py. We need a view to initiate the payment via an AJAX request from the frontend, and a webhook to receive the result from Safaricom.

 

# views.py
import json
from datetime import datetime
from django.utils.timezone import make_aware
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from .mpesa_utils import trigger_stk_push
from .models import Order, PaymentTransaction

@login_required
def initiate_mpesa_payment(request, order_id):
    order = Order.objects.get(pk=order_id)

    if request.method == 'POST':
        data = json.loads(request.body)
        phone_number = data.get('phone_number')

        try:
            response = trigger_stk_push(phone_number, order.amount, order.reference_number)
            
            # Save pending transaction to DB
            PaymentTransaction.objects.create(
                order=order,
                amount=order.amount,
                phone_number=phone_number,
                checkout_request_id=response.get('CheckoutRequestID'),
                is_complete=False
            )

            return JsonResponse({
                'success': True, 
                'message': "STK push sent! Please check your phone.",
                'checkout_request_id': response.get('CheckoutRequestID')
            })

        except Exception as e:
            return JsonResponse({'success': False, 'message': str(e)}, status=500)

@csrf_exempt
def stk_callback(request):
    data = json.loads(request.body)
    callback = data.get('Body', {}).get('stkCallback', {})
    result_code = callback.get('ResultCode', 1)
    checkout_id = callback.get('CheckoutRequestID', '')

    try:
        tx = PaymentTransaction.objects.get(checkout_request_id=checkout_id)
    except PaymentTransaction.DoesNotExist:
        return JsonResponse({'ResultCode': 1, 'ResultDesc': 'Transaction not found.'})

    if result_code != 0:
        tx.is_complete = False
        tx.save()
        return JsonResponse({'ResultCode': result_code, 'ResultDesc': 'Failed/Cancelled'})

    # Extract successful payment data
    items = callback.get('CallbackMetadata', {}).get('Item', [])
    for item in items:
        if item.get('Name') == 'MpesaReceiptNumber':
            tx.mpesa_receipt_number = item.get('Value')

    tx.is_complete = True
    tx.save()

    # Mark order as paid
    tx.order.status = 'PAID'
    tx.order.save()

    return JsonResponse({'ResultCode': 0, 'ResultDesc': 'Accepted'})

 

Step 4: Handling C2B Webhooks (Validation & Confirmation)

Sometimes STK push fails (network issues, timeouts), so users must pay manually via Paybill. Safaricom will ping your Validation URL to ask "Does this account exist?" and then your Confirmation URL to say "Here is the money."

 

# views.py (continued)

@csrf_exempt
def c2b_validation(request):
    data = json.loads(request.body)
    bill_ref_number = data.get('BillRefNumber', '').strip().upper()
    trans_amount = data.get('TransAmount')

    try:
        order = Order.objects.get(reference_number=bill_ref_number)
        
        # Optional: Validate Amount
        if float(trans_amount) < float(order.amount):
            return JsonResponse({
                "ResultCode": "C2B00013", 
                "ResultDesc": "Rejected: Amount is less than required."
            })

        return JsonResponse({"ResultCode": "0", "ResultDesc": "Accepted"})
        
    except Order.DoesNotExist:
        return JsonResponse({
            "ResultCode": "C2B00012", 
            "ResultDesc": "Rejected: Invalid Account Number."
        })

@csrf_exempt
def c2b_confirmation(request):
    data = json.loads(request.body)
    
    trans_id = str(data.get('TransID', ''))
    trans_amount = data.get('TransAmount')
    bill_ref_number = data.get('BillRefNumber', '').strip().upper()
    msisdn = str(data.get('MSISDN', ''))

    # Prevent duplicate processing
    if PaymentTransaction.objects.filter(mpesa_receipt_number=trans_id).exists():
        return JsonResponse({"ResultCode": "0", "ResultDesc": "Already processed"})

    try:
        order = Order.objects.get(reference_number=bill_ref_number)
        
        # Create completed transaction record
        PaymentTransaction.objects.create(
            order=order,
            amount=trans_amount,
            phone_number=msisdn,
            mpesa_receipt_number=trans_id,
            is_complete=True
        )

        order.status = 'PAID'
        order.save()

        return JsonResponse({"ResultCode": "0", "ResultDesc": "Processed Successfully"})

    except Exception as e:
        # Always return success to Safaricom to stop them from retrying infinitely,
        # but log the error on your side.
        print(f"C2B Error: {e}")
        return JsonResponse({"ResultCode": "0", "ResultDesc": "Error Handled"})

 

Step 5: Wiring the URLs

Finally, expose these views in your urls.py.

 

# urls.py
from django.urls import path
from . import views

urlpatterns = [
    # STK Push URLs
    path('initiate-payment/<int:order_id>/', views.initiate_mpesa_payment, name='initiate_payment'),
    path('mpesa-callback/', views.stk_callback, name='mpesa_callback'),

    # C2B URLs
    path('c2b/validation/', views.c2b_validation, name='c2b_validation'),
    path('c2b/confirmation/', views.c2b_confirmation, name='c2b_confirmation'),
    
    # Admin helper to register C2B URLs
    path('c2b/register-urls/', views.register_urls_view, name='c2b_register_urls'),
]

 

Conclusion

By separating your configuration, API logic, and views, your codebase remains clean and easy to debug. Remember to always catch Exceptions in your callbacks, if your Django server throws a 500 Internal Server Error back to Safaricom, they will continually retry the webhook, which can clutter your logs and cause duplicate data.

Always test thoroughly in the Safaricom Sandbox before pushing to production. Happy coding!

 

Want More Tutorials Like This? Let’s Connect!

I hope this guide helped demystify M-Pesa integration in Django for you. Dealing with payment gateways can be frustrating at first, but once you understand the flow of STK Pushes and C2B callbacks, it becomes incredibly powerful for your web applications.

If you found this tutorial valuable and want to level up your programming skills, I’d love for you to join my developer community. I regularly share practical coding tips, project walkthroughs, and insights into software development.

You can find me and follow my journey here:

  • 📺 Subscribe on YouTube: For full video tutorials, system design breakdowns, and deep dives into Python and Django. Click here to subscribe
  • 📱 Follow on TikTok: For quick, bite-sized coding tips, debugging hacks, and a behind-the-scenes look at my day-to-day life as a developer. Follow me here
  • 📘 Connect on Facebook: Join the conversation, ask questions about this tutorial, and interact with other developers. Like the page here

If you use this code in your project, shoot me a message on any of these platforms, I'd love to see what you build! Drop a comment on my latest video or post and let me know what topic you want me to cover next.

 

 

Share this:
SM
Saul Nyongesa Mupalia

Software Developer specializing in Python & Django. I write about code, systems, and solving real-world tech problems.