MTNMoMo.OpenApi 1.0.0

dotnet add package MTNMoMo.OpenApi --version 1.0.0
                    
NuGet\Install-Package MTNMoMo.OpenApi -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MTNMoMo.OpenApi" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MTNMoMo.OpenApi" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="MTNMoMo.OpenApi" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MTNMoMo.OpenApi --version 1.0.0
                    
#r "nuget: MTNMoMo.OpenApi, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MTNMoMo.OpenApi@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MTNMoMo.OpenApi&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=MTNMoMo.OpenApi&version=1.0.0
                    
Install as a Cake Tool

MTNMoMo.OpenApi

Production-ready .NET 8 SDK for the MTN Mobile Money Open API.

NuGet id: MTNMoMo.OpenApi · Namespaces / assemblies: MTNMoMo / MTNMoMo.Shared (unchanged in code).

Covers Collection, Disbursement, and Remittance end-to-end: strongly typed DTOs, HttpClientFactory resilience, OAuth token caching, structured logging, in-process statistics, sandbox provisioning, typed callbacks (PUT + POST), consent helpers, and SDK-side bulk orchestration.

dotnet add package MTNMoMo.OpenApi

Shared types (MTNMoMo.Shared namespaces — enums, DTOs, constants, MoMoResult<T>) ship inside this single package. You do not install a separate Shared package.

Feature matrix (Open API)

Product Operations covered
Collection RequestToPay (+ status, delivery notification), Invoice create/status/cancel, PreApproval create/status/cancel/list, RequestToWithdraw (+ status), CreatePayments (+ status), GetBasicUserInfo, account balance (+ currency), account holder active, bc-authorize / oauth2 token / userinfo-with-consent
Disbursement Transfer (+ status), Deposit v2 (+ status), Refund v2 (+ status), GetBasicUserInfo, account balance (+ currency), account holder active, consent helpers
Remittance Transfer (+ status), CashTransfer (+ status), GetBasicUserInfo, account balance (+ currency), account holder active, consent helpers
Sandbox Create API user / API key, get API user, one-shot ProvisionAsync
Bulk (SDK) Parallel RequestToPay / Transfer / Deposit with concurrency limits, continue-on-error, per-item results
Callbacks Shape-based kind detection → typed DTOs (MoMoParsedCallback); flat MoMoCallbackDto for legacy

Not in Open API (intentionally excluded): Ghana portal CSV bulk payroll, dual-authorization portal workflows, Collection Widget / USSD. Merchant CancelPreApproval often returns HTTP 400 in sandbox — MoMo documents subscriber USSD cancel as the practical path.

Quick start

builder.Services.AddMTNMoMo(builder.Configuration);

var collections = app.Services.GetRequiredService<ICollectionClient>();
var id = Guid.NewGuid();
await collections.RequestToPayAsync(
    RequestToPayRequest.Create(100m, CurrencyCode.EUR, "order-1", "46733123450"),
    id);

// Prefer typed callbacks:
// parser.TryParseTypedCallback(rawJson, out MoMoParsedCallback? parsed);

Table of contents

  1. Packages & namespaces
  2. Install & dependency injection
  3. Configuration reference
  4. Authentication & credentials
  5. HTTP transport details
  6. Sandbox guide
  7. Collection API (all methods)
  8. Disbursement API (all methods)
  9. Remittance API (all methods)
  10. Bulk orchestration
  11. Sandbox provisioning
  12. Callbacks
  13. Consent / OAuth2 KYC helpers
  14. Results, exceptions & error codes
  15. Enums & status mapping
  16. DTO & factory reference
  17. API path map
  18. Statistics & logging
  19. Resilience pipeline
  20. Runtime callback settings
  21. Facade IMoMoClient
  22. End-to-end recipes
  23. Troubleshooting
  24. Out of scope
  25. Versioning & license

1. Packages & namespaces

Assembly (both in the MTNMoMo.OpenApi nupkg) Role
MTNMoMo Clients, auth, HTTP, DI, bulk, statistics, logging, exceptions
MTNMoMo.Shared Enums, constants, DTOs, helpers, MoMoResult<T> (bundled; not a separate NuGet id)

Common namespaces

using MTNMoMo.Abstractions;              // ICollectionClient, IDisbursementClient, ...
using MTNMoMo.Auth;                      // IMoMoCallbackSettings, IMoMoRuntimeCredentialStore
using MTNMoMo.Configuration;             // MoMoOptions
using MTNMoMo.Exceptions;                // MoMoApiException, MoMoConfigurationException
using MTNMoMo.Extensions;                // AddMTNMoMo
using MTNMoMo.Statistics;                // IMoMoStatisticsCollector
using MTNMoMo.Shared.Constants;          // MoMoSandboxTestNumbers, MoMoApiPaths, ...
using MTNMoMo.Shared.Dtos.Account;
using MTNMoMo.Shared.Dtos.Bulk;
using MTNMoMo.Shared.Dtos.Callbacks;
using MTNMoMo.Shared.Dtos.Collection;
using MTNMoMo.Shared.Dtos.Common;
using MTNMoMo.Shared.Dtos.Consent;
using MTNMoMo.Shared.Dtos.Disbursement;
using MTNMoMo.Shared.Dtos.Remittance;
using MTNMoMo.Shared.Enums;
using MTNMoMo.Shared.Helpers;
using MTNMoMo.Shared.Results;

Injectable services

Interface Lifetime (default) Purpose
ICollectionClient Transient Collection product
IDisbursementClient Transient Disbursement product
IRemittanceClient Transient Remittance product
IMoMoBulkClient Transient Parallel fan-out
ISandboxProvisioningClient Transient Sandbox API user/key
IMoMoCallbackParser Singleton Parse callback JSON
IMoMoClient Transient Facade
IMoMoStatisticsCollector Singleton Metrics
IMoMoTokenProvider Singleton OAuth cache
IMoMoCredentialResolver Singleton Resolve keys per product
IMoMoRuntimeCredentialStore Singleton Runtime credential overrides
IMoMoCallbackSettings Singleton Session callback host/URL
IMoMoHttpClient Singleton Low-level transport

Named HttpClient: MTNMoMo.HttpClient (MoMoConfigKeys.HttpClientName).


2. Install & dependency injection

From configuration

using MTNMoMo.Extensions;

builder.Services.AddMTNMoMo(builder.Configuration);
// binds section "MTNMoMo" by default

Custom section name:

builder.Services.AddMTNMoMo(builder.Configuration, sectionName: "Payments:MoMo");

From an IConfigurationSection:

builder.Services.AddMTNMoMo(builder.Configuration.GetSection("MTNMoMo"));

From code

using MTNMoMo.Shared.Enums;

