PayGate

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:

ProviderMarket ShareCode
MTN Mobile Money~60%mtn
Telecel Cash~25%telecel
AirtelTigo Money~15%airteltigo

Phone Number Prefixes

Each provider has specific phone number prefixes:

ProviderPrefixes
MTN024, 054, 055, 059
Telecel020, 050
AirtelTigo027, 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

  1. Sign up for a PayGate account
  2. Complete your business verification
  3. Get your API keys from the Dashboard

Install the SDK

npm install @paygate/node
pip install paygate-python
composer require paygate/paygate-php
go get github.com/paygate/paygate-go

Create 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 confirm

Set 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 NumberBehavior
0241234567Succeeds immediately
0241234568Pending, then succeeds after 30s
0241234560Fails with insufficient funds
0241234561Fails with transaction declined
0241234563Times 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
})