Mobile Money
Complete guide to integrating mobile money payments in Ghana.
Mobile Money Integration Guide
This comprehensive guide covers everything you need to know about integrating mobile money payments in Ghana with PayGate.
Overview
Mobile money is the dominant payment method in Ghana, with over 50% of the adult population using it. PayGate supports all major providers:
| Provider | Market Share | Code |
|---|---|---|
| MTN Mobile Money | ~60% | mtn |
| Telecel Cash | ~25% | telecel |
| AirtelTigo Money | ~15% | airteltigo |
Phone Number Prefixes
Each provider has specific phone number prefixes:
| Provider | Prefixes |
|---|---|
| MTN | 024, 054, 055, 059 |
| Telecel | 020, 050 |
| AirtelTigo | 027, 057, 026, 056 |
PayGate automatically detects the provider from the phone number prefix. You don't need to specify the provider explicitly.
Integration Steps
Set Up Your Account
- Sign up for a PayGate account
- Complete your business verification
- Get your API keys from the Dashboard
Install the SDK
npm install @paygate/nodepip install paygate-pythoncomposer require paygate/paygate-phpgo get github.com/paygate/paygate-goCreate Your First Payment
const paygate = new PayGate('sk_test_...')
const payment = await paygate.payments.create({
amount: 5000, // GHS 50.00 in pesewas
currency: 'GHS',
payment_method: 'mobile_money',
phone: '0241234567', // Provider auto-detected
description: 'Order #1234'
})Handle the Response
The customer receives a prompt on their phone:
MTN Mobile Money
Payment Request from [Your Business]
Amount: GHS 50.00
Enter PIN to confirmSet Up Webhooks
Receive real-time notifications when payments complete:
app.post('/webhooks', (req, res) => {
const event = Webhook.constructEvent(req.body, signature, secret)
if (event.type === 'payment.succeeded') {
// Fulfill the order
}
res.json({ received: true })
})Complete Payment Flow
1. Customer Enters Phone Number
Build a form to collect the customer's phone number:
function PaymentForm() {
const [phone, setPhone] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
const response = await fetch('/api/create-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, amount: 5000 })
})
const { paymentId, status } = await response.json()
if (status === 'pending') {
// Show "check your phone" message
showCheckPhoneMessage()
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="024 XXX XXXX"
pattern="^0[2][04567][0-9]{7}$"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Processing...' : 'Pay GHS 50.00'}
</button>
</form>
)
}2. Create Payment on Server
// api/create-payment.ts
app.post('/api/create-payment', async (req, res) => {
const { phone, amount } = req.body
try {
const payment = await paygate.payments.create({
amount,
currency: 'GHS',
payment_method: 'mobile_money',
phone,
metadata: {
order_id: generateOrderId()
}
})
// Save payment ID to database
await db.orders.update({
where: { id: req.body.orderId },
data: { paymentId: payment.id }
})
res.json({
paymentId: payment.id,
status: payment.status
})
} catch (error) {
res.status(400).json({ error: error.message })
}
})3. Show Waiting State
While waiting for authorization:
function WaitingForPayment({ paymentId }) {
const [status, setStatus] = useState('pending')
useEffect(() => {
// Poll for status updates
const interval = setInterval(async () => {
const response = await fetch(`/api/payment-status/${paymentId}`)
const { status } = await response.json()
if (status !== 'pending') {
setStatus(status)
clearInterval(interval)
}
}, 3000) // Check every 3 seconds
return () => clearInterval(interval)
}, [paymentId])
if (status === 'pending') {
return (
<div className="text-center p-8">
<Spinner />
<h2>Check your phone</h2>
<p>Enter your PIN to complete the payment</p>
<p className="text-sm text-gray-500">
This usually takes less than a minute
</p>
</div>
)
}
if (status === 'succeeded') {
return <PaymentSuccess />
}
return <PaymentFailed />
}4. Handle Webhook Events
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
const event = Webhook.constructEvent(
req.body,
req.headers['x-paygate-signature'],
process.env.WEBHOOK_SECRET
)
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSuccess(event.data.object)
break
case 'payment.failed':
await handlePaymentFailure(event.data.object)
break
}
res.json({ received: true })
})
async function handlePaymentSuccess(payment) {
// Update order status
await db.orders.update({
where: { paymentId: payment.id },
data: { status: 'paid' }
})
// Send confirmation email
await sendConfirmationEmail(payment.metadata.customer_email)
// Trigger fulfillment
await fulfillOrder(payment.metadata.order_id)
}Phone Number Validation
Validate phone numbers before submitting:
function isValidGhanaPhone(phone: string): boolean {
// Remove all non-digits
const cleaned = phone.replace(/\D/g, '')
// Valid prefixes
const validPrefixes = [
'024', '054', '055', '059', // MTN
'020', '050', // Telecel
'027', '057', '026', '056' // AirtelTigo
]
// Check format: 10 digits starting with 0
if (cleaned.length === 10 && cleaned.startsWith('0')) {
const prefix = cleaned.substring(0, 3)
return validPrefixes.includes(prefix)
}
// Check international format: 12 digits starting with 233
if (cleaned.length === 12 && cleaned.startsWith('233')) {
const localNumber = '0' + cleaned.substring(3)
const prefix = localNumber.substring(0, 3)
return validPrefixes.includes(prefix)
}
return false
}
function getProvider(phone: string): string | null {
const cleaned = phone.replace(/\D/g, '')
let prefix = cleaned.substring(0, 3)
if (cleaned.startsWith('233')) {
prefix = '0' + cleaned.substring(3, 5)
}
const providers = {
'024': 'mtn', '054': 'mtn', '055': 'mtn', '059': 'mtn',
'020': 'telecel', '050': 'telecel',
'027': 'airteltigo', '057': 'airteltigo', '026': 'airteltigo', '056': 'airteltigo'
}
return providers[prefix] || null
}Error Handling
Handle common mobile money errors:
async function createPaymentWithErrorHandling(params) {
try {
return await paygate.payments.create(params)
} catch (error) {
const errorMessages = {
'insufficient_funds': 'Your mobile money balance is too low. Please top up and try again.',
'phone_unreachable': 'We couldn\'t reach your phone. Please make sure it\'s on and try again.',
'timeout': 'The payment request timed out. Please try again.',
'transaction_declined': 'The transaction was declined. Please check your PIN and try again.',
'daily_limit_exceeded': 'You\'ve reached your daily limit. Please try again tomorrow.',
'invalid_phone': 'The phone number is invalid. Please check and try again.'
}
const message = errorMessages[error.code] || 'Something went wrong. Please try again.'
throw new Error(message)
}
}Best Practices
1. Always Use Webhooks
Don't rely on polling alone. Webhooks provide real-time updates:
// Don't do this as your only check
const checkStatus = setInterval(async () => {
const payment = await paygate.payments.retrieve(paymentId)
if (payment.status === 'succeeded') {
fulfillOrder()
}
}, 5000)
// Do this: Use webhooks for reliable fulfillment
app.post('/webhooks', (req, res) => {
if (event.type === 'payment.succeeded') {
fulfillOrder(event.data.object)
}
})2. Use Idempotency Keys
Prevent duplicate charges:
const payment = await paygate.payments.create({
amount: 5000,
currency: 'GHS',
payment_method: 'mobile_money',
phone: '0241234567'
}, {
idempotencyKey: `order_${orderId}_attempt_${attemptNumber}`
})3. Set User Expectations
Mobile money payments require customer action:
<div className="bg-yellow-50 p-4 rounded-lg">
<h3>How it works:</h3>
<ol>
<li>Click "Pay Now"</li>
<li>You'll receive a prompt on your phone</li>
<li>Enter your PIN to authorize</li>
<li>Payment completes in seconds</li>
</ol>
<p className="text-sm mt-2">
Make sure your phone is on and has network coverage.
</p>
</div>4. Handle Network Issues
Ghana's mobile networks can be unreliable:
async function createPaymentWithRetry(params, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await paygate.payments.create(params)
} catch (error) {
if (error.code === 'network_error' && i < maxRetries - 1) {
await new Promise(r => setTimeout(r, 2000 * (i + 1)))
continue
}
throw error
}
}
}Testing
Use test phone numbers in sandbox mode:
| Phone Number | Behavior |
|---|---|
0241234567 | Succeeds immediately |
0241234568 | Pending, then succeeds after 30s |
0241234560 | Fails with insufficient funds |
0241234561 | Fails with transaction declined |
0241234563 | Times out |
// Test successful payment
const payment = await paygate.payments.create({
amount: 5000,
currency: 'GHS',
payment_method: 'mobile_money',
phone: '0241234567' // Always succeeds in test mode
})