Skip to main content

Mobile Money Payment Integration

Integrate M-Pesa, Telebirr, MTN Mobile Money, and other African mobile money platforms into your application.

What You'll Build​

In this tutorial, you'll create a payment system that:

  • ✅ Accepts mobile money payments (M-Pesa, Telebirr, MTN, Airtel Money)
  • ✅ Handles payment callbacks and confirmations
  • ✅ Checks transaction status
  • ✅ Manages refunds and reconciliation
  • ✅ Implements security best practices

Estimated Time: 30 minutes

Prerequisites​

  • ✅ AfriRoute Account with Payments API access (Sign up)
  • ✅ API Credentials from dashboard
  • ✅ Node.js 18+ or Python 3.8+
  • ✅ Test mobile money account for sandbox testing
  • ✅ Webhook endpoint (we'll set this up)

Payment Flow Overview​

┌──────────┐    1. Initiate    ┌───────────┐    2. Process    ┌──────────┐
│ Your │─────────────────▶│ AfriRoute │────────────────▶│ Mobile │
│ App │ │ Payment │ │ Network │
└──────────┘ │ API │ └──────────┘
│ └───────────┘ │
│ │ │
│ 4. Webhook │ 3. User Confirms │
│◀─────────────────────────────┤◀─────────────────────────────┘
│ (Payment Status) │
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Update │ │ Payment │
│ Database │ │ Complete │
└──────────┘ └──────────────┘

Step 1: Install Dependencies​

Node.js:

mkdir payment-integration
cd payment-integration
npm init -y
npm install express afriroute-sdk body-parser dotenv

Python:

mkdir payment-integration
cd payment-integration
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install afriroute-python flask python-dotenv

Step 2: Environment Setup​

.env
AFRIROUTE_API_KEY=your_api_key
AFRIROUTE_API_SECRET=your_secret
WEBHOOK_SECRET=your_webhook_secret
ENVIRONMENT=sandbox # Change to 'production' when ready

Step 3: Initiate Payment (Node.js)​

initiate-payment.js
const express = require('express');
const { AfriRoute } = require('afriroute-sdk');
require('dotenv').config();

const app = express();
app.use(express.json());

const afriroute = new AfriRoute({
apiKey: process.env.AFRIROUTE_API_KEY,
apiSecret: process.env.AFRIROUTE_API_SECRET,
environment: process.env.ENVIRONMENT
});

// In-memory storage (use database in production)
const transactions = new Map();

async function initiatePayment(amount, phoneNumber, currency = 'KES') {
try {
const payment = await afriroute.payments.initiate({
amount: amount,
currency: currency,
phone: phoneNumber,
provider: 'mpesa', // or 'telebirr', 'mtn', 'airtel'
reference: `ORD-${Date.now()}`,
description: 'Product purchase',
callback_url: 'https://your-domain.com/webhook/payment'
});

// Store transaction
transactions.set(payment.transaction_id, {
transaction_id: payment.transaction_id,
amount: amount,
phone: phoneNumber,
status: 'pending',
created_at: new Date()
});

console.log('✅ Payment initiated:', payment.transaction_id);

return {
success: true,
transaction_id: payment.transaction_id,
status: payment.status,
message: 'Payment request sent. Please check your phone.'
};

} catch (error) {
console.error('❌ Payment error:', error);
return {
success: false,
error: error.message
};
}
}

// API endpoint to initiate payment
app.post('/api/payments/initiate', async (req, res) => {
const { amount, phone, currency } = req.body;

if (!amount || !phone) {
return res.status(400).json({
error: 'Amount and phone number are required'
});
}

const result = await initiatePayment(amount, phone, currency);

if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
});

// Webhook endpoint to receive payment confirmations
app.post('/webhook/payment', async (req, res) => {
try {
const signature = req.headers['x-afriroute-signature'];

// Verify webhook signature
if (!verifyWebhookSignature(signature, req.body)) {
return res.status(401).json({ error: 'Invalid signature' });
}

const { transaction_id, status, amount, phone } = req.body;

console.log(`📬 Webhook received: ${transaction_id} - ${status}`);

// Update transaction status
const transaction = transactions.get(transaction_id);
if (transaction) {
transaction.status = status;
transaction.updated_at = new Date();
transactions.set(transaction_id, transaction);

// Process based on status
if (status === 'completed') {
await handleSuccessfulPayment(transaction);
} else if (status === 'failed') {
await handleFailedPayment(transaction);
}
}

res.status(200).json({ received: true });

} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});

async function handleSuccessfulPayment(transaction) {
console.log('✅ Payment successful:', transaction.transaction_id);

// Update your database
// Send confirmation SMS/email
// Fulfill order
// Update inventory
}

async function handleFailedPayment(transaction) {
console.log('❌ Payment failed:', transaction.transaction_id);

// Notify customer
// Log failure reason
// Retry logic if needed
}

function verifyWebhookSignature(signature, payload) {
const crypto = require('crypto');
const expectedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(payload))
.digest('hex');

return signature === expectedSignature;
}

