Beyond payroll, many platforms need to bulk-disburse payments to wallets: affiliate commissions, refunds, marketplace payouts, student grants, social aid. In 2026, the African B2B mobile money ecosystem is still fragmented — here's how to unify it.
TL;DR
- 4 major providers UEMOA + Anglophone: Wave, OM, MTN MoMo, Airtel Money.
- Unified architecture: payment-router with per-provider connector.
- Critical idempotency + retry + accounting reconciliation.
Unified architecture
`
[Platform]
↓
[Bulk payout request]
↓
[PayoutRouter — picks provider per receiver]
↓
┌───┴───┬──────┬──────┐
↓ ↓ ↓ ↓
[Wave] [OM] [MTN] [Airtel]
↓ ↓ ↓ ↓
└───┬───┴──────┴──────┘
↓
[Webhook callbacks]
↓
[Reconciliation + audit log]
`
Step 1 — common provider abstraction
`ts
export interface PayoutProvider {
name: string;
countries: string[];
initiateBulk(payouts: PayoutRequest[]): Promise
getStatus(externalId: string): Promise
verifyWebhook(body: string, signature: string): boolean;
}
interface PayoutRequest {
externalId: string;
amount: number;
currency: 'XOF' | 'XAF' | 'GHS' | 'NGN' | 'KES';
receiverMsisdn: string;
narration: string;
metadata?: Record
}
`
Step 2 — per-provider connector
Wave Connector
`ts
class WaveConnector implements PayoutProvider {
name = 'wave';
countries = ['SN', 'CI', 'ML', 'BF'];
async initiateBulk(payouts: PayoutRequest[]) {
// ... see Wave Business B2B article
}
async getStatus(externalId: string) {
const res = await fetch(https://api.wave.com/v1/business/disbursements/${externalId}, {
headers: { 'Authorization': Bearer ${process.env.WAVE_KEY} },
});
return res.json();
}
}
`
MTN MoMo Connector
`ts
class MtnMomoConnector implements PayoutProvider {
name = 'mtn-momo';
countries = ['CI', 'GH', 'NG', 'CM', 'UG', 'RW', 'ZM'];
async initiateBulk(payouts: PayoutRequest[]) {
const token = await this.getOAuthToken();
const results = await Promise.all(
payouts.map(p => this.singleTransfer(p, token))
);
return { batchId: crypto.randomUUID(), results };
}
private async singleTransfer(payout: PayoutRequest, token: string) {
const referenceId = crypto.randomUUID();
await fetch('https://sandbox.momodeveloper.mtn.com/disbursement/v1_0/transfer', {
method: 'POST',
headers: {
'Authorization': Bearer ${token},
'X-Reference-Id': referenceId,
'X-Target-Environment': process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
'Ocp-Apim-Subscription-Key': process.env.MTN_SUB_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: String(payout.amount),
currency: payout.currency,
externalId: payout.externalId,
payee: { partyIdType: 'MSISDN', partyId: payout.receiverMsisdn },
payerMessage: payout.narration,
payeeNote: payout.narration,
}),
});
return { externalId: payout.externalId, referenceId, status: 'PENDING' };
}
}
`
Smart routing
`ts
const PROVIDERS: Record
wave: new WaveConnector(),
mtn: new MtnMomoConnector(),
om: new OrangeMoneyConnector(),
airtel: new AirtelConnector(),
};
export async function routeBulkPayouts(payouts: PayoutRequest[]) {
const byProvider: Record
for (const p of payouts) {
const provider = await detectProvider(p.receiverMsisdn);
if (!byProvider[provider]) byProvider[provider] = [];
byProvider[provider].push(p);
}
const results = await Promise.all(
Object.entries(byProvider).map(async ([providerName, providerPayouts]) => {
const provider = PROVIDERS[providerName];
const result = await provider.initiateBulk(providerPayouts);
return { provider: providerName, result };
Need a professional website?
Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.
})
);
return results.flat();
}
async function detectProvider(msisdn: string): Promise
if (msisdn.startsWith('+221')) {
const stored = await prisma.user.findFirst({
where: { phone: msisdn },
select: { preferredWalletProvider: true },
});
return stored?.preferredWalletProvider ?? 'wave';
}
// etc. for other countries
}
`
Step 3 — reconciliation + audit
`prisma
model BulkPayoutBatch {
id String @id @default(cuid())
organizationId String
initiatedBy String
reason String
totalAmount Int
totalReceivers Int
status String
createdAt DateTime @default(now())
completedAt DateTime?
payouts Payout[]
}
model Payout {
id String @id @default(cuid())
batchId String
batch BulkPayoutBatch @relation(fields: [batchId], references: [id])
externalId String
receiverId String
receiverMsisdn String
amount Int
currency String
provider String
providerTxId String?
status String
errorMessage String?
initiatedAt DateTime @default(now())
completedAt DateTime?
@@unique([externalId, provider])
@@index([status, createdAt])
}
model PayoutAuditLog {
id String @id @default(cuid())
payoutId String
action String
payload Json
timestamp DateTime @default(now())
}
`
Step 4 — retry and idempotency
`ts
export async function retryFailedPayouts() {
const failed = await prisma.payout.findMany({
where: {
status: 'FAILED',
errorMessage: { in: ['NETWORK_ERROR', 'TEMPORARY_FAILURE'] },
initiatedAt: { gt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
});
for (const payout of failed) {
const provider = PROVIDERS[payout.provider];
try {
const result = await provider.initiateBulk([{
externalId: payout.externalId,
amount: payout.amount,
currency: payout.currency as any,
receiverMsisdn: payout.receiverMsisdn,
narration: 'retry',
}]);
await prisma.payout.update({
where: { id: payout.id },
data: {
status: result.results[0].status === 'success' ? 'SUCCESS' : 'FAILED',
providerTxId: result.results[0].transaction_id,
},
});
} catch (e) {
// Log
}
}
}
`
Typical use cases
Marketplace commissions
Platform with 2,000 vendors paid monthly = Wave/OM/MTN bulk disbursement per their country.
Affiliates / referrers
Kolonell referrer program: monthly commissions to referrers' Wave/OM wallets.
Customer refunds
E-commerce: refund to original wallet. See Wave/OM refund →.
Grants / social aid
NGO distributing aid: 5,000 monthly recipients × 25K XOF = 125M XOF disbursed in 1h vs 3 months manual.
Agricultural subsidies
SN government program input distribution: 8,000 farmers, direct OM wallet payment = transparency + traceability.
Cost comparison
| Provider | Bulk fees | Delay | Sandbox |
|---|---|---|---|
| Wave SN/CI | 0.5-1.0% | <5 min | ✗ |
| Orange Money | 1.5-2.0% | 5-15 min | ✓ |
| MTN MoMo | 0.5-1.5% | 5-30 min | ✓ |
| Airtel Money | 1.0-2.0% | 5-15 min | ✓ |
| Bank bulk transfer | 200 XOF/tx | T+1 days | — |
For >100 transactions: mobile money dominates on cost and delay.
FAQ
Q: Wave bulk cap?
A: 50M XOF per batch. Beyond, split into multiple batches.
Q: Disburse XOF from a USD platform?
A: Stripe Connect can pay USD to EUR/USD account, then Wise XOF conversion. Not direct to Wave wallets.
Q: Compliance?
A: SN Law 2008-12 on wallet number data processing. Strict AML/KYC if volume >50M XOF/month (BCEAO declaration).
Conclusion
African mobile money bulk payouts in 2026 = critical brick for scaling platforms. 4-12 weeks unified architecture per ambition. Massive savings vs manual payroll / bank transfers / cash agencies.
Mohamed Bah
Fondateur, Kolonell
Passionate about digital and entrepreneurship in Africa, Mohamed has been helping Sénégalese businesses with their digital transformation since 2020. Founder of Kolonell, he believes every SME deserves a professional and accessible online présence.

