MTN Mobile Money (MoMo) SDK for Dart & Flutter

Pub Version Dart SDK Flutter License

An elegant, type-safe, and robust Dart & Flutter SDK for integrating with the MTN Mobile Money (MoMo) API. This package supports sandboxed and production environments across all active African markets (e.g., Uganda, Ghana, Cameroon, Côte d'Ivoire, Zambia).

Supported Products: Collections · Disbursements · Remittances · Sandbox Provisioning

Warning

This is an unofficial community package. It is not affiliated with, endorsed by, or officially connected to MTN Group or any of its subsidiaries.


Architecture Overview

This SDK is engineered with a layered, modular architecture. Rather than writing fragile manual HTTP wrappers, the entire API layer is generated from MTN's OpenAPI specifications, ensuring full coverage, strict compliance, and future-proof extensibility.

graph TD
    subgraph core ["Core Package APIs"]
        MMC[MtnMomo Wrapper] --> |High-Level Orchestration| MC[MtnMomoClient Coordinator]
        MI[MomoInterceptor] --> |Auth & Header Injection| MMC
        TM[TokenManager] --> |Thread-Safe Token Caching| MI
    end

    subgraph gen ["Generated Clients (Retrofit & dart_mappable)"]
        MC --> CC[CollectionClient]
        MC --> DC[DisbursementsClient]
        MC --> RC[RemittanceClient]
        MC --> SC[SandboxProvisioningClient]
    end

    subgraph err ["Error Handling"]
        ME[mapDioException] --> MME[MtnMomoException Hierarchy]
        MME --> MTE[MtnMomoTransactionException with Error Codes]
    end

    CC & DC & RC & SC -.-> |HTTP Requests via Dio| MTN[MTN MoMo Gateway]

Highlights

  • Unified Client Coordinator (MtnMomoClient): A single entry point providing access to generated clients: CollectionClient, DisbursementsClient, RemittanceClient, and SandboxProvisioningClient.
  • Advanced High-Level Wrapper (MtnMomo): Handles tedious authentication plumbing automatically — including Remittances.
  • Automated OAuth2 Token Lifecycle: Built-in token caching, lifecycle validation, and lazy auto-refresh per product.
  • Concurrent Token Deduplication: Concurrent API requests seamlessly await a single ongoing token generation process, preventing race conditions or redundant token creation hits.
  • Rich Native Exception Hierarchy: Maps complex raw HTTP & MTN errors into distinct Dart Exceptions (MtnMomoNetworkException, MtnMomoAuthException, MtnMomoTransactionException, etc.).

Getting Started

Installation

Add the package to your pubspec.yaml:

dependencies:
  mtn_momo_sdk: ^0.0.1
  dio: ^5.9.0

Run pub get:

dart pub get

Best Practice: Product Token Isolation

Important

Collections, Disbursements, and Remittances are configured as separate products on the MTN MoMo Developer Portal and each uses distinct subscriptions, User IDs, API Keys, and target environment scopes.

Under the hood, MtnMomo utilizes a local TokenManager cache. If you attempt to share a single MtnMomo instance across products, their access tokens will collide and overwrite each other in the shared cache, resulting in 401 Unauthorized or 403 Forbidden errors.

Recommendation: Always instantiate separate, dedicated instances of MtnMomo per product:

// Dedicated Collections Instance
final collectionsMomo = MtnMomo(
baseUrl: 'https://sandbox.momodeveloper.mtn.com',
subscriptionKey: collectionsSubKey,
userId: collectionsUserId,
apiKey: collectionsApiKey,
);

// Dedicated Disbursements Instance
final disbursementsMomo = MtnMomo(
baseUrl: 'https://sandbox.momodeveloper.mtn.com',
subscriptionKey: disbursementsSubKey,
userId: disbursementsUserId,
apiKey: disbursementsApiKey,
);

// Dedicated Remittances Instance
final remittancesMomo = MtnMomo(
baseUrl: 'https://sandbox.momodeveloper.mtn.com',
subscriptionKey: remittancesSubKey,
userId: remittancesUserId,
apiKey: remittancesApiKey,
);

🛠 Complete Sandbox Walkthrough

Integrating with MTN MoMo Sandbox requires provisioning a dynamic API User and requesting an API Key before initializing transaction calls. Here is the full programmatic walkthrough:

import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:uuid/uuid.dart';
import 'package:mtn_momo_sdk/mtn_momo_sdk.dart';

final logger = Logger();

void main() async {
  const baseUrl = 'https://sandbox.momodeveloper.mtn.com';
  // Retrieve subscription key from momodeveloper.mtn.com
  const subscriptionKey = 'YOUR_OCP_APIM_SUBSCRIPTION_KEY'; 

  // Initialize the baseline Dio client
  final dio = Dio(
    BaseOptions(
      baseUrl: baseUrl,
      headers: {
        'Ocp-Apim-Subscription-Key': subscriptionKey,
        'Content-Type': 'application/json',
      },
    ),
  );

  // 1. Instantiate the Sandbox User Provisioning API Client
  final sandboxProvisioner = SandboxProvisioningClient(dio);
  final userUuid = const Uuid().v4();

  logger.i('Creating Sandboxed API User: $userUuid');
  try {
    await sandboxProvisioner.postV10Apiuser(
      xReferenceId: userUuid,
      apiUser: ApiUser(providerCallbackHost: 'your-callback-domain.com'),
    );
    logger.i('API User created successfully.');
  } catch (e) {
    logger.e('Failed to create API User', error: e);
    return;
  }

  // Allow the sandbox database to propagate the newly created user
  await Future.delayed(const Duration(seconds: 2));

  // 2. Request an API Key associated with the newly created API User
  logger.i('Requesting Sandbox API Key...');
  String? apiKey;
  try {
    final result = await sandboxProvisioner.postV10ApiuserApikey(
      xReferenceId: userUuid,
    );
    apiKey = result.apiKey;
    logger.i('Sandbox API Key acquired: $apiKey');
  } catch (e) {
    logger.e('Failed to acquire API Key', error: e);
    return;
  }

  if (apiKey == null) return;

  // 3. Initialize the production-ready MtnMomo client
  final momo = MtnMomo(
    baseUrl: baseUrl,
    subscriptionKey: subscriptionKey,
    userId: userUuid,
    apiKey: apiKey,
    targetEnvironment: 'sandbox',
  );

  // 4. Perform transaction operations (the Token is automatically fetched on the fly!)
  logger.i('Fetching Account Balance...');
  try {
    final balance = await momo.collection.getAccountBalance();
    logger.i('Current Balance: ${balance.availableBalance} ${balance.currency}');
  } catch (e) {
    logger.e('Operation failed', error: e);
  }
}

⚡ Core Integration Scenarios

Collections API

Initiate collections payments from customer wallets to your merchant account.

// Initialize the client
final momo = MtnMomo(
  baseUrl: 'https://sandbox.momodeveloper.mtn.com',
  subscriptionKey: 'YOUR_SUBSCRIPTION_KEY',
  userId: 'YOUR_PROVISIONED_USER_ID',
  apiKey: 'YOUR_PROVISIONED_API_KEY',
);

// 1. Validate customer account holder status
try {
  await momo.collection.validateAccountHolderStatus(
    accountHolderId: '256772123456',
    accountHolderIdType: 'msisdn',
  );
  print('Account holder is active and verified.');
} on MtnMomoTransactionException catch (e) {
  print('Account holder validation failed: ${e.errorCode.description}');
}

// 2. Request customer payment (Push USSD)
final referenceUuid = 'a9b8c7d6-e5f4-3a2b-1c0d-9e8f7a6b5c4d'; // Unique UUID v4
final requestToPayBody = RequestToPay(
  amount: '5000',
  currency: 'EUR',
  externalId: 'PAY_INV_88764',
  payer: const Party(
    partyIdType: PartyPartyIdType.msisdn,
    partyId: '256772123456',
  ),
  payerMessage: 'Premium Subscription Renewal',
  payeeNote: 'Thank you for choosing Antigravity Solutions',
);

try {
  await momo.collection.requesttoPay(
    xReferenceId: referenceUuid,
    body: requestToPayBody,
  );
  print('Payment request dispatched to customer handset.');
} catch (e) {
  print('Payment initialization error: $e');
}

// 3. Poll transaction status
try {
  final status = await momo.collection.requesttoPayTransactionStatus(
    referenceId: referenceUuid,
  );
  print('Transaction Status: ${status.status}');
  print('Reason Code: ${status.reason?.code}');
} catch (e) {
  print('Status check failed: $e');
}

Disbursements API

Safely pay out money from your merchant account directly into a recipient's mobile wallet.

// 1. Initialize Transfer
final transferUuid = 'f8e7d6c5-b4a3-2b1a-0f9e-8d7c6b5a4f3e'; // Unique UUID v4
final transferBody = Transfer(
  amount: '12000',
  currency: 'EUR',
  externalId: 'DISB_SAL_4431',
  payee: const Party(
    partyIdType: PartyPartyIdType.msisdn,
    partyId: '256772987654',
  ),
  payerMessage: 'Monthly Salary Disbursement',
  payeeNote: 'Salary processed successfully',
);

try {
  await momo.disbursements.transfer(
    xReferenceId: transferUuid,
    body: transferBody,
  );
  print('Disbursement transfer initialized.');
} catch (e) {
  print('Disbursement initialization failed: $e');
}

// 2. Fetch disbursement transfer status
try {
  final status = await momo.disbursements.getTransferStatus(
    referenceId: transferUuid,
  );
  print('Disbursement Status: ${status.status}');
} catch (e) {
  print('Disbursement check failed: $e');
}

Remittances API

Send cross-border money transfers internationally with full payer identity support for compliance.

// Use a dedicated MtnMomo instance for Remittances!
final remittanceMomo = MtnMomo(
  baseUrl: 'https://sandbox.momodeveloper.mtn.com',
  subscriptionKey: 'YOUR_REMITTANCES_SUBSCRIPTION_KEY',
  userId: 'YOUR_PROVISIONED_USER_ID',
  apiKey: 'YOUR_PROVISIONED_API_KEY',
);

// 1. Initiate a standard remittance transfer
final remitUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; // Unique UUID v4
try {
  await remittanceMomo.remittance.transfer(
    xReferenceId: remitUuid,
    body: Transfer(
      amount: '1000',
      currency: 'EUR',
      externalId: 'REM_INV_20241',
      payee: const Party(
        partyIdType: PartyPartyIdType.msisdn,
        partyId: '256772987654',
      ),
      payerMessage: 'Family support',
      payeeNote: 'Remittance received',
    ),
  );
  print('Remittance transfer initiated.');
} catch (e) {
  print('Remittance failed: $e');
}

// 2. Initiate a cross-border cash transfer with full payer identity
final cashUuid = 'b2c3d4e5-f6a7-8901-bcde-f12345678901'; // Unique UUID v4
try {
  await remittanceMomo.remittance.cashTransfer(
    xReferenceId: cashUuid,
    body: CashTransfer(
      amount: '1000',
      currency: 'EUR',
      externalId: 'CASH_REM_441',
      payee: const Party(
        partyIdType: PartyPartyIdType.msisdn,
        partyId: '256772987654',
      ),
      // Cross-border origination details
      orginatingCountry: 'SE',
      originalAmount: '12000',
      originalCurrency: 'SEK',
      payerMessage: 'Cross-border remittance',
      payeeNote: 'Cash received',
      // Payer identity for compliance
      payerIdentificationType: CashTransferPayerIdentificationType.pass,
      payerIdentificationNumber: 'AB123456',
      payerFirstName: 'Erik',
      payerSurName: 'Andersson',
      payerLanguageCode: 'sv',
      payerEmail: 'erik@example.se',
      payerMsisdn: '46701234567',
      payerGender: 'M',
    ),
  );
  print('Cash transfer initiated.');
} catch (e) {
  print('Cash transfer failed: $e');
}

// 3. Poll cash transfer status
try {
  final cashStatus = await remittanceMomo.remittance.getCashTransferStatus(
    referenceId: cashUuid,
  );
  print('Cash Transfer Status: ${cashStatus.status}');
} catch (e) {
  print('Cash transfer status check failed: $e');
}

📂 Standalone Modular Examples Suite

We have created individual standalone example files for each core integration scenario in the example/ directory. You can run them directly from the CLI to quickly test your integration:

See the example/README.md for detailed configuration and execution commands.


🔒 Advanced Resilient Exception Handling

Dio errors are often flat, structured strings. The SDK intercepts errors and wraps them into distinct custom MtnMomoException types to allow clean, idiomatic catch flows:

try {
  final balance = await momo.collection.getAccountBalance();
} on MtnMomoNetworkException {
  print('Unable to reach the server. Please verify your connection.');
} on MtnMomoAuthException catch (e) {
  print('Authentication Error (HTTP 401): ${e.message} - ${e.details}');
} on MtnMomoForbiddenException {
  print('Forbidden (HTTP 403): Ensure your server IP is whitelisted on the portal.');
} on MtnMomoNotFoundException {
  print('Resource not found (HTTP 404).');
} on MtnMomoConflictException {
  print('Conflict (HTTP 409): This reference UUID has already been utilized.');
} on MtnMomoTransactionException catch (e) {
  // Access rich mapped enum values from official MTN documentation
  print('Transaction Business Logic Error Code: ${e.errorCode.code}');
  print('Error Description: ${e.errorCode.description}');
  
  switch(e.errorCode) {
    case MtnMomoErrorCode.payerLimitReached:
      print('The customer has reached their daily wallet limits.');
      break;
    case MtnMomoErrorCode.notEnoughFunds:
      print('The customer\'s account has insufficient funds.');
      break;
    case MtnMomoErrorCode.approvalRejected:
      print('The customer rejected the payment prompt.');
      break;
    default:
      print('Unhandled transaction failure.');
  }
} on MtnMomoServerException {
  print('MTN Server is experiencing technical difficulties.');
} catch (e) {
  print('Unexpected non-SDK error: $e');
}

Supported Transaction Error Enums (MtnMomoErrorCode)

Error Code Enum Raw API Value Official Description
payeeNotFound PAYEE_NOT_FOUND Recipient MSISDN is invalid or unregistered.
payerNotFound PAYER_NOT_FOUND Sender MSISDN does not exist or is invalid.
invalidCallbackUrlHost INVALID_CALLBACK_URL_HOST Callback URL host must be a domain name, not an IP.
invalidReferenceId INVALID_REFERENCE_ID Reference ID (UUID v4) is invalid or malformed.
resourceNotFound RESOURCE_NOT_FOUND The specified transaction or reference cannot be located.
resourceAlreadyExist RESOURCE_ALREADY_EXIST Duplicate reference ID supplied.
payerLimitReached PAYER_LIMIT_REACHED Daily/Monthly wallet limits hit by customer.
approvalRejected APPROVAL_REJECTED User manually cancelled prompt or timed out.
notEnoughFunds NOT_ENOUGH_FUNDS Wallet has insufficient balance.
senderAccountNotActive SENDER_ACCOUNT_NOT_ACTIVE Payer's wallet is frozen or inactive.
internalProcessingError INTERNAL_PROCESSING_ERROR Core processing engine error.
couldNotPerformTransaction COULD_NOT_PERFORM_TRANSACTION System failure to complete transaction.
forbiddenIp FORBIDDEN_IP Source server IP is blocked.
accessDenied ACCESS_DENIED Invalid subscription key or products.

🛠 Development & Code Generation

If you modify the Swagger specifications under the schemes/ directory, you must run code generation:

  1. Model Parsing: Modify models or schemas in schemes/ (e.g. collection.json, disbursement.json, remittance.json, sandbox-provisioning-api.json).

  2. Build Generated Files: Execute the Dart compiler code generator:

    dart pub get
    dart run build_runner build --delete-conflicting-outputs
    
  3. Running SDK Tests: Run the regression test suite:

    dart test
    

    Run live sandbox integration tests (requires .env with keys):

    dart test test/sandbox_usecases_test.dart
    

📜 License

Distributed under the MIT License. See LICENSE for more details.

Libraries

mtn_momo_sdk