MTNMoMo.OpenApi
1.0.0
dotnet add package MTNMoMo.OpenApi --version 1.0.0
NuGet\Install-Package MTNMoMo.OpenApi -Version 1.0.0
<PackageReference Include="MTNMoMo.OpenApi" Version="1.0.0" />
<PackageVersion Include="MTNMoMo.OpenApi" Version="1.0.0" />
<PackageReference Include="MTNMoMo.OpenApi" />
paket add MTNMoMo.OpenApi --version 1.0.0
#r "nuget: MTNMoMo.OpenApi, 1.0.0"
#:package MTNMoMo.OpenApi@1.0.0
#addin nuget:?package=MTNMoMo.OpenApi&version=1.0.0
#tool nuget:?package=MTNMoMo.OpenApi&version=1.0.0
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
- Packages & namespaces
- Install & dependency injection
- Configuration reference
- Authentication & credentials
- HTTP transport details
- Sandbox guide
- Collection API (all methods)
- Disbursement API (all methods)
- Remittance API (all methods)
- Bulk orchestration
- Sandbox provisioning
- Callbacks
- Consent / OAuth2 KYC helpers
- Results, exceptions & error codes
- Enums & status mapping
- DTO & factory reference
- API path map
- Statistics & logging
- Resilience pipeline
- Runtime callback settings
- Facade
IMoMoClient - End-to-end recipes
- Troubleshooting
- Out of scope
- 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
- For each product call,
IMoMoTokenProviderloads credentials viaIMoMoCredentialResolver. - Resolver prefers runtime store overrides, then
MoMoOptionsproduct credentials. POST {product}/token/with BasicApiUserId:ApiKey+ subscription key.- Bearer token is cached in-memory per product/user with refresh skew.
- 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:
- The client method sets
includeCallbackUrl: true(create/initiation ops) IMoMoCallbackSettings.SendCallbackHeader == trueIMoMoCallbackSettings.CallbackUrlis 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
- Start a public tunnel (ngrok, etc.) to your local HTTPS app.
- Provision API user with
providerCallbackHost= host only (nohttps://, no path). - Set
CallbackUrl=https://{same-host}/api/momo/callback. - Listener must accept PUT and POST and return 200.
- 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:
SemaphoreSlimconcurrency 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(RequestToWithdrawCallbackDtoexists for explicit deserialize if you know the product). - Enum values
Deposit,CashTransfer, andRequestToWithdrawexist 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
providerCallbackHostat provision time == host ofX-Callback-UrlSendCallbackHeaderenabled (and URL not localhost unless tunneled)- Listener returns 200 for PUT and POST
- Poll status if callback is missed (MoMo typically does not retry)
- Handle
CREATEDas non-final for invoices / pre-approvals
13. Consent / OAuth2 KYC helpers
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 > 0externalId/ 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):
- Total request timeout
- Exponential retry (+ optional jitter) for transient faults
- Circuit breaker
- 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.appCallbackUrl=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; includesMTNMoMo.dll+MTNMoMo.Shared.dll) - License: MIT
- Packed README: this file (
PackageReadmeFile)
Official MoMo docs: https://momodeveloper.mtn.com/
| Product | Versions 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. |
-
net8.0
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Http.Resilience (>= 8.10.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
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 |