This guide sends money to MTN MoMo wallets from a Django app with the Disbursements API: refunds, payouts to sellers or drivers, salaries. You’ll build:
- a
payouts/client.pymodule: a cached token, transfers, transfer status and balance, - a
Payoutmodel and aservices.pythat never sends the same payout twice, even after a timeout, a crash or a double click, - a
check_payoutscommand for cron that settles payouts and safely resends only the ones MTN never received, - tests that run every case without calling MTN.
Every code block was run in fresh Django 5.2 and 6.1 projects, and the tests pass. The Node.js version of this guide explains how Disbursements differs from Collections in more detail.
How a payout works
Disbursements has no approval step: there is no PIN prompt, so the money leaves your disbursement account as soon as MTN accepts the request. A duplicate request sends the money twice. So:
- You save the payout with its own
X-Reference-Id(a UUID v4) before calling MTN. POST /disbursement/v1_0/transferanswers202 Accepted. That is not a payout yet.GET /disbursement/v1_0/transfer/{X-Reference-Id}saysSUCCESSFUL,FAILEDorPENDING. That is the source of truth.- After a timeout, you look the payout up by your own reference. Only a
404proves MTN never received it; then you send it again with the same reference.
new ──send──▶ sending ──202──▶ pending ──status──▶ paid / failed / review
│
├──400 (clear refusal)──▶ failed
└──timeout / 5xx──▶ unknown ──status 404──▶ sent again, same reference
└──status found──▶ pending / paid / failed
Before you start
- On momodeveloper.mtn.com, subscribe to the Disbursements product and copy its primary key.
- Create a sandbox API user and key with that Disbursements key. The MoMo Request Builder generates the two requests; switch it to Disbursements first.
- Install
requests, create the app and set the credentials in your environment. Never commit them:
pip install "django>=5.2" requests
python manage.py startapp payouts
mkdir -p payouts/management/commands && touch payouts/management/__init__.py payouts/management/commands/__init__.py
export MOMO_DISBURSEMENT_SUBSCRIPTION_KEY=your-disbursements-subscription-key
export MOMO_DISBURSEMENT_API_USER=your-api-user-uuid
export MOMO_DISBURSEMENT_API_KEY=your-api-key
Add "payouts" to INSTALLED_APPS.
Settings
# settings.py (add to your existing settings)
import os
MOMO_DISBURSEMENT = {
"BASE_URL": os.environ.get("MOMO_BASE_URL", "https://sandbox.momodeveloper.mtn.com"),
"TARGET_ENV": os.environ.get("MOMO_TARGET_ENV", "sandbox"),
"CURRENCY": os.environ.get("MOMO_CURRENCY", "EUR"), # the sandbox only accepts EUR
"SUBSCRIPTION_KEY": os.environ.get("MOMO_DISBURSEMENT_SUBSCRIPTION_KEY", ""),
"API_USER": os.environ.get("MOMO_DISBURSEMENT_API_USER", ""),
"API_KEY": os.environ.get("MOMO_DISBURSEMENT_API_KEY", ""),
}
The model
# payouts/models.py
import uuid
from django.db import models
class Payout(models.Model):
"""One payout to a MoMo wallet. Never reuse a row for a retry of a failed payout: create a new one."""
# Final: nothing changes them again. review = MTN reported a different amount: a person must look.
FINAL = ("paid", "failed", "review")
OPEN = ("sending", "pending", "unknown")
reference_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) # MTN's X-Reference-Id
amount = models.DecimalField(max_digits=12, decimal_places=2)
currency = models.CharField(max_length=3)
phone = models.CharField(max_length=15) # 256772123456
external_id = models.CharField(max_length=64) # your own id: an order, a seller, a refund
note = models.CharField(max_length=160)
status = models.CharField(max_length=10, default="new") # new | sending | pending | unknown | paid | failed | review
reason = models.CharField(max_length=100, blank=True)
financial_transaction_id = models.CharField(max_length=64, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [models.Index(fields=["status", "updated_at"])]
Run python manage.py makemigrations payouts and python manage.py migrate.
The Disbursements client
The client turns network failures and 5xx answers into an ambiguous MomoError: the transfer may have been created. A 400 is a clear refusal, so nothing was sent.
# payouts/client.py
from decimal import Decimal
import requests
from django.conf import settings
from django.core.cache import cache
class MomoError(Exception):
"""ambiguous=True: the transfer may exist. Look it up; never resend with a new reference."""
def __init__(self, message, status=0, ambiguous=False, body=None):
super().__init__(message)
self.status = status
self.ambiguous = ambiguous
self.body = body or {}
def cfg(key):
return settings.MOMO_DISBURSEMENT[key]
def call(method, path, **kwargs):
"""Network errors become ambiguous MomoErrors: the request may have reached MTN."""
try:
return requests.request(method, cfg("BASE_URL").rstrip("/") + path, timeout=30, **kwargs)
except requests.RequestException as exc:
raise MomoError(f"MoMo unreachable: {exc}", ambiguous=True) from exc
def body(res):
try:
data = res.json()
except ValueError:
return {}
return data if isinstance(data, dict) else {}
def token():
cached = cache.get("momo_disbursement_token")
if cached:
return cached
res = call("POST", "/disbursement/token/", auth=(cfg("API_USER"), cfg("API_KEY")),
headers={"Ocp-Apim-Subscription-Key": cfg("SUBSCRIPTION_KEY")}, data=b"") # Content-Length: 0
data = body(res)
if not res.ok or not data.get("access_token"):
raise MomoError(f"Disbursement token failed: {res.status_code}", res.status_code)
cache.set("momo_disbursement_token", data["access_token"], max(60, int(data.get("expires_in", 3600)) - 60))
return data["access_token"]
def headers(extra=None):
return {"Authorization": f"Bearer {token()}", "Ocp-Apim-Subscription-Key": cfg("SUBSCRIPTION_KEY"),
"X-Target-Environment": cfg("TARGET_ENV"), **(extra or {})}
def plain(amount):
"""Decimal('5000.00') -> '5000', Decimal('12.50') -> '12.5'."""
return format(Decimal(amount).normalize(), "f")
def transfer(payout):
"""Sends money to a MoMo wallet. Returns on 202: accepted, NOT paid yet."""
res = call("POST", "/disbursement/v1_0/transfer", headers=headers({"X-Reference-Id": str(payout.reference_id)}), json={
"amount": plain(payout.amount),
"currency": payout.currency,
"externalId": payout.external_id,
"payee": {"partyIdType": "MSISDN", "partyId": payout.phone},
"payerMessage": payout.note[:160],
"payeeNote": payout.note[:160],
})
if res.status_code in (202, 409):
return # 409: this X-Reference-Id was already received, so the first request stands
data = body(res)
raise MomoError(f"Transfer failed: {res.status_code} {data.get('code', '')}".strip(), res.status_code,
ambiguous=res.status_code >= 500, body=data)
def get_transfer(reference_id):
"""The source of truth, or None if MTN never received this reference."""
res = call("GET", f"/disbursement/v1_0/transfer/{reference_id}", headers=headers())
if res.status_code == 404:
return None
if not res.ok:
raise MomoError(f"Transfer status failed: {res.status_code}", res.status_code, ambiguous=True)
return body(res)
def balance():
"""{'availableBalance': '1000', 'currency': 'EUR'} for your disbursement account."""
res = call("GET", "/disbursement/v1_0/account/balance", headers=headers())
if not res.ok:
raise MomoError(f"Balance failed: {res.status_code}", res.status_code)
return body(res)
Phone numbers go in international format without the +, for example 256772123456. The amount goes as a string without trailing zeros: "5000" or "12.5". The African Phone Number Formatter shows the right format for each country.
Never pay twice
# payouts/services.py
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.utils import timezone
from . import client
from .models import Payout
def create(amount, phone, external_id, note):
"""Create the payout record. Its reference is fixed for life. Nothing is sent yet."""
try:
amount = Decimal(str(amount))
except InvalidOperation:
raise ValueError("Amount must be a number") from None
if not amount.is_finite() or amount <= 0 or amount != amount.quantize(Decimal("0.01")):
raise ValueError("Amount must be positive, with at most 2 decimals")
if not (str(phone).isdigit() and 9 <= len(str(phone)) <= 15):
raise ValueError("Phone must be international without +, e.g. 256772123456")
return Payout.objects.create(amount=amount, currency=settings.MOMO_DISBURSEMENT["CURRENCY"], phone=phone,
external_id=str(external_id)[:64], note=str(note)[:160])
def claim(payout, from_statuses, to):
"""Atomically move a payout between statuses. False when another process got there first."""
return Payout.objects.filter(pk=payout.pk, status__in=from_statuses).update(status=to, updated_at=timezone.now()) == 1
def save(queryset, **fields):
"""queryset.update() skips auto_now, so set updated_at here: the check job waits a minute after it."""
return queryset.update(updated_at=timezone.now(), **fields)
def send(payout):
"""Sends a payout once. Only "new" is ever sent, and only one process can claim it."""
if not claim(payout, ["new"], "sending"): # saved BEFORE the network call
payout.refresh_from_db()
return payout # sent, or maybe sent: never again
try:
client.transfer(payout)
save(Payout.objects.filter(pk=payout.pk), status="pending")
except client.MomoError as exc:
if exc.ambiguous:
save(Payout.objects.filter(pk=payout.pk), status="unknown", reason="NO_RESPONSE") # check() decides
else:
save(Payout.objects.filter(pk=payout.pk), status="failed", reason=str(exc.body.get("code") or f"HTTP_{exc.status}"))
payout.refresh_from_db()
return payout
def check(payout):
"""Settles a payout from MTN's answer. Safe to run as often as you like."""
if payout.status not in Payout.OPEN:
return payout
try:
result = client.get_transfer(payout.reference_id)
except client.MomoError:
return payout # MTN unreachable: try again on the next run
if result is None:
# MTN never received it. Only now is it safe to send again, with the SAME reference.
if claim(payout, ["sending", "unknown"], "new"):
payout.refresh_from_db()
return send(payout)
payout.refresh_from_db()
return payout
status = str(result.get("status") or "").upper()
# Only an open payout can be settled: a late or repeated answer never undoes a final one.
still_open = Payout.objects.filter(pk=payout.pk).exclude(status__in=Payout.FINAL)
if status == "SUCCESSFUL":
try:
same = Decimal(str(result.get("amount"))) == payout.amount and result.get("currency") == payout.currency
except InvalidOperation:
same = False
if same:
save(still_open, status="paid", financial_transaction_id=str(result.get("financialTransactionId") or ""))
else:
save(still_open, status="review", reason=f"AMOUNT_MISMATCH {result.get('amount')} {result.get('currency')}"[:100])
elif status == "FAILED":
reason = result.get("reason")
reason = reason.get("code") if isinstance(reason, dict) else reason
save(still_open, status="failed", reason=str(reason or "FAILED")[:100])
else:
save(still_open, status="pending") # still PENDING at MTN
payout.refresh_from_db()
return payout
def run(payouts):
"""Sends new payouts one by one, and stops before the disbursement account runs dry."""
left = Decimal(str(client.balance().get("availableBalance") or "0"))
for payout in payouts:
if payout.status != "new" or payout.amount > left:
continue # too big for what's left: stays "new" for the next run, after you top up
if send(payout).status != "failed":
left -= payout.amount
The rules:
- Create the
X-Reference-Idonce, with the payout, and save it before sending. It is the transfer’s identity at MTN, and every retry reuses it. - Claim before sending.
claim()is a conditionalUPDATE ... WHERE status IN (...). If two requests or workers race, only one changes the row, and only that one sends. - After a timeout, ask before resending. If MTN has the transfer, wait for its result. Only a
404lets you send again, with the same reference. If MTN already has it after all, it answers409, and the first request stands. - Final means final.
paid,failedandreviewnever change, even through an old copy of the model: every update excludes final statuses. - Check the amount. A
SUCCESSFULtransfer for a different amount or currency goes toreviewfor a person, never topaid.
To pay someone:
# In your own authenticated admin view or task
from payouts import services
payout = services.create("5000", "256772123456", "order-7", "Refund for order 7")
services.send(payout) # pending, unknown or failed. check_payouts settles the rest.
Settle payouts from cron
# payouts/management/commands/check_payouts.py
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from payouts import services
from payouts.models import Payout
class Command(BaseCommand):
help = "Settle MoMo payouts in flight. Run it every minute from cron."
def handle(self, *args, **options):
# A minute old, so we never look up a payout that is still being sent.
due = (Payout.objects.filter(status__in=Payout.OPEN, updated_at__lte=timezone.now() - timedelta(minutes=1))
.order_by("id")[:100])
counts = {}
for payout in due:
status = services.check(payout).status
counts[status] = counts.get(status, 0) + 1
self.stdout.write(", ".join(f"{n} {s}" for s, n in counts.items()) or "Nothing to check.")
Run it every minute: * * * * * cd /srv/app && .venv/bin/python manage.py check_payouts. A payout that MTN never received is resent by check() itself, with its original reference.
To send a batch, run() reads the balance first and leaves payouts it can’t afford as new for the next run, after you top up.
Test it without calling MTN
These tests patch requests with unittest.mock. The fake remembers transfers by X-Reference-Id, the way MTN does, so they check that a payout is saved before it’s sent and sent only once, that a timeout or a 5xx leads to a lookup and never a second transfer, that a resend uses the same reference, that a different amount goes to review, and that final results never change.
# payouts/tests.py
from datetime import timedelta
from io import StringIO
from unittest import mock
import requests
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.utils import timezone
from . import services
from .models import Payout
def reply(status, data=None):
res = mock.Mock(status_code=status, ok=200 <= status < 300)
res.json.return_value = data if data is not None else {}
return res
@override_settings(MOMO_DISBURSEMENT={
"BASE_URL": "https://momo.test", "TARGET_ENV": "sandbox", "CURRENCY": "EUR",
"SUBSCRIPTION_KEY": "disb-key", "API_USER": "user", "API_KEY": "secret",
})
class PayoutTests(TestCase):
def setUp(self):
cache.clear()
self.mtn = {} # transfers by X-Reference-Id, the way MTN keeps them
self.posts = []
self.next = "ok" # next POST /transfer: ok, timeout_before, timeout_after, 503 or 400
self.hide_once = False # next status lookup misses it (it can take a moment to register)
patcher = mock.patch("payouts.client.requests.request", side_effect=self.fake)
patcher.start()
self.addCleanup(patcher.stop)
def fake(self, method, url, timeout=None, headers=None, json=None, auth=None, data=None):
path = url.removeprefix("https://momo.test")
if path == "/disbursement/token/":
return reply(200, {"access_token": "disb-token", "expires_in": 3600})
if path == "/disbursement/v1_0/account/balance":
return reply(200, {"availableBalance": "7000", "currency": "EUR"})
if method == "POST" and path == "/disbursement/v1_0/transfer":
self.posts.append({"headers": headers, "json": json})
ref, mode, self.next = headers["X-Reference-Id"], self.next, "ok"
if mode == 400:
return reply(400, {"code": "PAYEE_NOT_FOUND"})
if mode == 503:
return reply(503)
if mode == "timeout_before":
raise requests.Timeout("read timed out")
if ref in self.mtn:
return reply(409, {"code": "RESOURCE_ALREADY_EXIST"})
self.mtn[ref] = {"status": "PENDING", "amount": json["amount"], "currency": json["currency"]}
if mode == "timeout_after":
raise requests.Timeout("read timed out")
return reply(202)
t = self.mtn.get(path.rsplit("/", 1)[-1])
if self.hide_once:
self.hide_once, t = False, None
return reply(200, t) if t else reply(404, {"code": "RESOURCE_NOT_FOUND"})
def payout(self, amount="5000"):
return services.create(amount, "256772123456", "order-7", "Refund for order 7")
def test_saved_before_sending_and_sent_once(self):
p = self.payout()
self.assertEqual(p.status, "new")
sent = services.send(p)
self.assertEqual(sent.status, "pending")
stale = Payout.objects.get(pk=p.pk)
stale.status = "new" # a double click with an old copy
services.send(stale)
services.send(sent)
self.assertEqual(len(self.posts), 1)
call = self.posts[0]
self.assertEqual(call["headers"]["X-Reference-Id"], str(p.reference_id))
self.assertEqual(call["headers"]["Authorization"], "Bearer disb-token")
self.assertEqual([call["json"]["amount"], call["json"]["currency"], call["json"]["payee"]],
["5000", "EUR", {"partyIdType": "MSISDN", "partyId": "256772123456"}])
def test_paid_and_failed_come_only_from_the_lookup_and_stay_final(self):
p = services.send(self.payout())
self.assertEqual(services.check(p).status, "pending")
self.mtn[str(p.reference_id)].update(status="SUCCESSFUL", financialTransactionId="987654321")
old = Payout.objects.get(pk=p.pk) # still says "pending"
paid = services.check(p)
self.assertEqual([paid.status, paid.financial_transaction_id], ["paid", "987654321"])
self.mtn[str(p.reference_id)]["status"] = "FAILED"
self.assertEqual(services.check(paid).status, "paid") # final: never undone
self.assertEqual(services.check(old).status, "paid") # not even through an old copy
q = services.send(self.payout())
self.mtn[str(q.reference_id)] = {"status": "FAILED", "reason": "PAYEE_NOT_FOUND"}
failed = services.check(q)
self.assertEqual([failed.status, failed.reason], ["failed", "PAYEE_NOT_FOUND"])
def test_a_different_amount_goes_to_review(self):
p = services.send(self.payout())
self.mtn[str(p.reference_id)] = {"status": "SUCCESSFUL", "amount": "500", "currency": "EUR"}
self.assertEqual(services.check(p).status, "review")
def test_timeout_before_mtn_saw_it_resends_with_the_same_reference(self):
self.next = "timeout_before"
p = services.send(self.payout())
self.assertEqual(p.status, "unknown")
self.assertEqual(services.check(p).status, "pending") # 404, so sent again
self.assertEqual([c["headers"]["X-Reference-Id"] for c in self.posts], [str(p.reference_id)] * 2)
self.assertEqual(list(self.mtn), [str(p.reference_id)])
def test_timeout_after_mtn_created_it_is_never_sent_twice(self):
self.next = "timeout_after"
p = services.send(self.payout())
self.assertEqual(p.status, "unknown")
self.mtn[str(p.reference_id)]["status"] = "SUCCESSFUL"
self.assertEqual(services.check(p).status, "paid")
self.assertEqual(len(self.posts), 1)
def test_a_resend_of_a_transfer_mtn_already_has_is_not_a_failure(self):
self.next = "timeout_after"
p = services.send(self.payout())
self.hide_once = True # the lookup misses it, so it is sent again with the same reference
self.assertEqual(services.check(p).status, "pending") # MTN answers 409: the first request stands
self.assertEqual([len(self.posts), len(self.mtn)], [2, 1])
def test_a_server_error_is_unknown_not_failed(self):
self.next = 503
p = services.send(self.payout())
self.assertEqual(p.status, "unknown") # it may have gone through: check() decides
self.assertEqual(services.check(p).status, "pending")
def test_a_clear_refusal_fails_at_once(self):
self.next = 400
p = services.send(self.payout())
self.assertEqual([p.status, p.reason], ["failed", "PAYEE_NOT_FOUND"])
def test_run_stops_before_the_balance_runs_out_and_the_command_checks(self):
a, b, c = self.payout("5000"), self.payout("5000"), self.payout("2000") # the balance is 7000
services.run([a, b, c])
for p in (a, b, c):
p.refresh_from_db()
self.assertEqual([a.status, b.status, c.status], ["pending", "new", "pending"])
self.mtn[str(a.reference_id)]["status"] = "SUCCESSFUL"
Payout.objects.update(updated_at=timezone.now() - timedelta(minutes=2))
out = StringIO()
call_command("check_payouts", stdout=out)
a.refresh_from_db()
self.assertEqual(a.status, "paid")
self.assertIn("1 paid", out.getvalue())
def test_bad_input_is_refused(self):
for amount, phone in (("50.5.5", "256772123456"), ("-1", "256772123456"), ("1.234", "256772123456"), ("10", "+256772")):
with self.subTest(amount=amount, phone=phone), self.assertRaises(ValueError):
services.create(amount, phone, "x", "y")
python manage.py test payouts
Test against the sandbox
Send a real sandbox transfer of 1 EUR to a test number and run python manage.py check_payouts a minute later. MTN’s sandbox test numbers simulate outcomes for transfers too: 46733123450 fails, 46733123451 is rejected, 46733123452 times out, 46733123453 stays pending, and numbers not on MTN’s list succeed. The full list is on the Request Builder page.
Common errors and fixes
| Error | Likely cause | Fix |
|---|---|---|
401 on the token or transfer |
Collection keys or token used for Disbursements | Use the Disbursements subscription key and an API user created with it |
411 on the token |
The token POST had no body | Keep data=b"" so requests sends Content-Length: 0 |
400 with a reason code |
A field is invalid: currency, amount format or phone number | In the sandbox use EUR, send the amount as a string, and the phone without + |
409 Conflict |
The same X-Reference-Id was sent twice |
Expected on a retry. The first transfer stands; check its status |
FAILED with NOT_ENOUGH_FUNDS |
Your disbursement account is short | Check client.balance() and top up before the next run |
FAILED with PAYEE_NOT_FOUND |
The receiving number has no active MoMo wallet | Ask the receiver to confirm their MoMo number |
Every MoMo failure reason is explained in the MTN MoMo error code reference.
Going to production
- Disbursements needs its own production approval from MTN. Use the production Disbursements subscription key, API user and key from the MTN MoMo Partner Portal.
- Set
MOMO_BASE_URLto the production host,MOMO_TARGET_ENVto your country (for examplemtnuganda) andMOMO_CURRENCYto the local currency (for exampleUGX). - Protect the code that creates payouts. Anyone who can call it can send your money. Require an authenticated staff user, set limits per payout and per day, and log who created each one.
- Use a cache all your servers share (Redis or the database) so they share the token.
- Reconcile daily against your MoMo statement, especially payouts that went through
unknown.
Comparing payout APIs? See mobile money payouts. Want this done for you, with M-Pesa B2C, Flutterwave and Paystack payouts too? The Django starter kit includes them.
Frequently asked questions
Can I use my MoMo Collections keys for Disbursements in Django?
No. Disbursements is a separate MoMo product with its own subscription key, API user and key, and its own token endpoint (/disbursement/token/). Keep them in their own settings block, as this guide does with MOMO_DISBURSEMENT.
How do I stop Django from sending the same MoMo payout twice?
Save the payout with its X-Reference-Id before sending, then claim it with a conditional update: Payout.objects.filter(pk=..., status="new").update(status="sending"). Only the caller whose update changed one row sends it.
My MoMo transfer request timed out. Should I send it again?
Not with a new reference. Look it up with GET /disbursement/v1_0/transfer/{X-Reference-Id}. If MTN has it, wait for its result. Only a 404 proves it was never created; then send it again with the same X-Reference-Id, and MTN refuses a real duplicate with 409.
Why does updated_at not change when I use queryset.update() in Django?
auto_now fields are only set by Model.save(). QuerySet.update() writes straight to the database, so set updated_at yourself, as the save() helper in this guide does. The check command relies on it to leave payouts that are still being sent alone.