builder.Services.AddMTNMoMo(options =>
{
    options.SubscriptionKey = "YOUR_SUBSCRIPTION_KEY";
    options.TargetEnvironment = TargetEnvironment.Sandbox;
    options.UseSandbox = true;
    options.CallbackUrl = "https://xxxx.ngrok-free.app/api/momo/callback";
    options.ThrowOnError = true;
    options.TokenRefreshSkewSeconds = 60;

    options.Collection.ApiUserId = "...";
    options.Collection.ApiKey = "...";
    options.Disbursement.ApiUserId = "...";
    options.Disbursement.ApiKey = "...";
    options.Remittance.ApiUserId = "...";
    options.Remittance.ApiKey = "...";
});

Options are validated by MoMoOptionsValidator when IOptions<MoMoOptions> is first resolved (subscription key required at root or per product; ApiUserId/ApiKey must both be set or both empty; credentials are optional until the first authenticated product call — provisioning-only startup is valid).

Fail-fast validation at startup

var app = builder.Build();
app.Services.ValidateMTNMoMoOptions(); // throws MoMoConfigurationException if invalid

3. Configuration reference

Section name: MTNMoMo (MoMoOptions.SectionName / MoMoConfigKeys.SectionName).

Root MoMoOptions

Property Type Default Description
SubscriptionKey string "" Default Ocp-Apim-Subscription-Key when product key is empty
TargetEnvironment TargetEnvironment Sandbox Sent as X-Target-Environment
UseSandbox bool true Selects sandbox base URL when BaseUrl is unset
BaseUrl string? null Absolute override (trailing / normalized)
CallbackUrl string? null Default full callback URL for X-Callback-Url
TokenRefreshSkewSeconds int 60 Refresh token this many seconds before expiry
ThrowOnError bool true true → throw MoMoApiException; false → MoMoResult.IsSuccess = false
Collection MoMoProductCredentials empty Collection API user/key
Disbursement MoMoProductCredentials empty Disbursement API user/key
Remittance MoMoProductCredentials empty Remittance API user/key
Resilience MoMoResilienceOptions see below Retry / timeout / circuit breaker
Statistics MoMoStatisticsOptions enabled In-process metrics

MoMoProductCredentials

Property Description
ApiUserId MoMo API User UUID (Basic auth username for /token/)
ApiKey API key for that user
SubscriptionKey Optional per-product subscription key override
IsConfigured true when both ApiUserId and ApiKey are non-empty

MoMoResilienceOptions

Property Default Description
MaxRetryAttempts 3 Total attempts including the first call
RetryBaseDelay 250ms Exponential backoff base
AttemptTimeout 30s Per-attempt timeout
TotalRequestTimeout 90s Outer timeout across retries
CircuitBreakerFailureRatio 0.5 Trip ratio (0–1)
CircuitBreakerMinimumThroughput 10 Min samples before evaluation
CircuitBreakerBreakDuration 30s Open duration
UseJitter true Jitter on retry delays

Retries on: HTTP 5xx, 408, 429, HttpRequestException, TaskCanceledException.

MoMoStatisticsOptions

Property Default Description
Enabled true Collect counters / latencies
MaxLatencySamples 1024 Ring buffer size for p95

Base URLs (MoMoBaseUrls)

Constant Value
Sandbox https://sandbox.momodeveloper.mtn.com/
Production https://proxy.momoapi.mtn.com/

Resolution: BaseUrl override → else sandbox if UseSandbox or TargetEnvironment == Sandbox → else production.

Full appsettings.json example

{
  "MTNMoMo": {
    "SubscriptionKey": "YOUR_SUBSCRIPTION_KEY",
    "TargetEnvironment": "Sandbox",
    "UseSandbox": true,
    "CallbackUrl": "https://xxxx.ngrok-free.app/api/momo/callback",
    "ThrowOnError": true,
    "TokenRefreshSkewSeconds": 60,
    "Collection": {
      "ApiUserId": "00000000-0000-0000-0000-000000000001",
      "ApiKey": "collection-api-key",
      "SubscriptionKey": null
    },
    "Disbursement": {
      "ApiUserId": "00000000-0000-0000-0000-000000000002",
      "ApiKey": "disbursement-api-key"
    },
    "Remittance": {
      "ApiUserId": "00000000-0000-0000-0000-000000000003",
      "ApiKey": "remittance-api-key"
    },
    "Resilience": {
      "MaxRetryAttempts": 3,
      "RetryBaseDelay": "00:00:00.250",
      "AttemptTimeout": "00:00:30",
      "TotalRequestTimeout": "00:01:30",
      "CircuitBreakerFailureRatio": 0.5,
      "CircuitBreakerMinimumThroughput": 10,
      "CircuitBreakerBreakDuration": "00:00:30",
      "UseJitter": true
    },
    "Statistics": {
      "Enabled": true,
      "MaxLatencySamples": 1024
    }
  }
}

4. Authentication & credentials

Token flow

  1. For each product call, IMoMoTokenProvider loads credentials via IMoMoCredentialResolver.
  2. Resolver prefers runtime store overrides, then MoMoOptions product credentials.
  3. POST {product}/token/ with Basic ApiUserId:ApiKey + subscription key.
  4. Bearer token is cached in-memory per product/user with refresh skew.
  5. Concurrent token fetches for the same cache key are coalesced.

Runtime credential overrides

After sandbox provisioning you can hot-swap credentials without restarting:

using MTNMoMo.Auth;
using MTNMoMo.Shared.Enums;

runtime.SetProductCredentials(
    MoMoProduct.Collection,
    apiUserId: userId.ToString(),
    apiKey: apiKey,
    subscriptionKey: optionalKey);

runtime.SetProductSubscriptionKey(MoMoProduct.Disbursement, "product-subscription-key");

runtime.Clear(MoMoProduct.Collection); // or Clear() for all

Important

MoMo issues separate API users per product. Do not reuse a Collection API user for Disbursement/Remittance.


5. HTTP transport details

Headers sent by the SDK

Header Constant When
Authorization: Bearer … — All product API calls
Authorization: Basic … — Token endpoint only
Ocp-Apim-Subscription-Key MoMoHeaderNames.SubscriptionKey Always
X-Target-Environment MoMoHeaderNames.TargetEnvironment Always (e.g. sandbox)
X-Reference-Id MoMoHeaderNames.ReferenceId Create / cancel / accepted ops when provided
X-Callback-Url MoMoHeaderNames.CallbackUrl Initiation POSTs when enabled

When X-Callback-Url is sent

Sent only if all are true:

  1. The client method sets includeCallbackUrl: true (create/initiation ops)
  2. IMoMoCallbackSettings.SendCallbackHeader == true
  3. IMoMoCallbackSettings.CallbackUrl is non-empty

Not sent on status GET, cancel DELETE, balance, account holder, or other non-initiation calls.

Reference id rules

