The Split That Costs You Conversions
Picture this: a user in Accra lands on your SaaS checkout page. They do not have a Visa card — or they have one but the 3DS verification keeps failing. They want to pay with MTN Mobile Money. Your app does not support it. They leave.
Now flip it. A user in Lagos wants to pay with a debit card. Your app only has a MoMo integration because you built it for the Ghanaian market first. They leave too.
This is the quiet conversion killer inside most African SaaS products. Teams pick one payment rail and ship. The fix is not complicated — but it does require thinking about payments as an abstraction rather than a direct API call.
Why Both Rails Matter
MTN MoMo dominates mobile money in Ghana, Uganda, Cameroon, and Côte d'Ivoire, with hundreds of millions of active wallets across sub-Saharan Africa. Paystack, on the other hand, is the card and bank-transfer backbone for Nigeria, Ghana, Kenya, and South Africa — with a polished API and strong developer tooling.
These two systems are not in competition on your checkout page. They are complementary. Card users skew toward higher-income urban professionals. MoMo users include an enormous segment of the population that is either unbanked or simply prefers the convenience of wallet payments.
Supporting both is not just good UX — it is a direct revenue decision.
Designing the Abstraction Layer
The core idea is simple: your application code should never care which payment provider is handling a transaction. It should speak to a PaymentProvider interface, and the concrete implementation — MoMo or Paystack — is resolved at runtime based on what the user selects.
Here is a minimal TypeScript interface that captures this:
// payment-provider.interface.ts
export interface PaymentProvider {
initiate(payload: PaymentPayload): Promise<PaymentResponse>;
verify(reference: string): Promise<VerificationResult>;
}
export interface PaymentPayload {
amount: number; // in minor units (pesewas / kobo)
currency: string; // e.g. "GHS", "NGN"
customerPhone?: string;
customerEmail?: string;
reference: string;
metadata?: Record<string, unknown>;
}
export interface PaymentResponse {
providerReference: string;
checkoutUrl?: string; // Paystack popup or redirect
ussdPrompt?: string; // MoMo USSD instruction for fallback
status: "pending" | "initiated" | "failed";
}
export interface VerificationResult {
status: "success" | "failed" | "pending";
amount: number;
currency: string;
providerReference: string;
}
Both MomoProvider and PaystackProvider implement this interface. Your checkout controller does not import either directly — it imports a PaymentProviderFactory.
Implementing the Factory
// payment-provider.factory.ts
import { MomoProvider } from "./momo.provider";
import { PaystackProvider } from "./paystack.provider";
import { PaymentProvider } from "./payment-provider.interface";
export type PaymentMethod = "momo" | "card" | "bank_transfer";
export function getPaymentProvider(method: PaymentMethod): PaymentProvider {
switch (method) {
case "momo":
return new MomoProvider({
subscriptionKey: process.env.MTN_MOMO_SUBSCRIPTION_KEY!,
apiUser: process.env.MTN_MOMO_API_USER!,
apiKey: process.env.MTN_MOMO_API_KEY!,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
});
case "card":
case "bank_transfer":
return new PaystackProvider({
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
default:
throw new Error(`Unsupported payment method: ${method}`);
}
}
Your checkout endpoint now looks like this:
app.post("/checkout/initiate", async (req, res) => {
const { method, amount, currency, customerPhone, customerEmail } = req.body;
const reference = generateReference(); // your own idempotent reference
const provider = getPaymentProvider(method);
const response = await provider.initiate({
amount,
currency,
customerPhone,
customerEmail,
reference,
});
return res.json({ reference, ...response });
});
Clean. Extensible. Adding Flutterwave or Hubtel tomorrow means writing one new class and one new case in the factory — nothing else changes.
Handling the Async Gap
The biggest UX challenge when combining these two providers is their fundamentally different payment flows.
Paystack is synchronous from the user's perspective: they enter card details, the popup closes, and your frontend immediately knows the result via a callback.
MTN MoMo is asynchronous: you call the Collections API, the user receives a USSD push prompt on their phone, they approve it, and MTN later sends a webhook to your server — sometimes within seconds, sometimes after a minute or two.
Handle this with a polling + webhook hybrid:
- After initiating a MoMo payment, store the
externalId(your reference) andproviderReferencein your database with apendingstatus. - On the frontend, begin polling
GET /checkout/status/:referenceevery 4 seconds. - On the backend, expose that status endpoint to read from your database — not the MoMo API directly (avoid rate limits).
- When MTN's webhook fires and updates the status to
success, the next poll cycle picks it up and your frontend transitions to the success screen.
Set a polling timeout of 90 seconds. If the status is still pending after that window, surface a "Check your phone for the MoMo prompt" message with a manual refresh option. Do not auto-fail — the user may have dismissed the prompt and can re-approve from their MoMo menu.
Checkout UX Considerations
A few decisions that make a real difference in conversion:
- Show both options by default. Do not try to detect the user's country and pre-select a method. Detection fails, and users know what they want to pay with.
- Label clearly. "Pay with MoMo" and "Pay with Card / Bank Transfer" are clearer than provider logos alone — especially in markets where users may not recognise the MTN MoMo brand logo.
- Phone number field placement. For MoMo, collect the mobile number on the same screen as the method selection. Do not redirect to a new page. Every extra step loses users.
- Amounts in local currency. Display the amount in GHS or NGN, not USD, even if your internal records are USD-denominated. Convert at the point of checkout using a reliable exchange rate API and lock the rate for the duration of the session.
Testing Across Both Providers
MTN MoMo provides a sandbox environment through their developer portal at momodeveloper.mtn.com. The sandbox supports simulated approvals and failures using specific test MSISDN values. Use these to automate your payment initiation tests.
Paystack's test mode is even simpler — test card numbers are well-documented, and you can trigger various failure scenarios (insufficient funds, declined, etc.) without real money moving.
Build a PaymentProvider mock that implements the same interface for your unit tests. This keeps your test suite fast and provider-agnostic.
Why This Matters for Your Project
If you are building a SaaS product aimed at African markets — or any market where multiple payment rails coexist — the abstraction pattern above is not over-engineering. It is the minimum responsible architecture. Starting with a single hardcoded provider creates technical debt that compounds every time you expand to a new country or onboard a new payment partner. A clean provider interface costs you an extra hour on day one and saves days of refactoring six months later, when your next funding round depends on launching in Nigeria and Ghana and Uganda — all at once.





