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 today | Becomes | Reach |
|---|---|---|
| Collections and Disbursements | cm.mtn, ci.mtn, gh.mtn, 10 countries | |
| Web Payment, one API per market | cm.orange, sn.orange, 8 countries | |
| Daraja STK Push | ke.mpesa, tz.mpesa, mz.mpesa | |
| Airtel Africa API | ke.airtel, ug.airtel, 9 countries | |
| One API per market | ci.moov, bj.moov, 6 countries | |
| Wave Business API | ci.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 integration | Wajub equivalent |
|---|---|
| Initialise a payment | POST /payments |
| Payment page | authorization_url, from that same response |
| Confirmation | A signed webhook, then GET /payments/{id} |
| Refund | POST /refunds |
| Payout | POST /transfers |
| Operator selection | The 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.
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.
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 };
}
}Store the id we return, not only your own reference
payment.id is what every later call takes. Your reference is carried on the payment and shown
back to you, but it is not a lookup key: GET /payments/{your-reference} answers 404. Keep both
columns on your order and index the one we minted.
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.
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.
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 elsewhere | Here |
|---|---|
success, successful, completed | succeeded |
pending, initiated | pending |
ongoing, processing | processing |
| No equivalent | partial, part of a split collected |
failed, declined | failed |
cancelled | cancelled |
abandoned, timeout | expired |
refunded | refunded |
| No equivalent | partially_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.