Operation Path id X-Reference-Id header
Create RequestToPay / Transfer / Deposit / … n/a New UUID (caller or SDK)
Get status Same as create UUID Optional / same id for logging
Cancel invoice Invoice create UUID New UUID (SDK generates)
Cancel pre-approval Pre-approval create UUID New UUID (SDK generates)

Reusing the create UUID as the cancel header causes RESOURCE_ALREADY_EXIST (HTTP 409) on invoice cancel.

Amount encoding

Factories format amounts with invariant culture F0 (no decimals), e.g. 100m → "100". MoMo sandbox expects whole units for EUR test amounts.


6. Sandbox guide

Currency

Sandbox only accepts EUR. Use CurrencyCode.EUR or MoMoCurrencyRules.GetDefaultCurrency(useSandbox: true).

using MTNMoMo.Shared.Helpers;

var currencies = MoMoCurrencyRules.GetSelectableCurrencies(useSandbox: true); // [EUR]
var def = MoMoCurrencyRules.GetDefaultCurrency(useSandbox: false);            // UGX

Official sandbox test MSISDNs (MoMoSandboxTestNumbers)

Constant MSISDN Expected outcome
Failed 46733123450 Failed
Rejected 46733123451 Rejected
Timeout 46733123452 Timeout
Success 56733123453 Successful
Pending 46733123454 Stays pending

