Skip to content

Migrate a provider

Map the old integration, run both side by side, then cut over.

This works the same whether you are leaving an aggregator, a direct operator API or code somebody wrote four years ago. The order matters more than the technique: map first, run both second, cut last.

What you are replacing

Every direct operator integration collapses into the same call. You stop maintaining one client, one credential set and one webhook format per operator.

Integrated directly todayBecomesReach
MTN MoMoMTN MoMoCollections and Disbursementscm.mtn, ci.mtn, gh.mtn, 10 countries
Orange MoneyOrange MoneyWeb Payment, one API per marketcm.orange, sn.orange, 8 countries
M-PesaM-PesaDaraja STK Pushke.mpesa, tz.mpesa, mz.mpesa
Airtel MoneyAirtel MoneyAirtel Africa APIke.airtel, ug.airtel, 9 countries
Moov MoneyMoov MoneyOne API per marketci.moov, bj.moov, 6 countries
WaveWaveWave Business APIci.wave, sn.wave

Coverage lists every country and channel, and it is worth checking before anything else. A corridor you depend on that is not there changes the plan.

Phase 1: map what you have

Your current integrationWajub equivalent
Initialise a paymentPOST /payments
Payment pageauthorization_url, from that same response
ConfirmationA signed webhook, then GET /payments/{id}
RefundPOST /refunds
PayoutPOST /transfers
Operator selectionThe channel field at processing

Write that table out for your own code before writing any. The rows you cannot fill are the real migration, and they are usually in the confirmation path.

Phase 2: put both behind one interface

Two providers can only run side by side if the rest of your code cannot tell them apart. One interface, one adapter each.

The interface both providers implement
export class PaymentGateway {
  async initialize(order) { throw new Error('not implemented'); }
  async verify(id) { throw new Error('not implemented'); }
  async refund(paymentId, amount) { throw new Error('not implemented'); }
}

The Wajub side is the official SDK, not a hand-rolled fetch wrapper. It carries the retry behaviour, the idempotency keys and the typed errors you would otherwise write twice.

The Wajub adapter
import Wajub from '@wajub/node';
import { PaymentGateway } from './payment-gateway.js';

const wajub = new Wajub({
  secretKey: process.env.WAJUB_SECRET_KEY,
  webhookSecret: process.env.WAJUB_WEBHOOK_SECRET,
});

export class WajubGateway extends PaymentGateway {
  async initialize(order) {
    const payment = await wajub.payments.create(
      {
        amount: order.amount,
        currency: order.currency,
        customer: { email: order.email, phone: order.phone },
        reference: `order-${order.id}`,
        callback: `${process.env.BASE_URL}/payment/return`,
      },
      { idempotencyKey: `order-${order.id}` },
    );

    return { id: payment.id, redirectUrl: payment.authorization_url };
  }

  async verify(id) {
    const payment = await wajub.payments.retrieve(id);
    return { status: payment.status, amount: payment.amount };
  }

  async refund(paymentId, amount) {
    const refund = await wajub.refunds.create({
      payment: paymentId,
      amount,
      reason: 'requested_by_customer',
    });

    return { id: refund.id, status: refund.status };
  }
}

Phase 3: run both

Keep the old provider authoritative while Wajub runs in sandbox on the same traffic. The point is not to take money twice, it is to compare two answers to one question.

Mirror into sandbox without touching the live path
export async function initialize(order) {
  const legacy = await legacyGateway.initialize(order);

  if (process.env.WAJUB_MIRROR === 'true') {
    wajubSandboxGateway
      .initialize(order)
      .then((mirrored) => logComparison(order.id, legacy, mirrored))
      .catch((err) => logger.warn({ err, orderId: order.id }, 'mirror failed'));
  }

  return legacy;
}

The mirrored call is deliberately not awaited and its failure is deliberately not fatal. A mirror that can break checkout is worse than no mirror.

Phase 4: shift traffic

Bucket on something stable, so a customer who reloads does not hop between providers mid-order.

A dial, not a switch
import { createHash } from 'node:crypto';

function bucketOf(orderId) {
  const digest = createHash('sha256').update(String(orderId)).digest('hex');
  return parseInt(digest.slice(0, 8), 16) % 100;
}

export function gatewayFor(orderId) {
  const percent = Number(process.env.WAJUB_ROLLOUT_PERCENT ?? 0);
  return bucketOf(orderId) < percent ? wajubGateway : legacyGateway;
}

Raise the percentage while success rate, time to confirmation and refund behaviour all hold. Any one of them moving is a reason to pause, not to push on.

The four differences that break integrations

Amounts are in major units

amount: 25000 means 25,000 XAF. Providers that take kobo, pesewas or cents will have taught your code to multiply by a hundred, and that habit turns a 250 XAF order into a 25,000 XAF one. Per currency bounds are in limits.

Statuses do not map one to one

Wajub carries nine payment statuses, and two of them have no equivalent in most providers.

Status elsewhereHere
success, successful, completedsucceeded
pending, initiatedpending
ongoing, processingprocessing
No equivalentpartial, part of a split collected
failed, declinedfailed
cancelledcancelled
abandoned, timeoutexpired
refundedrefunded
No equivalentpartially_refunded

Treat partial and processing as not-yet-final rather than folding them into failure, or you will cancel orders that were about to succeed.

The browser is not the confirmation

If your old provider let you mark an order paid on the return URL, that shortcut does not survive the move. The redirect tells you the payer came back, nothing more. The signed webhook, or a server-side GET /payments/{id}, is what marks an order paid.

Webhook signatures are not optional

Every delivery carries a timestamp and an HMAC-SHA256 signature, and verifying it is the whole security model. Verify against the raw body before parsing. Signature verification has the code for each language.

Reconcile before you cut

Run both for at least 72 hours of real traffic and compare amounts, statuses and fees in Konsole. A mismatch found there costs a query. The same mismatch found after cutover costs a reconciliation.

After the cut

The integrations you kept for redundancy stop earning their keep. Orchestration routes across providers from one call, so an operator outage becomes a fallback rather than a lost transaction, and waterfall failover is where that is configured.

What did you think of this content?