The Problem With Picking One Payment Provider

If you are building a SaaS product for the African market, you have already discovered the uncomfortable truth: no single payment provider covers everyone you want to reach. Paystack is excellent for card payments and bank transfers in Nigeria, Ghana, and a growing number of markets. MTN Mobile Money is the default financial instrument for tens of millions of users across Ghana, Uganda, Côte d'Ivoire, and beyond — many of whom do not own a credit card.

The typical approach is to integrate one provider, ship, and promise yourself you will add the other later. That backlog ticket usually ages quietly while you watch conversion rates suffer among users who simply cannot pay with what you have offered them.

The better approach is a thin abstraction layer that treats both providers as interchangeable adapters behind a single checkout interface. This article walks through exactly that pattern.


Designing the Abstraction Layer

The core idea is simple: your application code should never call MTN MoMo or Paystack directly. Instead, it calls a PaymentGateway interface, and a routing function decides which concrete provider handles the transaction at runtime.

The router needs two inputs:

  • Country code — determines which providers are legally and operationally available.
  • Payment method type — distinguishes mobile money from card/bank channels.

Here is a minimal TypeScript implementation:

type PaymentMethod = "mobile_money" | "card" | "bank_transfer";

interface CheckoutPayload {
  amount: number;         // in smallest currency unit (pesewas, kobo, etc.)
  currency: string;       // ISO 4217: GHS, NGN, UGX …
  country: string;        // ISO 3166-1 alpha-2: GH, NG, UG …
  method: PaymentMethod;
  customer: { email: string; phone?: string };
  reference: string;
}

interface PaymentGateway {
  initiate(payload: CheckoutPayload): Promise<{ redirectUrl?: string; ussdCode?: string; status: string }>;
  verify(reference: string): Promise<{ paid: boolean; amount: number }>;
}

function resolveGateway(payload: CheckoutPayload): PaymentGateway {
  const { country, method } = payload;

  if (method === "mobile_money" && ["GH", "UG", "CI"].includes(country)) {
    return new MoMoGateway();
  }

  // Paystack handles cards and bank transfers for NG, GH, ZA, KE
  if (["NG", "GH", "ZA", "KE"].includes(country)) {
    return new PaystackGateway();
  }

  throw new Error(`No supported gateway for ${method} in ${country}`);
}

This keeps all routing logic in one place. When you add a new provider — say, Flutterwave for Francophone markets — you update resolveGateway and nothing else changes downstream.


Implementing the MoMo Adapter

MTN MoMo's API uses an OAuth 2.0 client-credentials flow. You acquire a token per product (Collections, Disbursements, Remittances) and then POST a payment request. The gotcha most developers hit: the API is asynchronous. You initiate a "RequestToPay," the user gets a USSD push prompt, and the result arrives via webhook — not in the original HTTP response.

Your MoMoGateway.initiate() should:

  1. Exchange your API key and subscription key for a bearer token.
  2. POST to /collection/v1_0/requesttopay with the amount, currency, and the customer's MSISDN (phone number, no leading zero, no +).
  3. Return immediately with the X-Reference-Id you generated (use UUID v4) — this becomes your transaction reference.
  4. Store the reference in your database with a pending status.

Your webhook handler then calls MoMoGateway.verify(), hits /collection/v1_0/requesttopay/{referenceId}, and updates the record to paid or failed.

One operational note: the MoMo sandbox and production environments use different base URLs and require separate credentials. Encode this in your environment config, not in the adapter code itself.


Implementing the Paystack Adapter

Paystack's Collections API is more synchronous-friendly. PaystackGateway.initiate() calls /transaction/initialize, receives an authorization_url, and returns it. Your frontend redirects the user there. On completion, Paystack redirects back to your callback_url, and you call /transaction/verify/{reference} to confirm.

For mobile money on Paystack (available in Ghana), you pass "mobile_money" as the channel along with the customer's phone number. This means Paystack can also handle some MoMo transactions — which is exactly why the routing logic matters. In Ghana, you may want to route MoMo directly through MTN's own API for lower fees and faster settlement, while falling back to Paystack for cards.


Presenting a Clean Checkout UI

With the backend routing handled, your checkout UI becomes straightforward. Present the user with relevant options based on their detected or selected country:

  • Ghana: Card, Mobile Money (MTN/Vodafone), Bank Transfer
  • Nigeria: Card, Bank Transfer, USSD
  • Uganda: Mobile Money (MTN/Airtel)

When the user selects a method and submits, your frontend sends a single POST to your own /api/checkout endpoint with the method and country. Your backend calls resolveGateway(), initiates the transaction, and returns either a redirect URL (Paystack card flow) or a confirmation that a USSD push has been sent (MoMo flow). The frontend renders the appropriate next step.

This single endpoint contract means your frontend never needs to know which provider is active.


Handling Failures Gracefully

Failed transactions are the silent killers of SaaS revenue in African markets. Network timeouts on MoMo are common; insufficient funds responses on Paystack need a clear user message. Build these behaviors into your abstraction:

  • Retry logic: For MoMo timeouts, expose a /api/checkout/retry/{reference} endpoint that re-queries the provider status before deciding to re-initiate.
  • Fallback routing: If a user's MoMo initiation fails after two attempts, offer Paystack as an alternative if their country supports it.
  • Webhook idempotency: Both providers may deliver duplicate webhook events. Store processed references in a set and skip duplicates before updating order state.

A Note on Currencies and Amounts

MTN MoMo amounts must be in whole units (no decimals), and currencies are passed as ISO codes. Paystack amounts are in the smallest currency unit (kobo for NGN, pesewas for GHS). Normalize this in your CheckoutPayload — pick one convention (smallest unit recommended) and let each adapter convert as needed internally.


Why This Matters for Your Project

If you are scaling a SaaS product across more than one African country, every payment provider you bolt on independently adds maintenance surface area, duplicated webhook handlers, and inconsistent error states. A unified gateway abstraction keeps your business logic clean, makes A/B testing providers trivially easy, and lets you onboard new payment rails — whether that is Airtel Money, Orange Money, or a local bank API — without touching checkout code. The upfront investment in this pattern pays back the first time you expand to a new market without a two-week integration sprint.