// Check payment status endpoint
app.get('/api/payments/:transactionId', async (req, res) => {
const { transactionId } = req.params;

try {
const status = await afriroute.payments.getStatus(transactionId);
res.json(status);
} catch (error) {
res.status(500).json({ error: error.message });
}
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`💳 Payment server running on port ${PORT}`);
});

Step 4: Python Implementation​

payment_integration.py
import os
import hmac
import hashlib
from flask import Flask, request, jsonify
from afriroute import AfriRoute
from dotenv import load_dotenv
from datetime import datetime

load_dotenv()

app = Flask(__name__)

# Initialize AfriRoute client
client = AfriRoute(
api_key=os.getenv('AFRIROUTE_API_KEY'),
api_secret=os.getenv('AFRIROUTE_API_SECRET'),
environment=os.getenv('ENVIRONMENT', 'sandbox')
)

# In-memory storage (use database in production)
transactions = {}

def initiate_payment(amount, phone, currency='KES', provider='mpesa'):
"""Initiate a mobile money payment"""
try:
payment = client.payments.initiate(
amount=amount,
currency=currency,
phone=phone,
provider=provider,
reference=f"ORD-{int(datetime.now().timestamp())}",
description="Product purchase",
callback_url="https://your-domain.com/webhook/payment"
)

# Store transaction
transactions[payment['transaction_id']] = {
'transaction_id': payment['transaction_id'],
'amount': amount,
'phone': phone,
'provider': provider,
'status': 'pending',
'created_at': datetime.now().isoformat()
}

print(f"✅ Payment initiated: {payment['transaction_id']}")

return {
'success': True,
'transaction_id': payment['transaction_id'],
'status': payment['status'],
'message': 'Payment request sent. Please check your phone.'
}

except Exception as e:
print(f"❌ Payment error: {str(e)}")
return {
'success': False,
'error': str(e)
}

@app.route('/api/payments/initiate', methods=['POST'])
def initiate_payment_endpoint():
"""API endpoint to initiate payment"""
data = request.json

amount = data.get('amount')
phone = data.get('phone')
currency = data.get('currency', 'KES')
provider = data.get('provider', 'mpesa')

if not amount or not phone:
return jsonify({'error': 'Amount and phone are required'}), 400

result = initiate_payment(amount, phone, currency, provider)

if result['success']:
return jsonify(result), 200
else:
return jsonify(result), 500

@app.route('/webhook/payment', methods=['POST'])
def payment_webhook():
"""Webhook endpoint to receive payment confirmations"""
try:
# Verify signature
signature = request.headers.get('X-AfriRoute-Signature')
if not verify_webhook_signature(signature, request.data):
return jsonify({'error': 'Invalid signature'}), 401

data = request.json
transaction_id = data.get('transaction_id')
status = data.get('status')

print(f"📬 Webhook received: {transaction_id} - {status}")

# Update transaction
if transaction_id in transactions:
transactions[transaction_id]['status'] = status
transactions[transaction_id]['updated_at'] = datetime.now().isoformat()

if status == 'completed':
handle_successful_payment(transactions[transaction_id])
elif status == 'failed':
handle_failed_payment(transactions[transaction_id])

return jsonify({'received': True}), 200

