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
FlutterwavePayoutmodel and aservices.pythat never sends the same payout twice, even after a timeout, a crash or a double click, - a webhook view that checks
verif-hashand settles payouts only from the looked-up transfer, - a
check_flutterwave_payoutscommand 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
- You save the payout with your own
reference, then callPOST /v3/transfers. - Flutterwave answers
"Transfer Queued Successfully"withdata.idand statusNEW. That is not a payout yet. - The status moves through
PENDINGtoSUCCESSFULorFAILED. Flutterwave sends atransfer.completedwebhook either way. - 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
- In your Flutterwave dashboard, switch to test mode, copy the test secret key, and fund the test balance you’ll pay out from.
- In the webhook settings, set your webhook URL and a long random secret hash (32+ characters), and turn on retries.
- 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:
- Create the reference once, and save it before sending. It identifies the payout in the webhook.
- Claim before sending. The conditional
UPDATE ... WHERE status = 'new'lets only one request or worker send a payout. - Never resend automatically. An
unknownor stucksendingpayout 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. - 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.
- Final means final.
paid,failedandreviewnever 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.