Callbacks in sandbox

  1. Start a public tunnel (ngrok, etc.) to your local HTTPS app.
  2. Provision API user with providerCallbackHost = host only (no https://, no path).
  3. Set CallbackUrl = https://{same-host}/api/momo/callback.
  4. Listener must accept PUT and POST and return 200.
  5. MoMo may send the callback only once — always poll status as backup.

7. Collection API (all methods)

Inject ICollectionClient.

Method catalog

Method HTTP Path Callback header Notes
RequestToPayAsync POST v1_0/requesttopay Yes Debit payer
GetRequestToPayStatusAsync GET v1_0/requesttopay/{id} No
SendDeliveryNotificationAsync POST v1_0/requesttopay/{id}/deliverynotification No After success
CreateInvoiceAsync POST v2_0/invoice Yes
GetInvoiceStatusAsync GET v2_0/invoice/{id} No
CancelInvoiceAsync DELETE v2_0/invoice/{id} No Body optional externalId; new header UUID
CreatePreApprovalAsync POST v2_0/preapproval Yes
GetPreApprovalStatusAsync GET v2_0/preapproval/{id} No
CancelPreApprovalAsync DELETE v1_0/preapproval/{id} No Sandbox often HTTP 400; USSD cancel is practical path
GetApprovedPreApprovalsAsync GET v1_0/preapprovals/{type}/{id} No
RequestToWithdrawAsync POST v2_0/requesttowithdraw Yes
GetRequestToWithdrawStatusAsync GET v1_0/requesttowithdraw/{id} No Status stays on v1 path
CreatePaymentAsync POST v2_0/payment Yes Against pre-approval
GetPaymentStatusAsync GET v2_0/payment/{id} No
GetAccountBalanceAsync() GET v1_0/account/balance No
GetAccountBalanceAsync(currency) GET v1_0/account/balance/{currency} No
ValidateAccountHolderAsync GET v1_0/accountholder/{type}/{id}/active No
GetBasicUserInfoAsync GET v1_0/accountholder/{type}/{id}/basicuserinfo No
BcAuthorizeAsync POST form v1_0/bc-authorize No Consent
CreateOauth2TokenAsync POST form oauth2/token/ No Consent
GetUserInfoWithConsentAsync GET oauth2/v1_0/userinfo No Uses consent access token

RequestToPay

var request = RequestToPayRequest.Create(
    amount: 100m,
    currency: CurrencyCode.EUR,
    externalId: "order-1001",
    payerMsisdn: MoMoSandboxTestNumbers.Success,
    payerMessage: "Order 1001",      // keep short; MoMo limits message length
    payeeNote: "Thanks");

var referenceId = Guid.NewGuid();
var accepted = await collections.RequestToPayAsync(request, referenceId, ct);
// accepted.Data.ReferenceId / ExternalId / AcceptedAtUtc

var status = await collections.GetRequestToPayStatusAsync(referenceId, ct);
// status.Data.TransactionStatus / FinancialTransactionId / ErrorCode / Reason

RequestToPayRequest fields: Amount, Currency, ExternalId, Payer (PartyDto), PayerMessage, PayeeNote.

RequestToPayStatusResponse fields: Amount, Currency, FinancialTransactionId, ExternalId, Payer, PayerMessage, PayeeNote, Status, Reason + computed TransactionStatus, ErrorCode.

Delivery notification

await collections.SendDeliveryNotificationAsync(
    referenceId,
    new DeliveryNotificationRequest { NotificationMessage = "Payment received." },
    ct);

Invoice

var invoice = InvoiceRequest.Create(
    amount: 250m,
    currency: CurrencyCode.EUR,
    externalId: "inv-1001",
    intendedPayerMsisdn: MoMoSandboxTestNumbers.Success,
    validityDurationSeconds: 3600,
    description: "Service invoice");

var created = await collections.CreateInvoiceAsync(invoice, Guid.NewGuid(), ct);
var id = created.Data!.ReferenceId;

var invoiceStatus = await collections.GetInvoiceStatusAsync(id, ct);

await collections.CancelInvoiceAsync(
    id,
    new CancelInvoiceRequest { ExternalId = "inv-1001" },
    ct);

InvoiceRequest fields: Amount, Currency, ExternalId, ValidityDuration, IntendedPayer, Payee, Description.

Cancel only makes sense while the invoice is still cancellable (typically pending/created — not already paid/Successful).

Pre-approval

var pre = PreApprovalRequest.Create(
    payerMsisdn: MoMoSandboxTestNumbers.Success,
    payerCurrency: CurrencyCode.EUR,
    payerMessage: "Authorize monthly debit",
    validityTimeSeconds: 3600);

var created = await collections.CreatePreApprovalAsync(pre, Guid.NewGuid(), ct);
var id = created.Data!.ReferenceId;

var status = await collections.GetPreApprovalStatusAsync(id, ct);
// status.Data.ExpirationDateTime / TransactionStatus

var list = await collections.GetApprovedPreApprovalsAsync(
    "56733123453", PartyIdType.Msisdn, ct);
// list.Data.Preapprovals[*].PreapprovalId / Status / ExpirationDateTime

await collections.CancelPreApprovalAsync(id, ct);

Note: MoMo community guidance — subscribers often cancel pre-approvals via USSD. Merchant DELETE frequently returns HTTP 400 (empty body) in sandbox even when the request shape is correct.

RequestToWithdraw

var withdraw = RequestToWithdrawRequest.Create(
    amount: 50m,
    currency: CurrencyCode.EUR,
    externalId: "wd-1",
    payerMsisdn: MoMoSandboxTestNumbers.Success,
    payerMessage: "Withdraw",
    payeeNote: "Cash out");

var accepted = await collections.RequestToWithdrawAsync(withdraw, Guid.NewGuid(), ct);
var wdStatus = await collections.GetRequestToWithdrawStatusAsync(accepted.Data!.ReferenceId, ct);

Payments (against pre-approval)

var payment = PaymentRequest.Create(
    amount: 40m,
    currency: CurrencyCode.EUR,
    externalTransactionId: "pay-1",
    preapprovalId: preApprovalId);

var accepted = await collections.CreatePaymentAsync(payment, Guid.NewGuid(), ct);
var payStatus = await collections.GetPaymentStatusAsync(accepted.Data!.ReferenceId, ct);

PaymentRequest fields: ExternalTransactionId, Amount, Currency, PreapprovalId.

Balance / account holder / basic user info

var balance = await collections.GetAccountBalanceAsync(ct);
// balance.Data.AvailableBalance / Currency

var eur = await collections.GetAccountBalanceAsync(CurrencyCode.EUR, ct);

var active = await collections.ValidateAccountHolderAsync("56733123453", PartyIdType.Msisdn, ct);
// active.Data.Result (bool)

var info = await collections.GetBasicUserInfoAsync("56733123453", PartyIdType.Msisdn, ct);
// GivenName, FamilyName, Birthdate, Locale, Gender, Status

8. Disbursement API (all methods)

Inject IDisbursementClient.

Method HTTP Path Callback
TransferAsync POST v1_0/transfer Yes
GetTransferStatusAsync GET v1_0/transfer/{id} No
DepositAsync POST v2_0/deposit Yes
GetDepositStatusAsync GET v1_0/deposit/{id} No
RefundAsync POST v2_0/refund Yes
GetRefundStatusAsync GET v1_0/refund/{id} No
GetAccountBalanceAsync() / (currency) GET balance paths No
ValidateAccountHolderAsync GET accountholder active No
GetBasicUserInfoAsync GET basicuserinfo No
BcAuthorizeAsync / CreateOauth2TokenAsync / GetUserInfoWithConsentAsync consent — No

Transfer

var request = TransferRequest.Create(
    amount: 100m,
    currency: CurrencyCode.EUR,
    externalId: "payout-55",
    payeeMsisdn: MoMoSandboxTestNumbers.Success,
    payerMessage: "Vendor payout",
    payeeNote: "Invoice 55");

var accepted = await disbursements.TransferAsync(request, Guid.NewGuid(), ct);
var status = await disbursements.GetTransferStatusAsync(accepted.Data!.ReferenceId, ct);

Deposit (v2)

var deposit = DepositRequest.Create(
    amount: 100m,
    currency: CurrencyCode.EUR,
    externalId: "dep-1",
    payeeMsisdn: MoMoSandboxTestNumbers.Success);

var accepted = await disbursements.DepositAsync(deposit, Guid.NewGuid(), ct);
var status = await disbursements.GetDepositStatusAsync(accepted.Data!.ReferenceId, ct);

Refund (v2)

Collection has no refund endpoint. Use Disbursement refund with the original transaction reference:

var refund = RefundRequest.Create(
    amount: 50m,
    currency: CurrencyCode.EUR,
    externalId: "refund-1",
    referenceIdToRefund: originalReferenceId,
    payerMessage: "Refund",
    payeeNote: "Order cancelled");

var accepted = await disbursements.RefundAsync(refund, Guid.NewGuid(), ct);
var status = await disbursements.GetRefundStatusAsync(accepted.Data!.ReferenceId, ct);

RefundRequest fields: Amount, Currency, ExternalId, ReferenceIdToRefund, PayerMessage, PayeeNote.


9. Remittance API (all methods)

Inject IRemittanceClient.

Method HTTP Path Callback
TransferAsync POST v1_0/transfer Yes
GetTransferStatusAsync GET v1_0/transfer/{id} No
CashTransferAsync POST v1_0/cashtransfer Yes
GetCashTransferStatusAsync GET v1_0/cashtransfer/{id} No
Balance / account holder / basic user info / consent same patterns as other products

Remittance transfer

var request = TransferRequest.Create(
    amount: 100m,
    currency: CurrencyCode.EUR,
    externalId: "remit-1",
    payeeMsisdn: MoMoSandboxTestNumbers.Success);

var accepted = await remittance.TransferAsync(request, Guid.NewGuid(), ct);

Cash transfer

var cash = CashTransferRequest.Create(
    amount: 75m,
    currency: CurrencyCode.EUR,
    externalId: "cash-1",
    payeeMsisdn: MoMoSandboxTestNumbers.Success,
    payerMessage: "Cash transfer",
    payeeNote: "Remittance");

var accepted = await remittance.CashTransferAsync(cash, Guid.NewGuid(), ct);
var status = await remittance.GetCashTransferStatusAsync(accepted.Data!.ReferenceId, ct);

10. Bulk orchestration

Inject IMoMoBulkClient.

MoMo Open API has no bulk REST endpoint. This SDK fans out many single-item calls with:

  • SemaphoreSlim concurrency limit
  • Per-item success/failure
  • Optional continue-on-error
  • Optional delay between items
  • Cancellation support

BulkExecutionOptions

Property Default Description
MaxDegreeOfParallelism 4 Clamped to ≥ 1
ContinueOnError true If false, remaining items are skipped after first failure
DelayBetweenItems 00:00:00 Delay before items after the first

Methods

Task<MoMoBulkResult> BulkRequestToPayAsync(IReadOnlyList<BulkRequestToPayItem> items, BulkExecutionOptions? options = null, CancellationToken ct = default);
Task<MoMoBulkResult> BulkTransferAsync(IReadOnlyList<BulkTransferItem> items, BulkExecutionOptions? options = null, CancellationToken ct = default);
Task<MoMoBulkResult> BulkDepositAsync(IReadOnlyList<BulkDepositItem> items, BulkExecutionOptions? options = null, CancellationToken ct = default);

Item types

Type Key properties
BulkRequestToPayItem Request, ReferenceId?, CorrelationId?
BulkTransferItem Request, ReferenceId?, CorrelationId?
BulkDepositItem Request, ReferenceId?, CorrelationId?

Result types

MoMoBulkResult: Operation, Items, TotalDuration, SuccessCount, FailureCount, TotalCount

MoMoBulkItemResult: CorrelationId, ReferenceId, ExternalId, IsSuccess, ErrorMessage, ErrorCode, StatusCode, Duration

Example

var options = new BulkExecutionOptions
{
    MaxDegreeOfParallelism = 8,
    ContinueOnError = true
};

var items = Enumerable.Range(1, 50)
    .Select(i => new BulkTransferItem
    {
        CorrelationId = $"row-{i}",
        ReferenceId = Guid.NewGuid(),
        Request = TransferRequest.Create(1000m, CurrencyCode.EUR, $"pay-{i}", MoMoSandboxTestNumbers.Success)
    })
    .ToList();

var result = await bulk.BulkTransferAsync(items, options, ct);

foreach (var item in result.Items.Where(x => !x.IsSuccess))
{
    Console.WriteLine($"{item.CorrelationId}: {item.ErrorCode} {item.ErrorMessage}");
}

Not included: Ghana portal CSV bulk payroll upload / dual authorization workflows.


11. Sandbox provisioning

Inject ISandboxProvisioningClient. Sandbox-only; each product subscription key provisions credentials for that product only.

Methods

Task<MoMoResult<AcceptedReferenceResponse>> CreateApiUserAsync(
    MoMoProduct product,
    CreateApiUserRequest request,
    Guid? referenceId = null,
    CancellationToken ct = default);

Task<MoMoResult<ApiUserResponse>> GetApiUserAsync(
    MoMoProduct product,
    Guid referenceId,
    CancellationToken ct = default);

Task<MoMoResult<ApiKeyResponse>> CreateApiKeyAsync(
    MoMoProduct product,
    Guid referenceId,
    CancellationToken ct = default);

Task<MoMoResult<SandboxCredentialsResponse>> ProvisionAsync(
    MoMoProduct product,
    string providerCallbackHost,
    string? subscriptionKey = null,
    Guid? referenceId = null,
    CancellationToken ct = default);

One-shot provision

using MTNMoMo.Auth;

var result = await sandbox.ProvisionAsync(
    MoMoProduct.Collection,
    providerCallbackHost: "xxxx.ngrok-free.app", // host only — no scheme/path
    subscriptionKey: null,                       // optional; when set, updates runtime subscription key
    referenceId: null,                           // SDK generates API user UUID
    ct);

var creds = result.Data!;
// creds.ApiUserId / ApiKey / Product / SubscriptionKey / ProviderCallbackHost / ProvisionedAtUtc

// SDK returns credentials — you wire them for subsequent calls:
runtime.SetProductCredentials(
    creds.Product,
    apiUserId: creds.ApiUserId.ToString(),
    apiKey: creds.ApiKey,
    subscriptionKey: creds.SubscriptionKey);

callbackSettings.SetCallbackHost(creds.ProviderCallbackHost!);
callbackSettings.SetSendCallbackHeader(true);

ProvisionAsync does not auto-persist ApiUserId/ApiKey into IMoMoRuntimeCredentialStore. Only an optional subscriptionKey argument updates the runtime subscription key. Persist or hot-swap credentials yourself (config, secrets store, or SetProductCredentials).

Step-by-step

var apiUserId = Guid.NewGuid();

await sandbox.CreateApiUserAsync(
    MoMoProduct.Collection,
    new CreateApiUserRequest { ProviderCallbackHost = "xxxx.ngrok-free.app" },
    apiUserId,
    ct);

var user = await sandbox.GetApiUserAsync(MoMoProduct.Collection, apiUserId, ct);
var key = await sandbox.CreateApiKeyAsync(MoMoProduct.Collection, apiUserId, ct);

Repeat for MoMoProduct.Disbursement and MoMoProduct.Remittance with their subscription keys.


12. Callbacks

Prefer TryParseTypedCallback: detect kind from JSON shape, then deserialize into a dedicated DTO on MoMoParsedCallback. Use TryParseCallback / Parse only when you need the flat legacy MoMoCallbackDto (named TryParseCallback so ASP.NET model binding does not treat the type as a bindable TryParse parameter).

Kind detection → typed DTO

MoMoCallbackKind Distinct JSON markers Property on MoMoParsedCallback DTO
PreApproval payerCurrency, expirationDateTime, or payer without amount PreApproval PreApprovalCallbackDto
Invoice intendedPayer, invoiceId, paymentReference, expiryDateTime; or externalId+amount without payer/payee (often with description) Invoice InvoiceCallbackDto
Refund referenceIdToRefund Refund RefundCallbackDto
Payment preapprovalId Payment PaymentCallbackDto
Transfer payee + amount Transfer TransferCallbackDto
RequestToPay payer + amount RequestToPay RequestToPayCallbackDto
Unknown externalId and/or status only — —

Shape ambiguities (no product hint in the payload):

  • Deposit and CashTransfer callbacks share the Transfer shape → classified as Transfer.
  • RequestToWithdraw shares the RequestToPay shape → classified as RequestToPay (RequestToWithdrawCallbackDto exists for explicit deserialize if you know the product).
  • Enum values Deposit, CashTransfer, and RequestToWithdraw exist for completeness / ToSummary() mapping.

MoMoParsedCallback fields

Property Description
Kind Discriminator
RawPayload Original JSON
DisplayId Best correlation id (externalId, financial txn id, payer:…, …)
Status Raw status string
TransactionStatus Mapped enum (Created for CREATED, etc.)
ErrorCode Mapped failure reason when present
Typed props Exactly one of Invoice / PreApproval / RequestToPay / Transfer / Payment / Refund / RequestToWithdraw
bool ok = parser.TryParseTypedCallback(rawJson, out MoMoParsedCallback? parsed);
if (!ok || parsed is null)
    return;

switch (parsed.Kind)
{
    case MoMoCallbackKind.Invoice:
        _ = parsed.Invoice!.ExternalId;
        _ = parsed.Invoice.IntendedPayer?.PartyId;
        _ = parsed.TransactionStatus; // may be Created or Successful
        break;
    case MoMoCallbackKind.PreApproval:
        _ = parsed.PreApproval!.Payer?.PartyId;
        break;
    case MoMoCallbackKind.RequestToPay:
        _ = parsed.RequestToPay!.FinancialTransactionId;
        break;
    case MoMoCallbackKind.Transfer:
        _ = parsed.Transfer!.Payee?.PartyId;
        break;
    case MoMoCallbackKind.Payment:
        _ = parsed.Payment!.PreapprovalId;
        break;
    case MoMoCallbackKind.Refund:
        _ = parsed.Refund!.ReferenceIdToRefund;
        break;
}

// Flat projection for simple ledgers / UI lists:
MoMoCallbackDto summary = parsed.ToSummary();

Statuses include CREATED (invoice / pre-approval lifecycle) via TransactionStatus.Created — not terminal; keep polling or wait for a later callback until IsTerminal().

Minimal ASP.NET Core listener (PUT + POST)

MoMo documents: allow PUT & POST on the callback host. Read the raw body (do not rely on [FromBody] string for arbitrary MoMo JSON).

app.MapMethods("/api/momo/callback", new[] { HttpMethods.Post, HttpMethods.Put }, async (
    HttpRequest request,
    IMoMoCallbackParser parser) =>
{
    using var reader = new StreamReader(request.Body);
    var payload = await reader.ReadToEndAsync();

    if (!parser.TryParseTypedCallback(payload, out var callback) || callback is null)
        return Results.Ok(); // acknowledge; avoid MoMo retry storms on junk

    // Idempotently update your ledger by Kind + DisplayId / ExternalId
    return Results.Ok(new
    {
        kind = callback.Kind.ToString(),
        id = callback.DisplayId,
        status = callback.TransactionStatus.ToString()
    });
});

Also available via facade: IMoMoClient.Callbacks.

Security notes

  • MoMo does not cryptographically sign callbacks.
  • Validate externalId / reference ids against your own records.
  • Keep the URL unguessable.
  • Prefer HTTPS; production requires trusted certs on port 443.

Callback checklist

  1. providerCallbackHost at provision time == host of X-Callback-Url
  2. SendCallbackHeader enabled (and URL not localhost unless tunneled)
  3. Listener returns 200 for PUT and POST
  4. Poll status if callback is missed (MoMo typically does not retry)
  5. Handle CREATED as non-final for invoices / pre-approvals

Available on Collection, Disbursement, and Remittance clients.

using MTNMoMo.Shared.Dtos.Consent;

// 1) Start consent
var authReq = BcAuthorizeRequest.Create(msisdn: "56733123453", scope: "profile");
// Form fields: login_hint=ID:{msisdn}/MSISDN, scope, access_type=offline
var auth = await collections.BcAuthorizeAsync(authReq, ct);
// auth.Data.AuthReqId / Interval / ExpiresIn

// 2) Exchange for consent access token
var tokenReq = Oauth2TokenRequest.Create(auth.Data!.AuthReqId!);
// Form: grant_type=urn:openid:params:grant-type:ciba, auth_req_id=...
var token = await collections.CreateOauth2TokenAsync(tokenReq, ct);
// AccessToken, TokenType, ExpiresIn, Scope, RefreshToken

// 3) Fetch userinfo with consent token (not the product OAuth token)
var info = await collections.GetUserInfoWithConsentAsync(token.Data!.AccessToken!, ct);
// Sub, Name, GivenName, FamilyName, Birthdate, Locale, Gender

These are optional KYC/consent flows; most payment integrations only need RequestToPay / Transfer.


14. Results, exceptions & error codes

MoMoResult<T>

Property Description
IsSuccess Call succeeded
Data Typed payload (may be null on accepted-bool ops)
StatusCode HTTP status
ErrorMessage Human message
ErrorCode MoMoErrorCode
RawBody Raw response text
ReferenceId Related UUID
Operation MoMoOperation

Exception hierarchy

Type When
MoMoException Base; carries Operation, Product, StatusCode, ErrorCode, ReferenceId, RawResponse
MoMoApiException Non-success HTTP when ThrowOnError = true
MoMoAuthenticationException Token endpoint failure
MoMoConfigurationException Missing credentials / invalid options

Handling patterns

// Pattern A: throw-on-error (default)
try
{
    var result = await collections.RequestToPayAsync(request, Guid.NewGuid(), ct);
    var id = result.Data!.ReferenceId;
}
catch (MoMoApiException ex)
{
    _ = ex.ErrorCode.GetDisplayName();
    _ = ex.ErrorCode.GetGuidance();
    _ = ex.RawResponse;
}

// Pattern B: result mode
options.ThrowOnError = false;
var result = await collections.RequestToPayAsync(request, Guid.NewGuid(), ct);
if (!result.IsSuccess)
{
    Console.WriteLine(result.ErrorCode.GetGuidance());
}

MoMoErrorCode catalog

Enum API value Guidance (summary)
PayeeNotFound PAYEE_NOT_FOUND Confirm payee/business is active
PayerNotFound PAYER_NOT_FOUND Validate account holder before debit
NotEnoughFunds NOT_ENOUGH_FUNDS Ask payer to top up
PayerLimitReached PAYER_LIMIT_REACHED Reduce amount / other wallet
PayeeLimitReached PAYEE_LIMIT_REACHED Reduce amount / other wallet
PayeeNotAllowedToReceive PAYEE_NOT_ALLOWED_TO_RECEIVE Payee cannot receive
PaymentNotApproved PAYMENT_NOT_APPROVED Payer must approve prompt
ResourceNotFound RESOURCE_NOT_FOUND Bad id / wrong product
InternalProcessingError INTERNAL_PROCESSING_ERROR Retry / support
ServiceUnavailable SERVICE_UNAVAILABLE Backoff retry
CouldNotPerformTransaction COULD_NOT_PERFORM_TRANSACTION Approval window ~5 min; retry
TransferTypeUnknown TRANSFER_TYPE_UNKNOWN Wrong transfer type for market
TransactionNotFound TRANSACTION_NOT_FOUND Wrong reference/product
SenderAccountNotActive SENDER_ACCOUNT_NOT_ACTIVE Activate sender wallet
ResourceAlreadyExist RESOURCE_ALREADY_EXIST (+ RESOURCE_ALREADY_EXISTS) Use unique UUID v4 reference id
ValidationError VALIDATION_ERROR Field types/lengths/currency
InvalidCurrency INVALID_CURRENCY Sandbox = EUR only
InvalidCallbackUrlHost INVALID_CALLBACK_URL_HOST Host must match provisioned callback host
Unknown — Inspect RawBody

Helpers: FromApiValue, ToApiValue, GetDisplayName, GetGuidance.


15. Enums & status mapping

MoMoProduct

Enum Path segment
Collection collection
Disbursement disbursement
Remittance remittance

PartyIdType

Enum API value
Msisdn MSISDN
Email EMAIL
PartyCode PARTY_CODE

TransactionStatus

Enum API values accepted
Pending PENDING, ONGOING
Created CREATED (invoice / pre-approval created, not final)
Successful SUCCESSFUL, SUCCESS, COMPLETED
Failed FAILED
Rejected REJECTED
Timeout TIMEOUT
Cancelled CANCELLED, CANCELED
Unknown anything else / null

status.IsTerminal() → successful / failed / rejected / timeout / cancelled. Created is not terminal.

MoMoCallbackKind

Unknown, RequestToPay, Transfer, Invoice, PreApproval, Payment, Deposit, Refund, CashTransfer, RequestToWithdraw — see §12 Callbacks for which values the parser actually assigns at runtime.

CurrencyCode

UGX, GHS, XOF, XAF, ZMW, RWF, EUR, USD, ZAR, LRD, GNF, SZL, BWP, LSL
API value = enum name uppercased (ToApiValue()).

TargetEnvironment → X-Target-Environment

Enum API value
Sandbox sandbox
MtnUganda mtnuganda
MtnGhana mtnghana
MtnIvoryCoast mtnivorycoast
MtnCameroon mtncameroon
MtnZambia mtnzambia
MtnRwanda mtnrwanda
MtnBenin mtnbenin
MtnCongo mtncongo
MtnGuinea mtnguineaconakry
MtnLiberia mtnliberia
MtnSouthAfrica mtnsouthafrica
MtnSwaziland mtnswaziland (also accepts mtneswatini)
MtnBotswana mtnbotswana
MtnLesotho mtnlesotho

MoMoOperation

Used for logging/statistics tagging — includes every client operation from CreateAccessToken through BulkDeposit (see enum definition in MTNMoMo.Shared).


16. DTO & factory reference

Prefer *.Create(...) factories — they validate and format amounts/currencies.

Shared

Type Role
PartyDto PartyIdType + PartyId; PartyDto.Create(msisdn)
MoneyDto Amount/currency pair (where used)
AcceptedReferenceResponse ReferenceId, ExternalId, AcceptedAtUtc
MoMoErrorResponse Upstream { code, message }

Collection factories

Factory Key parameters
RequestToPayRequest.Create amount, currency, externalId, payerMsisdn, messages
InvoiceRequest.Create amount, currency, externalId, intendedPayerMsisdn, validity, description
PreApprovalRequest.Create payerMsisdn, payerCurrency, message, validityTimeSeconds
RequestToWithdrawRequest.Create amount, currency, externalId, payerMsisdn
PaymentRequest.Create amount, currency, externalTransactionId, preapprovalId
DeliveryNotificationRequest NotificationMessage
CancelInvoiceRequest ExternalId?

Disbursement / remittance factories

Factory Key parameters
TransferRequest.Create amount, currency, externalId, payeeMsisdn
DepositRequest.Create amount, currency, externalId, payeeMsisdn
RefundRequest.Create amount, currency, externalId, referenceIdToRefund
CashTransferRequest.Create amount, currency, externalId, payeeMsisdn

Account / sandbox

Type Fields
AccountBalanceResponse AvailableBalance, Currency
AccountHolderStatusResponse Result (bool)
BasicUserInfoResponse GivenName, FamilyName, Birthdate, Locale, Gender, Status
CreateApiUserRequest ProviderCallbackHost
ApiUserResponse provisioned user details
ApiKeyResponse ApiKey
SandboxCredentialsResponse ApiUserId, ApiKey, Product, SubscriptionKey, ProviderCallbackHost, ProvisionedAtUtc

Callback DTOs (MTNMoMo.Shared.Dtos.Callbacks)

Type Role
MoMoParsedCallback Discriminated parse result + ToSummary()
RequestToPayCallbackDto Collection debit callback
TransferCallbackDto Transfer / deposit / cash-transfer shape
InvoiceCallbackDto Invoice (+ intendedPayer, description, …)
PreApprovalCallbackDto Pre-approval (often no externalId)
PaymentCallbackDto Charge against pre-approval
RefundCallbackDto Refund (+ referenceIdToRefund)
RequestToWithdrawCallbackDto Withdraw shape (same fields as RTP)
MoMoCallbackDto Flat legacy summary

Validation rules enforced by factories

  • amount > 0
  • externalId / MSISDN not null/whitespace
  • Refund / payment preapproval ids not Guid.Empty
  • Amount serialized as whole units (F0)

MSISDN format expected by MoMo: international digits without +.


17. API path map

Relative to {base}/{product}/ (e.g. https://sandbox.momodeveloper.mtn.com/collection/).

Constant Path
Token token/
Oauth2Token oauth2/token/
BcAuthorize v1_0/bc-authorize
RequestToPay v1_0/requesttopay
RequestToPayStatus v1_0/requesttopay/{0}
RequestToPayDeliveryNotification v1_0/requesttopay/{0}/deliverynotification
RequestToWithdraw v2_0/requesttowithdraw
RequestToWithdrawStatus v1_0/requesttowithdraw/{0}
Invoice / InvoiceStatus v2_0/invoice, v2_0/invoice/{0}
PreApproval / PreApprovalStatus v2_0/preapproval, v2_0/preapproval/{0}
PreApprovalCancel v1_0/preapproval/{0}
ApprovedPreApprovals v1_0/preapprovals/{0}/{1}
Payment / PaymentStatus v2_0/payment, v2_0/payment/{0}
Transfer / TransferStatus v1_0/transfer, v1_0/transfer/{0}
Deposit / DepositStatus v2_0/deposit, v1_0/deposit/{0}
Refund / RefundStatus v2_0/refund, v1_0/refund/{0}
CashTransfer / status v1_0/cashtransfer, v1_0/cashtransfer/{0}
AccountBalance v1_0/account/balance
AccountBalanceInCurrency v1_0/account/balance/{0}
AccountHolderActive v1_0/accountholder/{0}/{1}/active
BasicUserInfo v1_0/accountholder/{0}/{1}/basicuserinfo
UserInfoWithConsent oauth2/v1_0/userinfo
ApiUser / ApiUserById / ApiUserKey v1_0/apiuser, v1_0/apiuser/{0}, v1_0/apiuser/{0}/apikey

Sandbox provisioning uses the subscription-key header against the product’s sandbox user APIs (not Bearer product token).


18. Statistics & logging

Statistics

var snap = momo.Statistics.GetSnapshot();
// snap.CapturedAtUtc
// snap.TotalCalls / TotalSuccesses / TotalFailures
// snap.Operations[*]:
//   Operation, Product, TotalCalls, SuccessCount, FailureCount,
//   AverageDurationMs, MinDurationMs, MaxDurationMs, P95DurationMs

momo.Statistics.Reset(); // clear in-process counters

Disable via MTNMoMo:Statistics:Enabled = false.

Logging

Source-generated LoggerMessage events in MoMoLog cover:

  • Operation started / succeeded / failed
  • Token cache hit / miss
  • Structured fields: product, operation, reference id, status code, duration

Hook any ILogger provider (Console, Serilog, Application Insights, …).


19. Resilience pipeline

Registered on named client MTNMoMo.HttpClient via Microsoft.Extensions.Http.Resilience.

Order (outer → inner):

  1. Total request timeout
  2. Exponential retry (+ optional jitter) for transient faults
  3. Circuit breaker
  4. Per-attempt timeout

Tune exclusively through MoMoOptions.Resilience / config section.


20. Runtime callback settings

IMoMoCallbackSettings keeps provisioning host and X-Callback-Url aligned.

Member Description
CallbackHost Host only
CallbackUrl Full URL
SendCallbackHeader Master switch for sending X-Callback-Url
SetCallbackHost(host) Updates host; rebuilds URL path /api/momo/callback when applicable
SetCallbackUrl(url) Sets full URL
SetSendCallbackHeader(bool) Enable/disable header

For localhost-only testing without a tunnel, set SendCallbackHeader = false and poll status instead (avoids INVALID_CALLBACK_URL_HOST).


21. Facade IMoMoClient

public interface IMoMoClient
{
    ICollectionClient Collection { get; }
    IDisbursementClient Disbursement { get; }
    IRemittanceClient Remittance { get; }
    IMoMoBulkClient Bulk { get; }
    ISandboxProvisioningClient Sandbox { get; }
    IMoMoCallbackParser Callbacks { get; }
    IMoMoStatisticsCollector Statistics { get; }
}

Useful when a single service needs multiple products without injecting each client separately.


22. End-to-end recipes

A) Sandbox checkout with callback + poll

using MTNMoMo.Auth;

// 1) Provision Collection credentials with ngrok host, then wire runtime + callback
var provisioned = (await sandbox.ProvisionAsync(
    MoMoProduct.Collection, "xxxx.ngrok-free.app", ct: ct)).Data!;

runtime.SetProductCredentials(
    provisioned.Product,
    provisioned.ApiUserId.ToString(),
    provisioned.ApiKey,
    provisioned.SubscriptionKey);

callbackSettings.SetCallbackHost(provisioned.ProviderCallbackHost!);
callbackSettings.SetSendCallbackHeader(true);

// 2) Charge
var rtp = RequestToPayRequest.Create(100m, CurrencyCode.EUR, "order-1", MoMoSandboxTestNumbers.Success);
var id = Guid.NewGuid();
await collections.RequestToPayAsync(rtp, id, ct);

// 3) Either wait for PUT/POST callback (TryParseTypedCallback), or poll:
for (var i = 0; i < 10; i++)
{
    var status = await collections.GetRequestToPayStatusAsync(id, ct);
    if (status.Data!.TransactionStatus.IsTerminal())
        break;
    await Task.Delay(TimeSpan.FromSeconds(2), ct);
}

B) Payout + refund

