Skip to content

Flutterwave · Django

Flutterwave Mobile Money Payouts in Django: MTN and Airtel Uganda

Send payouts to MTN and Airtel Money wallets in Uganda with the Flutterwave v3 Transfers API in Django, without ever paying twice. With tests.

Published

Skip the boilerplate → Paste a transfer.completed webhook into the Flutterwave Webhook Checker to see what it means.

This guide sends money from your Flutterwave balance to MTN Mobile Money and Airtel Money wallets in Uganda from a Django app, with Flutterwave’s v3 Transfers API: refunds, seller payouts, salaries. You’ll build:

  • a client.py: create a transfer and look one up,
  • a FlutterwavePayout model and a services.py that never sends the same payout twice, even after a timeout, a crash or a double click,
  • a webhook view that checks verif-hash and settles payouts only from the looked-up transfer,
  • a check_flutterwave_payouts command that settles pending payouts and reports the ones that need a person,
  • tests that run every case without calling Flutterwave.

Every code block was run in fresh Django 5.2 and 6.1 projects, and the tests at the end pass. API facts come from Flutterwave’s public v3 documentation, checked in November 2026. The Node.js version of this guide has the same design. To receive payments, see Flutterwave mobile money in Django.

How a payout works

  1. You save the payout with your own reference, then call POST /v3/transfers.
  2. Flutterwave answers "Transfer Queued Successfully" with data.id and status NEW. That is not a payout yet.
  3. The status moves through PENDING to SUCCESSFUL or FAILED. Flutterwave sends a transfer.completed webhook either way.
  4. You confirm with GET /v3/transfers/{id}, the source of truth.
new ──send──▶ sending ──queued (id)──▶ pending ──lookup: SUCCESSFUL──▶ paid
                 │                         └────lookup: FAILED──▶ failed
                 ├──4xx (clear refusal)──▶ failed
                 └──timeout / 5xx──▶ unknown ──webhook brings the id──▶ pending / paid / failed

There’s one gap to design around. Flutterwave’s docs describe looking a transfer up by its id, which you only learn from the response. If the response is lost, you can’t check the transfer yourself. So a timed-out payout is never resent automatically: it waits for the webhook, which carries your reference and the id, or for a person to check the dashboard.

Before you start

  1. In your Flutterwave dashboard, switch to test mode, copy the test secret key, and fund the test balance you’ll pay out from.
  2. In the webhook settings, set your webhook URL and a long random secret hash (32+ characters), and turn on retries.
  3. Install requests, create the app and set both values in your environment. Never commit them.
pip install "django>=5.2" requests
python manage.py startapp flutterwave_payouts
mkdir -p flutterwave_payouts/management/commands
touch flutterwave_payouts/management/__init__.py flutterwave_payouts/management/commands/__init__.py
export FLW_SECRET_KEY=your-test-secret-key
export FLW_SECRET_HASH=the-same-secret-hash-as-your-dashboard

Add "flutterwave_payouts" to INSTALLED_APPS, and the settings (if you followed the mobile money guide, you already have them):

# settings.py (add to your existing settings)
import os

FLUTTERWAVE = {
    "BASE_URL": "https://api.flutterwave.com",
    "SECRET_KEY": os.environ.get("FLW_SECRET_KEY", ""),
    "SECRET_HASH": os.environ.get("FLW_SECRET_HASH", ""),
}

The model

# flutterwave_payouts/models.py
import uuid

from django.db import models


def new_reference():
    return f"payout-{uuid.uuid4()}"


