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!