var transferId = (await disbursements.TransferAsync(
    TransferRequest.Create(100m, CurrencyCode.EUR, "payout-1", MoMoSandboxTestNumbers.Success),
    Guid.NewGuid(), ct)).Data!.ReferenceId;

// later, refund a collectible/refundable reference (business-dependent)
await disbursements.RefundAsync(
    RefundRequest.Create(100m, CurrencyCode.EUR, "refund-1", transferId),
    Guid.NewGuid(), ct);

C) Invoice lifecycle

var inv = await collections.CreateInvoiceAsync(
    InvoiceRequest.Create(200m, CurrencyCode.EUR, "inv-9", MoMoSandboxTestNumbers.Success, 3600),
    Guid.NewGuid(), ct);

var status = await collections.GetInvoiceStatusAsync(inv.Data!.ReferenceId, ct);

// Created / Pending are non-terminal — cancel while still open
if (status.Data!.TransactionStatus is TransactionStatus.Created
    or TransactionStatus.Pending
    or TransactionStatus.Unknown)
{
    // Path uses create UUID; SDK sends a *new* X-Reference-Id on the DELETE
    await collections.CancelInvoiceAsync(
        inv.Data.ReferenceId,
        new CancelInvoiceRequest { ExternalId = "inv-9" },
        ct);
}