class FlutterwavePayout(models.Model):
    """One payout to a mobile money wallet. A failed payout is never retried in place: create a new one."""

    # Final: nothing changes them again. review = the transfer's amount or currency differs: a person must look.
    FINAL = ("paid", "failed", "review")

    reference = models.CharField(max_length=64, unique=True, default=new_reference, editable=False)  # the webhook carries it
    amount = models.PositiveBigIntegerField()  # whole shillings
    currency = models.CharField(max_length=3, default="UGX")
    phone = models.CharField(max_length=15)  # 256772123456
    network = models.CharField(max_length=10)  # MTN or Airtel
    name = models.CharField(max_length=100)
    narration = models.CharField(max_length=100)
    status = models.CharField(max_length=10, default="new")  # new | sending | pending | unknown | paid | failed | review
    transfer_id = models.PositiveBigIntegerField(null=True, blank=True)  # Flutterwave's id: the only way to look it up
    reason = models.CharField(max_length=200, 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 flutterwave_payouts and python manage.py migrate.

The transfers client

Network failures, 5xx answers and bodies that aren’t JSON become an ambiguous error: the transfer may have been created. Flutterwave’s docs say a timeout comes back as a 503 after about 28 seconds and the transfer may still be processing. A 4xx is a clear refusal.

# flutterwave_payouts/client.py
import hmac
from urllib.parse import quote

import requests
from django.conf import settings


class FlutterwaveTransferError(Exception):
    """ambiguous=True: the transfer may exist. Never send the payout again automatically."""

    def __init__(self, message, status=0, ambiguous=False, body=None):
        super().__init__(message)
        self.status = status
        self.ambiguous = ambiguous
        self.body = body or {}


def call(method, path, **kwargs):
    cfg = settings.FLUTTERWAVE
    try:
        res = requests.request(method, cfg["BASE_URL"].rstrip("/") + path, timeout=60,
                               headers={"Authorization": f"Bearer {cfg['SECRET_KEY']}", "Accept": "application/json"}, **kwargs)
    except requests.RequestException as exc:
        raise FlutterwaveTransferError(f"Flutterwave unreachable: {exc}", ambiguous=True) from exc
    try:
        data = res.json()
    except ValueError:
        data = None
    if res.status_code >= 500 or not isinstance(data, dict):
        raise FlutterwaveTransferError(f"Flutterwave HTTP {res.status_code}", res.status_code, ambiguous=True,
                                       body=data if isinstance(data, dict) else {})
    return res, data


def create_transfer(payout):
    """Queues a mobile money transfer. Returns Flutterwave's data: id, reference, status ("NEW"). Not paid yet."""
    res, data = call("POST", "/v3/transfers", json={
        "account_bank": payout.network,  # the operator code: "MTN" or "Airtel" for Uganda
        "account_number": payout.phone,  # with the country code: 256772123456
        "amount": payout.amount,
        "currency": payout.currency,
        "beneficiary_name": payout.name,
        "narration": payout.narration,
        "reference": payout.reference,  # saved before this call
    })
    transfer = data.get("data")
    if res.ok and data.get("status") == "success" and isinstance(transfer, dict) and transfer.get("id"):
        return transfer
    raise FlutterwaveTransferError(f"Transfer refused: {data.get('message') or res.status_code}", res.status_code, body=data)


def get_transfer(transfer_id):
    """The source of truth for a transfer you have the id of: id, reference, status, amount, currency, complete_message."""
    res, data = call("GET", f"/v3/transfers/{quote(str(transfer_id), safe='')}")
    if res.ok and data.get("status") == "success" and isinstance(data.get("data"), dict):
        return data["data"]
    raise FlutterwaveTransferError(f"Transfer lookup failed: {res.status_code}", res.status_code, ambiguous=True, body=data)


def hash_ok(given, secret_hash):
    """verif-hash must equal the secret hash you set in the dashboard."""
    if len(secret_hash or "") < 32 or not isinstance(given, str):
        return False
    return hmac.compare_digest(secret_hash.encode(), given.encode())

The operator code. Flutterwave’s mobile money page lists Airtel, MPS and MTN as the options for Uganda (UGX). This guide sends MTN or Airtel in account_bank; confirm which code your account expects in test mode before going live. The African Phone Number Formatter shows the network for any Ugandan number, and account_number takes the country code: 256772123456.

Never pay twice

# flutterwave_payouts/services.py
import re
from decimal import Decimal, InvalidOperation

from django.utils import timezone

from . import client
from .models import FlutterwavePayout


def create(amount, phone, network, name, narration):
    """Create the payout record. Its reference is fixed for life and never reused. Nothing is sent yet."""
    if not isinstance(amount, int) or isinstance(amount, bool) or amount <= 0:
        raise ValueError("Payout amounts are whole shillings, more than zero")
    if not re.fullmatch(r"256\d{9}", str(phone)):
        raise ValueError("Phone must look like 256772123456")
    if network not in ("MTN", "Airtel"):
        raise ValueError("Network must be MTN or Airtel")
    return FlutterwavePayout.objects.create(amount=amount, phone=phone, network=network, name=str(name)[:100], narration=str(narration)[:100])


def save(queryset, **fields):
    """queryset.update() skips auto_now, so set updated_at here: the check command relies on it."""
    return queryset.update(updated_at=timezone.now(), **fields)


def still_open(payout):
    """Only payouts that aren't final can change, whatever an old copy of the model says."""
    return FlutterwavePayout.objects.filter(pk=payout.pk).exclude(status__in=FlutterwavePayout.FINAL)


def fresh(payout):
    payout.refresh_from_db()
    return payout


def send(payout):
    """Sends a payout once. Only "new" is ever sent, and only one process can claim it."""
    if save(FlutterwavePayout.objects.filter(pk=payout.pk, status="new"), status="sending") != 1:  # saved BEFORE the call
        return fresh(payout)  # sent, or maybe sent: never again
    sending = FlutterwavePayout.objects.filter(pk=payout.pk, status="sending")
    try:
        data = client.create_transfer(payout)
    except client.FlutterwaveTransferError as exc:
        if exc.ambiguous:
            save(sending, status="unknown", reason="NO_RESPONSE")  # may exist, and we have no id: wait for the webhook
        else:
            save(sending, status="failed", reason=str(exc.body.get("message") or f"HTTP_{exc.status}")[:200])
        return fresh(payout)
    save(sending, status="pending", transfer_id=int(data["id"]))  # queued is not paid
    return fresh(payout)


def settle(payout, transfer):
    """Applies a transfer fetched from Flutterwave to its payout."""
    if transfer.get("reference") != payout.reference or not transfer.get("id"):
        return fresh(payout)  # not this payout
    transfer_id = int(transfer["id"])
    try:
        same = Decimal(str(transfer.get("amount"))) == payout.amount and transfer.get("currency") == payout.currency
    except InvalidOperation:
        same = False
    if not same:
        save(still_open(payout), status="review", transfer_id=transfer_id,
             reason=f"TRANSFER {transfer.get('amount')} {transfer.get('currency')}"[:200])
        return fresh(payout)
    status = str(transfer.get("status") or "").upper()
    if status == "SUCCESSFUL":
        save(still_open(payout), status="paid", transfer_id=transfer_id, reason="")
    elif status == "FAILED":
        save(still_open(payout), status="failed", transfer_id=transfer_id, reason=str(transfer.get("complete_message") or "FAILED")[:200])
    else:  # NEW or PENDING
        save(still_open(payout), status="pending", transfer_id=transfer_id)
    return fresh(payout)


def check(payout):
    """Scheduled check. Only payouts with a transfer id can be looked up; the rest wait for the webhook or a person."""
    if payout.status not in ("pending", "unknown") or not payout.transfer_id:
        return payout
    try:
        transfer = client.get_transfer(payout.transfer_id)
    except client.FlutterwaveTransferError:
        return payout  # Flutterwave unreachable: try again on the next run
    return settle(payout, transfer)


def handle_webhook(body):
    """Handles a transfer.completed webhook whose verif-hash you already checked. The body only says which transfer to fetch."""
    data = body.get("data") if isinstance(body.get("data"), dict) else {}
    if body.get("event") != "transfer.completed" or not data.get("id") or not isinstance(data.get("reference"), str):
        return None
    payout = FlutterwavePayout.objects.filter(reference=data["reference"]).first()
    if payout is None:
        return None  # not ours
    if payout.transfer_id and str(payout.transfer_id) != str(data["id"]):
        return None  # not the transfer Flutterwave gave us for this payout
    transfer = client.get_transfer(data["id"])  # raises if Flutterwave is unreachable: answer 500 so it retries
    return settle(payout, transfer)

The rules:

  1. Create the reference once, and save it before sending. It identifies the payout in the webhook.
  2. Claim before sending. The conditional UPDATE ... WHERE status = 'new' lets only one request or worker send a payout.
  3. Never resend automatically. An unknown or stuck sending payout waits for its webhook, or for a person to check the dashboard. If it really never happened, create a new payout with a new reference, on purpose.
  4. Only the looked-up transfer counts. The webhook body only says which transfer to fetch; its reference, amount and currency must match the payout, and once a payout has a transfer id, no other id can settle it.
  5. Final means final. paid, failed and review never change: every update excludes final payouts.

To pay someone, from your own authenticated admin view or task:

from flutterwave_payouts import services

services.send(services.create(50000, "256772123456", "MTN", "Jane Namuli", "Seller payout 77"))  # pending, unknown or failed

The webhook

# flutterwave_payouts/views.py
import json
import logging

from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from . import client, services

log = logging.getLogger(__name__)


@csrf_exempt  # Flutterwave can't send a CSRF token; verif-hash protects this view instead
@require_POST
def webhook(request):
    if not client.hash_ok(request.headers.get("verif-hash"), settings.FLUTTERWAVE.get("SECRET_HASH") or ""):
        return HttpResponse(status=401)
    try:
        body = json.loads(request.body)
    except ValueError:
        return HttpResponse(status=200)  # not JSON: nothing to do
    if not isinstance(body, dict):
        return HttpResponse(status=200)
    try:
        services.handle_webhook(body)
    except client.FlutterwaveTransferError as exc:
        log.warning("Flutterwave transfer webhook not processed: %s", exc)
        return HttpResponse(status=500)  # Flutterwave retries, if retries are on in your dashboard
    return HttpResponse(status=200)
# flutterwave_payouts/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path("webhooks/flutterwave/", views.webhook),
]

