Mobile Money is not just a payment option in Ghana — it is the payment option. When we built the payment flow for the Pinuno Academy registration system, we knew that MoMo had to work reliably from day one. Here is what we learned.
How the Hubtel Receive Money API Works
Hubtel acts as an aggregator. You make one API call to Hubtel and they handle the network routing — whether the customer is on MTN, Telecel, or AirtelTigo. Your application sends a receive money request with the customer's number, amount, and a callback URL. Hubtel sends a USSD prompt to the customer's phone. The customer enters their PIN. Hubtel posts the result to your callback URL.
That flow looks simple. In production, every one of those steps can fail independently.
Lesson 1: Never Trust the Callback Alone
The webhook is how you find out the payment succeeded. But webhooks fail. The customer's bank server is slow. Your server is briefly unreachable. Hubtel's retry mechanism has a limit. We solved this by:
- Always creating the payment record in the database before initiating the Hubtel call — with status
pending - Polling our own payment status endpoint from the frontend every 10 seconds while the customer is on the pending screen
- Running a cron job every 30 minutes that queries Hubtel's status API for any payment that has been pending for more than 5 minutes
Between these three mechanisms, a payment that Hubtel confirms will be reflected in our database even if the original webhook never arrived.
Lesson 2: Idempotency Is Not Optional
Hubtel can send the same webhook more than once — especially if your server returns a non-200 response the first time and they retry. If your webhook handler processes the same payment twice, you will activate the same student account twice and potentially deliver credentials twice.
Our handler checks $payment->isPending() before doing anything. If the payment is already marked paid, it returns 200 OK immediately and does nothing. That check is the most important line in the entire payment flow.
Lesson 3: The MoMo Number Needs Cleaning
Your registration form will receive MoMo numbers in every format imaginable: 0241234567, +233241234567, 233241234567, 024 123 4567. Hubtel expects the local format without the country code: 0241234567. Build a normaliser and run every number through it before sending to the API. Fail fast on invalid formats with a clear validation error — do not let malformed numbers reach Hubtel and produce cryptic error responses.
// Normalise to 0XXXXXXXXX
$tel = preg_replace('/\s+/', '', $telephone);
if (str_starts_with($tel, '+233')) {
$tel = '0' . substr($tel, 4);
} elseif (str_starts_with($tel, '233')) {
$tel = '0' . substr($tel, 3);
}
Lesson 4: Give the Customer Somewhere to Go When It Fails
A declined MoMo payment is not unusual — insufficient funds, network timeout, customer pressed the wrong button. Your payment failure screen should not just say "payment failed." It should:
- Show the reference number so the customer can quote it to support
- Offer a "Retry" button that re-initiates the USSD push without making the customer re-enter their details
- Explain the most common reasons a MoMo prompt is not received (network coverage, wrong number, insufficient funds)
- Give a phone number to call if nothing works
The customer paid for your product in their head before they pressed submit. A failed payment is a bad experience. Make it as easy as possible to recover from.
Lesson 5: Log Everything
Log the raw webhook payload. Log the reference. Log what action was taken. Log when the status changed and to what. When a student comes to you saying "I paid but my account isn't active," you need to be able to trace exactly what happened — and "I don't have logs" is not an acceptable answer when someone's money is involved.
Yii::info('[webhook] ref=' . $ref . ' status=' . $status . ' providerRef=' . $provRef, 'payment');
One More Thing: Sandbox vs Production
Hubtel's sandbox environment does not actually send USSD prompts. You test the API call format, not the full customer experience. Always do at least one real test transaction in production with a small amount before going live. The sandbox will not catch normalisation issues, timeout behaviour, or network-specific quirks.
MoMo integration is manageable if you treat it seriously from the start. The edge cases are well-defined. Build for them upfront and you will not be debugging payment issues at 11pm on a weekend.