Skip to content

MTN MoMo · Django

MTN MoMo Disbursements API in Django: Safe Payouts and Refunds

Send money to MTN MoMo wallets from Django with the Disbursements API: transfers, status checks, balance checks and payouts never sent twice, with tests.

Published

Skip the boilerplate → Generate the Disbursements requests in this guide, in Python or cURL, with the MoMo Request Builder.

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.py module: a cached token, transfers, transfer status and balance,
  • a Payout model and a services.py that never sends the same payout twice, even after a timeout, a crash or a double click,
  • a check_payouts command 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:

  1. You save the payout with its own X-Reference-Id (a UUID v4) before calling MTN.
  2. POST /disbursement/v1_0/transfer answers 202 Accepted. That is not a payout yet.
  3. GET /disbursement/v1_0/transfer/{X-Reference-Id} says SUCCESSFUL, FAILED or PENDING. That is the source of truth.
  4. After a timeout, you look the payout up by your own reference. Only a 404 proves 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

  1. On momodeveloper.mtn.com, subscribe to the Disbursements product and copy its primary key.
  2. Create a sandbox API user and key with that Disbursements key. The MoMo Request Builder generates the two requests; switch it to Disbursements first.
  3. 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:

  1. Create the X-Reference-Id once, with the payout, and save it before sending. It is the transfer’s identity at MTN, and every retry reuses it.
  2. Claim before sending. claim() is a conditional UPDATE ... WHERE status IN (...). If two requests or workers race, only one changes the row, and only that one sends.
  3. After a timeout, ask before resending. If MTN has the transfer, wait for its result. Only a 404 lets you send again, with the same reference. If MTN already has it after all, it answers 409, and the first request stands.
  4. Final means final. paid, failed and review never change, even through an old copy of the model: every update excludes final statuses.
  5. Check the amount. A SUCCESSFUL transfer for a different amount or currency goes to review for a person, never to paid.

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_URL to the production host, MOMO_TARGET_ENV to your country (for example mtnuganda) and MOMO_CURRENCY to the local currency (for example UGX).
  • 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.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.