Include it in your project’s urls.py with path("", include("flutterwave_payouts.urls")). Flutterwave sends every event to the one webhook URL in your dashboard. If you already receive charge.completed webhooks (the mobile money guide also uses webhooks/flutterwave/), don’t add a second route: in that view, after the verif-hash check, also call services.handle_webhook(body). It ignores anything that isn’t transfer.completed.

Settle payouts from cron

# flutterwave_payouts/management/commands/check_flutterwave_payouts.py
from datetime import timedelta

from django.core.management.base import BaseCommand
from django.utils import timezone

from flutterwave_payouts import services
from flutterwave_payouts.models import FlutterwavePayout


class Command(BaseCommand):
    help = "Settle Flutterwave payouts in flight, and report the ones a person must check. Run it every few minutes."

    def handle(self, *args, **options):
        now = timezone.now()
        # Payouts with a transfer id, in flight for over a minute: ask Flutterwave.
        due = (FlutterwavePayout.objects.filter(status__in=("pending", "unknown"), transfer_id__isnull=False,
                                                updated_at__lte=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
        # No id and no webhook after an hour: a person must check the dashboard. Never resend these automatically.
        stuck = FlutterwavePayout.objects.filter(status__in=("sending", "unknown"), transfer_id__isnull=True,
                                                 updated_at__lte=now - timedelta(hours=1))
        for reference in stuck.values_list("reference", flat=True):
            self.stderr.write(f"Check {reference} in the Flutterwave dashboard: no answer and no webhook.")
        self.stdout.write(", ".join(f"{n} {s}" for s, n in counts.items()) or "Nothing to check.")

Run it every five minutes: */5 * * * * cd /srv/app && .venv/bin/python manage.py check_flutterwave_payouts. Send its error output somewhere you’ll see it.

Test it without calling Flutterwave

These tests patch requests with unittest.mock. They check that a payout is saved before it’s sent and sent only once, that the webhook’s verif-hash is checked and its body never trusted, that a lost response leads to unknown and never a second transfer, that another payout’s transfer can’t settle it, that a wrong amount goes to review, and that final results never change.

# flutterwave_payouts/tests.py
from datetime import timedelta
from io import StringIO
from unittest import mock

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 FlutterwavePayout

HASH = "test-secret-hash-of-at-least-32-characters"
FLW = {"BASE_URL": "https://flw.test", "SECRET_KEY": "FLWSECK_TEST-x", "SECRET_HASH": HASH}


def reply(status, data=None):
    res = mock.Mock(status_code=status, ok=200 <= status < 300)
    if data is None:
        res.json.side_effect = ValueError("no JSON")
    else:
        res.json.return_value = data
    return res


@override_settings(FLUTTERWAVE=FLW)
class FlutterwavePayoutTests(TestCase):
    def setUp(self):
        self.flw = {}  # transfers by id, the way Flutterwave keeps them
        self.next_id = 85623
        self.posts = []  # each POST /v3/transfers, with our payout's status at that moment
        self.next = "ok"  # next POST: ok, timeout (created, but the answer is lost) or 400
        self.down = False  # lookups answer 503
        patcher = mock.patch("flutterwave_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):
        assert headers["Authorization"] == "Bearer FLWSECK_TEST-x"
        path = url.removeprefix("https://flw.test")
        if method == "POST" and path == "/v3/transfers":
            mode, self.next = self.next, "ok"
            self.posts.append({"json": json, "status": FlutterwavePayout.objects.get(reference=json["reference"]).status})
            if mode == 400:
                return reply(400, {"status": "error", "message": "Insufficient balance", "data": None})
            t = {"id": self.next_id, "reference": json["reference"], "amount": json["amount"], "currency": json["currency"],
                 "status": "NEW", "complete_message": ""}
            self.next_id += 1
            self.flw[t["id"]] = t
            if mode == "timeout":
                return reply(503)
            return reply(200, {"status": "success", "message": "Transfer Queued Successfully", "data": dict(t)})
        if method == "GET" and path.startswith("/v3/transfers/"):
            if self.down:
                return reply(503)
            t = self.flw.get(int(path.rsplit("/", 1)[1]))
            return reply(200, {"status": "success", "message": "Transfer fetched", "data": dict(t)}) if t else \
                reply(404, {"status": "error", "message": "No transfer found", "data": None})
        raise AssertionError(f"unexpected {method} {url}")

    def payout(self):
        return services.create(50000, "256772123456", "MTN", "Jane Namuli", "Seller payout 77")

    def by_ref(self, reference):
        return next(t for t in self.flw.values() if t["reference"] == reference)

    def webhook(self, data, verif_hash=HASH, event="transfer.completed"):
        extra = {"HTTP_VERIF_HASH": verif_hash} if verif_hash is not None else {}
        return self.client.post("/webhooks/flutterwave/", {"event": event, "data": data}, content_type="application/json", **extra)

    def get(self, payout):
        return FlutterwavePayout.objects.get(pk=payout.pk)

    def test_saved_before_sending_sent_once_and_paid_from_the_looked_up_transfer(self):
        p = self.payout()
        sent = services.send(p)
        self.assertEqual((sent.status, sent.transfer_id), ("pending", 85623))
        services.send(self.get(p))  # a double click
        stale = self.get(p)
        stale.status = "new"  # an old copy that still says "new"
        services.send(stale)
        self.assertEqual([x["status"] for x in self.posts], ["sending"])
        self.assertEqual(self.posts[0]["json"], {"account_bank": "MTN", "account_number": "256772123456", "amount": 50000, "currency": "UGX",
                                                 "beneficiary_name": "Jane Namuli", "narration": "Seller payout 77", "reference": p.reference})
        self.flw[85623]["status"] = "SUCCESSFUL"
        self.assertEqual(self.webhook({"id": 85623, "reference": p.reference, "status": "SUCCESSFUL"}).status_code, 200)
        self.assertEqual(self.get(p).status, "paid")

    def test_the_webhook_checks_verif_hash_and_never_trusts_the_body(self):
        p = services.send(self.payout())
        data = {"id": p.transfer_id, "reference": p.reference, "status": "SUCCESSFUL", "amount": 50000}
        self.assertEqual(self.webhook(data, "wrong-hash-wrong-hash-wrong-hash-xx").status_code, 401)
        self.assertEqual(self.webhook(data, None).status_code, 401)
        self.assertEqual(self.webhook(data).status_code, 200)
        self.assertEqual(self.get(p).status, "pending")  # Flutterwave still says NEW
        self.flw[p.transfer_id]["status"] = "SUCCESSFUL"
        self.assertEqual(self.webhook(data, event="charge.completed").status_code, 200)
        self.assertEqual(self.webhook({"id": p.transfer_id, "reference": "not-ours"}).status_code, 200)
        self.assertEqual(self.get(p).status, "pending")
        with override_settings(FLUTTERWAVE={**FLW, "SECRET_HASH": "short"}):  # a weak hash refuses everything
            self.assertEqual(self.webhook(data, "short").status_code, 401)

    def test_no_response_means_unknown_never_a_second_transfer_and_the_webhook_settles_it(self):
        self.next = "timeout"
        p = services.send(self.payout())
        self.assertEqual((p.status, p.transfer_id, p.reason), ("unknown", None, "NO_RESPONSE"))
        services.send(p)
        self.assertEqual(services.check(p).status, "unknown")  # no id: nothing to look up, nothing resent
        self.assertEqual(len(self.posts), 1)
        t = self.by_ref(p.reference)
        t["status"] = "SUCCESSFUL"
        self.assertEqual(self.webhook({"id": t["id"], "reference": p.reference}).status_code, 200)  # the webhook carries the id
        self.assertEqual((self.get(p).status, self.get(p).transfer_id), ("paid", t["id"]))

    def test_another_payouts_transfer_cannot_settle_a_payout(self):
        other = services.send(self.payout())
        self.flw[other.transfer_id]["status"] = "SUCCESSFUL"
        self.next = "timeout"
        unknown = services.send(self.payout())
        # A body naming the unknown payout's reference but the other payout's transfer id:
        self.webhook({"id": other.transfer_id, "reference": unknown.reference})
        self.assertEqual(self.get(unknown).status, "unknown")
        mine = services.send(self.payout())  # and a payout with an id is only settled by that id
        self.webhook({"id": other.transfer_id, "reference": mine.reference})
        self.assertEqual(self.get(mine).status, "pending")

    def test_failed_keeps_the_reason_and_final_means_final(self):
        p = services.send(self.payout())
        self.assertEqual(services.check(p).status, "pending")  # still NEW
        self.flw[p.transfer_id].update(status="FAILED", complete_message="Recipient wallet not found")
        failed = services.check(p)
        self.assertEqual((failed.status, failed.reason), ("failed", "Recipient wallet not found"))
        self.flw[p.transfer_id]["status"] = "SUCCESSFUL"  # even if a later lookup disagreed...
        self.webhook({"id": p.transfer_id, "reference": p.reference})
        self.assertEqual(self.get(p).status, "failed")

        q = services.send(self.payout())
        self.flw[q.transfer_id]["status"] = "SUCCESSFUL"
        self.assertEqual(services.check(q).status, "paid")
        stale = FlutterwavePayout(pk=q.pk, reference=q.reference, amount=q.amount, currency="UGX", status="pending")
        self.flw[q.transfer_id]["status"] = "FAILED"
        self.assertEqual(services.settle(stale, self.flw[q.transfer_id]).status, "paid")  # ...through an old copy too

    def test_a_different_amount_or_currency_goes_to_review(self):
        for change in ({"amount": 5000}, {"currency": "KES"}, {"amount": 50000.5}, {"amount": "lots"}):
            p = services.send(self.payout())
            self.flw[p.transfer_id].update(status="SUCCESSFUL", **change)
            self.assertEqual(services.check(p).status, "review", change)

    def test_a_clear_refusal_fails_at_once(self):
        self.next = 400
        p = services.send(self.payout())
        self.assertEqual((p.status, p.reason), ("failed", "Insufficient balance"))

    def test_an_unreachable_flutterwave_changes_nothing_and_the_webhook_asks_for_a_retry(self):
        p = services.send(self.payout())
        self.down = True
        self.assertEqual(services.check(p).status, "pending")
        self.assertEqual(self.webhook({"id": p.transfer_id, "reference": p.reference}).status_code, 500)

    def test_the_command_checks_pending_payouts_and_reports_stuck_ones(self):
        a = services.send(self.payout())
        self.next = "timeout"
        b = services.send(self.payout())
        self.flw[a.transfer_id]["status"] = "SUCCESSFUL"
        FlutterwavePayout.objects.update(updated_at=timezone.now() - timedelta(hours=2))
        err = StringIO()
        call_command("check_flutterwave_payouts", stdout=StringIO(), stderr=err)
        self.assertIn(f"Check {b.reference} in the Flutterwave dashboard", err.getvalue())
        self.assertEqual((self.get(a).status, self.get(b).status, len(self.posts)), ("paid", "unknown", 2))  # b was not resent

    def test_bad_input_is_refused(self):
        for amount, phone, network in [(0, "256772123456", "MTN"), (500.5, "256772123456", "MTN"), (True, "256772123456", "MTN"),
                                       (500, "0772123456", "MTN"), (500, "256772123456", "MPESA")]:
            with self.assertRaises(ValueError):
                services.create(amount, phone, network, "x", "x")
        self.assertNotEqual(self.payout().reference, self.payout().reference)
python manage.py test flutterwave_payouts

To try the requests by hand first, use the Payout Request Builder.

Common errors and fixes

Every status and error type, with what your code should do, is in the Flutterwave error reference.

Symptom Likely cause Fix
Transfer refused with a balance message Your Flutterwave balance is too low Fund the balance before a payout run
Payout failed with a wallet message The number isn’t an active wallet on that network, or the wrong operator code Check the number and account_bank
403 on the webhook CSRF blocks the view Keep @csrf_exempt on the webhook view
401 on the webhook FLW_SECRET_HASH differs from the dashboard, or is shorter than 32 characters Use exactly the same long value in both places
Payout stays unknown No response, and no webhook yet Wait for the webhook; check the dashboard. Never resend with a new reference
review The transfer’s amount or currency differs from the payout Look at it in the dashboard

Going to production

  • Complete Flutterwave’s business verification, switch to your live secret key, and set a new live secret hash.
  • Protect the code that creates payouts. Anyone who can call it can send your money. Require an authenticated admin, set limits per payout and per day, and log who created each one.
  • Send one small live payout to an MTN number and one to an Airtel number, to confirm the operator codes.
  • Reconcile daily against your Flutterwave balance history, especially payouts that went through unknown.

Comparing payout APIs? See mobile money payouts. Want this done for you, with MTN MoMo, M-Pesa B2C and Paystack payouts too? The Django starter kit includes them.

Using Laravel? See Flutterwave payouts in Laravel.

Frequently asked questions

How do I send money to an MTN or Airtel wallet with Flutterwave in Django?

POST /v3/transfers with account_bank set to the mobile money operator code, account_number set to the number with its country code (256772123456), amount, currency UGX, beneficiary_name and your own reference, saved in your database first. Flutterwave answers "Transfer Queued Successfully" with status NEW; the result comes later.

My Flutterwave transfer request timed out. Should Django send it again?

No. Flutterwave may have created it, and you don't have its id to check. Mark the payout unknown and wait for the transfer.completed webhook, which carries your reference and the transfer id, or check the dashboard before deciding anything.

Why does my Flutterwave webhook view return 403 in Django?

CSRF protection. Flutterwave can't send a CSRF token, so mark the webhook view with @csrf_exempt and compare the verif-hash header with your secret hash instead.

Is the Flutterwave transfer webhook only sent for successful transfers?

No. Flutterwave sends transfer.completed both when a transfer succeeds and when it fails. Look the transfer up with GET /v3/transfers/{id} and settle from that.

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.