except Exception as e:
print(f"Webhook error: {str(e)}")
return jsonify({'error': 'Internal server error'}), 500

def verify_webhook_signature(signature, payload):
"""Verify webhook signature for security"""
secret = os.getenv('WEBHOOK_SECRET').encode()
expected_signature = hmac.new(secret, payload, hashlib.sha256).hexdigest()
return signature == expected_signature

def handle_successful_payment(transaction):
"""Handle successful payment"""
print(f"✅ Payment successful: {transaction['transaction_id']}")

# Update database
# Send confirmation
# Fulfill order
# Send receipt

def handle_failed_payment(transaction):
"""Handle failed payment"""
print(f"❌ Payment failed: {transaction['transaction_id']}")

# Notify customer
# Log failure
# Retry if appropriate

@app.route('/api/payments/<transaction_id>', methods=['GET'])
def get_payment_status(transaction_id):
"""Check payment status"""
try:
status = client.payments.get_status(transaction_id)
return jsonify(status), 200
except Exception as e:
return jsonify({'error': str(e)}), 500

if __name__ == '__main__':
app.run(port=3000, debug=True)

Step 5: Frontend Integration​

payment-form.html
<!DOCTYPE html>
<html>
<head>
<title>Payment Integration</title>
<style>
.payment-form {
max-width: 400px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input, select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
width: 100%;
padding: 12px;
background: #003366;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background: #004080;
}
.status {
margin-top: 20px;
padding: 10px;
border-radius: 4px;
}
.success {
background: #d4edda;
color: #155724;
}
.error {
background: #f8d7da;
color: #721c24;
}
</style>
</head>
<body>
<div class="payment-form">
<h2>💳 Mobile Money Payment</h2>

<form id="paymentForm">
<div class="form-group">
<label>Payment Provider</label>
<select id="provider" required>
<option value="mpesa">M-Pesa (Kenya)</option>
<option value="telebirr">Telebirr (Ethiopia)</option>
<option value="mtn">MTN Mobile Money</option>
<option value="airtel">Airtel Money</option>
</select>
</div>

<div class="form-group">
<label>Phone Number</label>
<input type="tel" id="phone" placeholder="+254700123456" required>
</div>

<div class="form-group">
<label>Amount</label>
<input type="number" id="amount" placeholder="100" min="1" required>
</div>

<div class="form-group">
<label>Currency</label>
<select id="currency" required>
<option value="KES">KES - Kenyan Shilling</option>
<option value="ETB">ETB - Ethiopian Birr</option>
<option value="UGX">UGX - Ugandan Shilling</option>
<option value="TZS">TZS - Tanzanian Shilling</option>
</select>
</div>

<button type="submit">Pay Now</button>
</form>

<div id="status"></div>
</div>

<script>
const form = document.getElementById('paymentForm');
const statusDiv = document.getElementById('status');

form.addEventListener('submit', async (e) => {
e.preventDefault();

const provider = document.getElementById('provider').value;
const phone = document.getElementById('phone').value;
const amount = document.getElementById('amount').value;
const currency = document.getElementById('currency').value;

// Show loading
statusDiv.innerHTML = '<p>⏳ Processing payment...</p>';
statusDiv.className = 'status';

try {
// Initiate payment
const response = await fetch('/api/payments/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
provider,
phone,
amount: parseFloat(amount),
currency
})
});

const result = await response.json();

if (result.success) {
statusDiv.innerHTML = `
<p>✅ ${result.message}</p>
<p>Transaction ID: ${result.transaction_id}</p>
<p>Check your phone to complete payment.</p>
`;
statusDiv.className = 'status success';

// Poll for status
pollPaymentStatus(result.transaction_id);
} else {
statusDiv.innerHTML = `<p>❌ ${result.error}</p>`;
statusDiv.className = 'status error';
}

} catch (error) {
statusDiv.innerHTML = `<p>❌ Error: ${error.message}</p>`;
statusDiv.className = 'status error';
}
});

