Au-delà de la paie, beaucoup de plateformes ont besoin de disburser des paiements en masse vers des wallets : commissions affiliés, remboursements, payouts marketplace, bourses étudiantes, aides sociales. En 2026, l'écosystème B2B mobile money africain est encore fragmenté — voici comment l'unifier.
TL;DR
- 4 providers majeurs UEMOA + Anglophone : Wave, OM, MTN MoMo, Airtel Money.
- Architecture unifiée : payment-router avec connector par provider.
- Idempotence + retry + reconciliation comptable critiques.
Architecture unifiée
`
[Plateforme]
↓
[Bulk payout request]
↓
[PayoutRouter — choisit provider par receiver]
↓
┌───┴───┬──────┬──────┐
↓ ↓ ↓ ↓
[Wave] [OM] [MTN] [Airtel]
↓ ↓ ↓ ↓
└───┬───┴──────┴──────┘
↓
[Webhook callbacks]
↓
[Reconciliation + audit log]
`
Étape 1 — abstraction provider commune
`ts
// lib/payouts/types.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; // votre ID
amount: number; // XOF entier
currency: 'XOF' | 'XAF' | 'GHS' | 'NGN' | 'KES';
receiverMsisdn: string;
narration: string;
metadata?: Record
}
`
Étape 2 — connector par provider
Wave Connector
`ts
class WaveConnector implements PayoutProvider {
name = 'wave';
countries = ['SN', 'CI', 'ML', 'BF'];
async initiateBulk(payouts: PayoutRequest[]) {
// ... voir article Wave Business B2B
}
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();
// MTN MoMo nécessite 1 call par receiver (pas de bulk natif)
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' };
}
}
`
Routing intelligent
`ts
// lib/payouts/router.ts
const PROVIDERS: Record
wave: new WaveConnector(),
mtn: new MtnMomoConnector(),
om: new OrangeMoneyConnector(),
airtel: new AirtelConnector(),
};
export async function routeBulkPayouts(payouts: PayoutRequest[]) {
// Grouper par provider selon receiver country + détection wallet
const byProvider: Record
for (const p of payouts) {
const provider = await detectProvider(p.receiverMsisdn);
if (!byProvider[provider]) byProvider[provider] = [];
byProvider[provider].push(p);
}
// Disbursement parallèle
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 };
Besoin d'un site web professionnel ?
Kolonell crée des sites web qui attirent des clients, optimisés pour le marché sénégalais. Devis gratuit en 2 minutes.
})
);
return results.flat();
}
async function detectProvider(msisdn: string): Promise
// Logique : préfixe + heuristiques + base data
if (msisdn.startsWith('+221')) {
// SN : Wave > OM > Free
const stored = await prisma.user.findFirst({
where: { phone: msisdn },
select: { preferredWalletProvider: true },
});
return stored?.preferredWalletProvider ?? 'wave';
}
// etc. pour autres pays
}
`
Étape 3 — reconciliation + audit
`prisma
model BulkPayoutBatch {
id String @id @default(cuid())
organizationId String
initiatedBy String // user.id
reason String // PAYROLL / COMMISSIONS / REFUNDS / SUBSIDY / OTHER
totalAmount Int
totalReceivers Int
status String // DRAFT / IN_PROGRESS / COMPLETED / PARTIALLY_FAILED
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 // WAVE / OM / MTN / AIRTEL
providerTxId String?
status String // PENDING / SUCCESS / FAILED / REVERSED
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 // INITIATED / PROCESSED / WEBHOOK_RECEIVED / RECONCILED
payload Json
timestamp DateTime @default(now())
}
`
Étape 4 — retry et idempotence
`ts
// jobs/retry-failed-payouts.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 {
// Idempotence via externalId : si déjà traité côté provider, retournera SUCCESS
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) {
// Logger
}
}
}
`
Cas d'usage typiques
Marketplace commissions
Plateforme avec 2 000 vendeurs payés mensuellement = bulk disbursement Wave/OM/MTN selon leur pays.
Affiliés / apporteurs d'affaires
Programme apporteurs Kolonell : commissions mensuelles vers wallets Wave/OM des apporteurs.
Remboursements customers
E-commerce : refund vers wallet d'origine. Voir refund Wave/OM →.
Bourses / aides sociales
ONG distribuant aides : 5 000 bénéficiaires/mois × 25K XOF = 125M FCFA disbursés en 1h vs 3 mois manuel.
Subsides agricoles
Programme gouvernemental SN distribution intrants : 8 000 paysans, paiement direct wallet OM = transparence + traçabilité.
Coûts comparés
| Provider | Frais bulk | Délai | 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 | ✓ |
| Banque virement bulk | 200 XOF/transaction | T+1 jours | — |
Pour > 100 transactions : mobile money domine en coût et délai.
FAQ
Q : Plafond bulk Wave ?
R : 50M XOF par batch. Au-delà, splitter en plusieurs batches.
Q : Reverser FCFA d'une plateforme USD ?
R : Stripe Connect peut payer en USD vers compte EUR/USD, puis conversion XOF via Wise. Pas direct vers Wave wallets.
Q : Conformité ?
R : Loi 2008-12 SN sur traitement données wallet numbers. AML/KYC strict si volume > 50M FCFA/mois (déclaration BCEAO).
Conclusion
Bulk payouts mobile money Afrique en 2026 = brique critique pour plateformes scaling. Architecture unifiée 4-12 semaines selon ambition. Économies massives vs paie manuelle / virements bancaires / agences cash.
Mohamed Bah
Fondateur, Kolonell
Passionné par le digital et l'entrepreneuriat en Afrique, Mohamed accompagne les entreprises sénégalaises dans leur transformation digitale depuis 2020. Fondateur de Kolonell, il croit que chaque PME mérite une présence en ligne professionnelle et accessible.