D) Pre-approval + payment

var preId = (await collections.CreatePreApprovalAsync(
    PreApprovalRequest.Create(MoMoSandboxTestNumbers.Success, CurrencyCode.EUR),
    Guid.NewGuid(), ct)).Data!.ReferenceId;

// Callbacks often arrive as CREATED then SUCCESSFUL (TransactionStatus.Created → Successful)
for (var i = 0; i < 15; i++)
{
    var preStatus = await collections.GetPreApprovalStatusAsync(preId, ct);
    if (preStatus.Data!.TransactionStatus == TransactionStatus.Successful)
        break;
    await Task.Delay(TimeSpan.FromSeconds(2), ct);
}

var pay = PaymentRequest.Create(25m, CurrencyCode.EUR, "sub-charge-1", preId);
await collections.CreatePaymentAsync(pay, Guid.NewGuid(), ct);

// Note: CancelPreApproval often returns HTTP 400 in sandbox; prefer USSD subscriber cancel.

E) Bulk salary-style transfers

var rows = employees.Select((e, i) => new BulkTransferItem
{
    CorrelationId = e.EmployeeNumber,
    Request = TransferRequest.Create(e.Amount, CurrencyCode.EUR, $"sal-{e.EmployeeNumber}", e.Msisdn)
}).ToList();