async function pollPaymentStatus(transactionId) {
const maxAttempts = 30; // 5 minutes (30 * 10 seconds)
let attempts = 0;

const interval = setInterval(async () => {
attempts++;

try {
const response = await fetch(`/api/payments/${transactionId}`);
const status = await response.json();

if (status.status === 'completed') {
clearInterval(interval);
statusDiv.innerHTML = `
<p>✅ Payment Successful!</p>
<p>Amount: ${status.amount} ${status.currency}</p>
<p>Receipt: ${status.receipt}</p>
`;
statusDiv.className = 'status success';
} else if (status.status === 'failed') {
clearInterval(interval);
statusDiv.innerHTML = `<p>❌ Payment Failed: ${status.error}</p>`;
statusDiv.className = 'status error';
}

if (attempts >= maxAttempts) {
clearInterval(interval);
statusDiv.innerHTML = `<p>⏰ Payment timeout. Check status later.</p>`;
}

} catch (error) {
console.error('Status check error:', error);
}

}, 10000); // Check every 10 seconds
}
</script>
</body>
</html>

Step 6: Handle Refunds​

refunds.py
def process_refund(transaction_id, amount=None, reason="Customer request"):
"""Process a refund"""
try:
refund = client.payments.refund(
transaction_id=transaction_id,
amount=amount, # None = full refund
reason=reason
)

print(f"✅ Refund initiated: {refund['refund_id']}")

return {
'success': True,
'refund_id': refund['refund_id'],
'amount': refund['amount'],
'status': refund['status']
}

except Exception as e:
print(f"❌ Refund error: {str(e)}")
return {
'success': False,
'error': str(e)
}

Best Practices​

1. Idempotency​

// Use unique reference IDs
const reference = `ORD-${orderId}-${Date.now()}`;

// Store in database to prevent duplicate charges
const existing = await db.findTransaction({ reference });
if (existing) {
return existing;
}

2. Security​

# Always verify webhook signatures
def verify_webhook_signature(signature, payload):
secret = os.getenv('WEBHOOK_SECRET').encode()
expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)

3. Error Handling​

try {
const payment = await afriroute.payments.initiate({...});
} catch (error) {
if (error.code === 'INSUFFICIENT_BALANCE') {
// Customer doesn't have enough money
} else if (error.code === 'INVALID_PHONE') {
// Phone number format is wrong
} else if (error.code === 'PROVIDER_UNAVAILABLE') {
// Mobile money service is down
}
}

4. Transaction Logging​

# Log all transactions
import logging

logging.basicConfig(
filename='payments.log',
level=logging.INFO,
format='%(asctime)s - %(message)s'
)

logging.info(f"Payment initiated: {transaction_id}, Amount: {amount}")
logging.info(f"Payment completed: {transaction_id}")

Troubleshooting​

Issue: "Provider Unavailable"​

Solution: Mobile money service might be down. Retry after a few minutes.

Issue: "Insufficient Balance"​

Solution: Customer doesn't have enough money. Show clear error message.

Issue: "Webhook Not Received"​

Solution:

  • Verify webhook URL is publicly accessible
  • Check firewall settings
  • Verify signature validation logic

Issue: "Payment Stuck in Pending"​

Solution:

# Implement timeout and status checking
if transaction_age > 5_minutes and status == 'pending':
status = client.payments.get_status(transaction_id)
if status == 'pending':
# Cancel or retry
client.payments.cancel(transaction_id)

Testing​

Sandbox Mode​

// Use test phone numbers
const testNumbers = {
mpesa: '+254700000000', // Always succeeds
mpesa_fail: '+254700000001', // Always fails
telebirr: '+251900000000'
};

// Test small amounts
const testAmount = 1; // 1 KES

Production Checklist​

  • ✅ Switch environment to production
  • ✅ Use production API credentials
  • ✅ Implement database storage
  • ✅ Set up proper logging
  • ✅ Configure webhook URL with HTTPS
  • ✅ Implement refund workflows
  • ✅ Add transaction reconciliation
  • ✅ Monitor payment success rates
  • ✅ Set up alerts for failures
  • ✅ Comply with PCI-DSS (if storing card data)

Next Steps​

Resources​

Pro Tip

Always test with small amounts (1 KES/ETB) in sandbox before going live!