var bulkResult = await bulk.BulkTransferAsync(rows, new BulkExecutionOptions
{
    MaxDegreeOfParallelism = 10,
    ContinueOnError = true
}, ct);

23. Troubleshooting

Symptom Likely cause Fix
INVALID_CURRENCY Non-EUR in sandbox Use CurrencyCode.EUR
INVALID_CALLBACK_URL_HOST Callback host ≠ provisioned host Re-provision with same host, or disable callback header
RESOURCE_ALREADY_EXIST on cancel/create Reused X-Reference-Id Always new UUID for creates; SDK already renews cancel header
Callback 405 on PUT Listener only mapped POST Map PUT and POST
Callback parse fails / wrong kind Unexpected JSON shape Use TryParseTypedCallback; inspect RawPayload / Kind
PreApproval rejected as invalid Missing externalId (normal) Typed parser accepts payer + status; return 200
No callback received Localhost / header off / host mismatch Use ngrok; enable header; match provision host; poll status
CancelPreApproval HTTP 400 empty body Sandbox / platform limitation Use USSD subscriber cancel; treat API cancel as best-effort
Token / config exception Missing ApiUserId/ApiKey Provision → SetProductCredentials, or set options per product
PAYER_NOT_FOUND Bad MSISDN Validate with ValidateAccountHolderAsync; use sandbox test numbers
Transfer works, Collection fails Wrong product credentials Each product needs its own API user

ngrok tip

Forwarding  https://xxxx.ngrok-free.app -> https://localhost:7xxx
  • providerCallbackHost = xxxx.ngrok-free.app
  • CallbackUrl = https://xxxx.ngrok-free.app/api/momo/callback

24. Out of scope

Intentionally not implemented (not Open API or not callable from this SDK):

  • Ghana / portal CSV bulk payroll upload & dual-authorization portal workflows
  • Collection Widget / USSD UI products
  • Non-Open-API MTN internal APIs

25. Versioning & license

  • Target framework: .NET 8
  • Package id: MTNMoMo.OpenApi (single package; includes MTNMoMo.dll + MTNMoMo.Shared.dll)
  • License: MIT
  • Packed README: this file (PackageReadmeFile)

Official MoMo docs: https://momodeveloper.mtn.com/

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 121 10/4/2026