This guide accepts MTN Mobile Money and Airtel Money in Uganda through Flutterwave’s v3 API, in a Django app. You’ll build:
- a
flutterwaveapp with a payments model that is saved before each charge, - a small
requestsclient: phone formatting, network detection, the charge and verification by your own reference, - a
confirm_paymentfunction that settles payments once, only from verified data, - a webhook view that checks Flutterwave’s
verif-hash, and a reconcile command for webhooks that never arrive, - 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 pass on both. API facts come from Flutterwave’s public v3 documentation, checked in October 2026. The Node.js version of this guide explains the flow in more detail.
Before you start
- In your Flutterwave dashboard, switch to test mode and copy the test secret key.
- In the webhook settings, set the URL to
https://your-domain/webhooks/flutterwave/and a long random secret hash. Generate one withpython -c "import secrets; print(secrets.token_urlsafe(32))". Turn on webhook retries. For local development, use a tunnel such ascloudflaredorngrok. - Install
requestsand create the app:
pip install "django>=5.2" requests
python manage.py startapp flutterwave
Add "flutterwave" to INSTALLED_APPS, and set the environment variables (never commit them):
export FLW_SECRET_KEY=your-test-secret-key
export FLW_SECRET_HASH=the-same-secret-hash-as-your-dashboard
export FLW_REDIRECT_URL="https://your-domain/orders/thanks?ref={tx_ref}"
Settings
# 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", ""),
# Where Flutterwave sends the customer back to. {tx_ref} is replaced with the payment's reference.
"REDIRECT_URL": os.environ.get("FLW_REDIRECT_URL", ""),
}
The model
You choose the tx_ref and send it with the charge. A UUID is unique per attempt, and you know it before calling Flutterwave, so a lost response never means a lost payment.
# flutterwave/models.py
import uuid
from django.db import models
class FlutterwavePayment(models.Model):
class Status(models.TextChoices):
PENDING = "pending"
PAID = "paid"
FAILED = "failed"
REVIEW = "review" # paid, but less than the order or in another currency
tx_ref = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) # our reference, sent to Flutterwave
order_id = models.CharField(max_length=64, db_index=True)
amount = models.PositiveIntegerField() # whole shillings
currency = models.CharField(max_length=3, default="UGX")
phone = models.CharField(max_length=12) # 256772123456
network = models.CharField(max_length=6) # MTN or AIRTEL
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
flw_ref = models.CharField(max_length=64, blank=True) # Flutterwave's reference once paid
failure_reason = models.CharField(max_length=190, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.order_id} UGX {self.amount} ({self.status})"
Run python manage.py makemigrations flutterwave and python manage.py migrate.
The Flutterwave client
The client turns network problems and 5xx answers into an ambiguous error: the request may have reached Flutterwave, so the payment must stay pending and be checked. A 4xx is a clear refusal.
# flutterwave/client.py
from urllib.parse import quote
import requests
from django.conf import settings
# Uganda mobile prefixes for MTN and Airtel, from Google libphonenumber's carrier data (October 2026).
# The longest matching prefix wins: 798 and 799 are Africell, 7240 is Hamilton Telecom.
NETWORKS = {
"70": "AIRTEL", "730": "AIRTEL", "731": "AIRTEL", "732": "AIRTEL", "74": "AIRTEL", "75": "AIRTEL",
"724": "MTN", "7240": None, "737": "MTN", "76": "MTN", "77": "MTN", "78": "MTN", "79": "MTN",
"798": None, "799": None,
}
class FlutterwaveError(Exception):
"""ambiguous=True: the request may have reached Flutterwave (timeout, 5xx). Check; don't assume it failed."""
def __init__(self, message, ambiguous=False):
super().__init__(message)
self.ambiguous = ambiguous
def phone(raw):
"""'0772 123 456', '+256 772 123456' or '772123456' -> '256772123456'."""
digits = "".join(ch for ch in str(raw or "") if ch.isdigit())
if digits.startswith("00"):
digits = digits[2:]
if digits.startswith("2560"):
digits = "256" + digits[4:]
elif digits.startswith("0"):
digits = "256" + digits[1:]
elif len(digits) == 9:
digits = "256" + digits
if not (len(digits) == 12 and digits.startswith("256")):
raise ValueError("Enter a Ugandan mobile number, for example 0772 123 456.")
return digits
def network(number):
"""'MTN' or 'AIRTEL' for a number like 256772123456, or None if it is neither."""
national = number[3:]
matches = [p for p in NETWORKS if national.startswith(p)]
return NETWORKS[max(matches, key=len)] if matches else None
def _send(method, path, json=None):
cfg = settings.FLUTTERWAVE
try:
res = requests.request(
method, cfg["BASE_URL"] + path, json=json, timeout=30,
headers={"Authorization": f"Bearer {cfg['SECRET_KEY']}", "Accept": "application/json"},
)
except requests.RequestException as exc:
raise FlutterwaveError(f"Flutterwave unreachable: {exc}", ambiguous=True) from exc
if res.status_code >= 500:
raise FlutterwaveError(f"Flutterwave HTTP {res.status_code}", ambiguous=True)
try:
body = res.json()
except ValueError:
body = {}
return res, body if isinstance(body, dict) else {}
def charge(tx_ref, amount, number, net, email, redirect_url=""):
"""Starts a Uganda mobile money charge. Returns the URL to send the customer to (it may be None)."""
payload = {
"tx_ref": tx_ref, "amount": amount, "currency": "UGX",
"phone_number": number, "network": net, "email": email,
}
if redirect_url:
payload["redirect_url"] = redirect_url
res, body = _send("POST", "/v3/charges?type=mobile_money_uganda", payload)
if not res.ok or body.get("status") != "success":
raise FlutterwaveError(f"Flutterwave refused the charge: {body.get('message') or res.status_code}")
return ((body.get("meta") or {}).get("authorization") or {}).get("redirect")
def verify(tx_ref):
"""The source of truth: the transaction for our tx_ref, or None if Flutterwave has none (yet)."""
res, body = _send("GET", f"/v3/transactions/verify_by_reference?tx_ref={quote(tx_ref)}")
data = body.get("data")
return data if res.ok and body.get("status") == "success" and isinstance(data, dict) else None
Phone number format. Flutterwave’s Uganda page doesn’t say which format phone_number takes, and its examples disagree. Its mobile money transfer docs use the country code without a plus, so this guide sends 256772123456. Test mode approves payments automatically, so it can’t tell you if the format is wrong: confirm with one small live payment on MTN and one on Airtel before launch. The African Phone Number Formatter shows the network for any Ugandan number.
Confirm payments safely
The webhook and the reconcile command both end up here. It asks Flutterwave first, then locks the row and changes it only if it’s still pending, so a repeated webhook racing the reconcile command can’t settle a payment twice.
# flutterwave/services.py
import logging
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.utils import timezone
from . import client
from .models import FlutterwavePayment
logger = logging.getLogger(__name__)
def _amount(value):
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return Decimal("-1") # never enough: the payment goes to review
def confirm_payment(tx_ref):
"""Ask Flutterwave for the real status and settle the payment once. Safe to call repeatedly.
Raises client.FlutterwaveError if Flutterwave can't be reached; the payment stays pending.
"""
tx = client.verify(str(tx_ref)) # network call outside the lock
with transaction.atomic():
payment = FlutterwavePayment.objects.select_for_update().filter(tx_ref=tx_ref).first()
if payment is None or payment.status != FlutterwavePayment.Status.PENDING:
return payment # unknown, or already settled
if tx is None or str(tx.get("tx_ref")) != str(payment.tx_ref):
# No transaction yet. After an hour there never will be: nothing was charged.
if payment.created_at < timezone.now() - timedelta(hours=1):
payment.status = FlutterwavePayment.Status.FAILED
payment.failure_reason = "not_found"
payment.save(update_fields=["status", "failure_reason", "updated_at"])
return payment
status = str(tx.get("status") or "").lower()
if status == "successful":
enough = tx.get("currency") == payment.currency and _amount(tx.get("amount")) >= payment.amount
payment.status = FlutterwavePayment.Status.PAID if enough else FlutterwavePayment.Status.REVIEW
payment.flw_ref = str(tx.get("flw_ref") or "")[:64]
payment.failure_reason = "" if enough else f"paid {tx.get('amount')} {tx.get('currency')}"[:190]
payment.save(update_fields=["status", "flw_ref", "failure_reason", "updated_at"])
if enough:
logger.info("Flutterwave payment for order %s confirmed", payment.order_id)
# Fulfil the order here, after the commit: transaction.on_commit(lambda: ...)
elif status == "failed":
payment.status = FlutterwavePayment.Status.FAILED
payment.failure_reason = str(tx.get("processor_response") or "failed")[:190]
payment.save(update_fields=["status", "failure_reason", "updated_at"])
return payment # pending: a later webhook or reconcile_flutterwave settles it
| Flutterwave says | What the code does |
|---|---|
successful, right currency, amount at least the order |
Marks it paid and stores flw_ref |
successful, but less or another currency |
Marks it review for a person to look at |
failed |
Marks it failed with the network’s message |
pending |
Leaves it; a later webhook or the command settles it |
| No transaction, less than an hour old | Leaves it pending |
| No transaction after an hour | Marks it failed (not_found): nothing was charged |
| 5xx or no answer | Raises, and the payment stays pending. A server error proves nothing |
The views
Three endpoints: start a payment, let your page poll your own database, and receive Flutterwave’s webhook. The start view saves the payment before calling Flutterwave; if the call times out, the payment stays pending for the reconcile command.
# flutterwave/views.py
import hmac
import json
import logging
import uuid
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST
from . import client
from .models import FlutterwavePayment
from .services import confirm_payment
logger = logging.getLogger(__name__)
def _json(request):
try:
data = json.loads(request.body or b"{}")
except ValueError:
return {}
return data if isinstance(data, dict) else {}
@require_POST
def start_payment(request):
"""POST {order_id, amount, phone, email} -> 201 with tx_ref and the URL to send the customer to."""
data = _json(request)
order_id = str(data.get("order_id") or "").strip()[:64]
amount = data.get("amount") # take it from your order record in a real app
email = str(data.get("email") or "").strip()
try:
number = client.phone(data.get("phone"))
validate_email(email)
except (ValueError, ValidationError):
return JsonResponse({"error": "Enter a Ugandan mobile number and your email address."}, status=422)
net = client.network(number)
if not order_id or not isinstance(amount, int) or amount < 1 or net is None:
return JsonResponse({"error": "Enter an order, a whole amount, and an MTN or Airtel number."}, status=422)
# Saved BEFORE the call: if the response is lost, reconcile_flutterwave still finds it by tx_ref.
payment = FlutterwavePayment.objects.create(order_id=order_id, amount=amount, phone=number, network=net)
return_to = settings.FLUTTERWAVE.get("REDIRECT_URL", "").replace("{tx_ref}", str(payment.tx_ref))
try:
redirect = client.charge(str(payment.tx_ref), amount, number, net, email, return_to)
except client.FlutterwaveError as exc:
logger.warning("Flutterwave charge for %s: %s", payment.tx_ref, exc)
if not exc.ambiguous:
payment.status = FlutterwavePayment.Status.FAILED
payment.failure_reason = str(exc)[:190]
payment.save(update_fields=["status", "failure_reason", "updated_at"])
return JsonResponse({"error": "The payment could not be started. Please try again."}, status=502)
redirect = None # it may have gone through: leave it pending, reconcile settles it
return JsonResponse({"tx_ref": str(payment.tx_ref), "redirect_url": redirect}, status=201)
@require_GET
def payment_status(request, tx_ref):
"""Your order page polls this, not Flutterwave."""
payment = get_object_or_404(FlutterwavePayment, tx_ref=tx_ref)
return JsonResponse({"status": payment.status})
@csrf_exempt # Flutterwave can't send a CSRF token; verif-hash protects this view instead
@require_POST
def webhook(request):
expected = settings.FLUTTERWAVE.get("SECRET_HASH", "")
given = request.headers.get("verif-hash", "")
if not expected or not hmac.compare_digest(expected.encode(), given.encode()):
return JsonResponse({"error": "Invalid verif-hash"}, status=401) # not from Flutterwave
data = _json(request).get("data")
try:
tx_ref = uuid.UUID(str(data.get("tx_ref"))) if isinstance(data, dict) else None
except ValueError:
tx_ref = None
if tx_ref and FlutterwavePayment.objects.filter(tx_ref=tx_ref).exists():
try:
confirm_payment(tx_ref) # never trust the body; this asks Flutterwave
except client.FlutterwaveError as exc:
logger.warning("Flutterwave verify for %s failed: %s", tx_ref, exc)
return JsonResponse({"error": "try again"}, status=503) # Flutterwave retries if retries are on
return JsonResponse({"ok": True})
# flutterwave/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("payments/flutterwave/", views.start_payment),
path("payments/flutterwave/<uuid:tx_ref>/", views.payment_status),
path("webhooks/flutterwave/", views.webhook),
]
Include them in your project’s urls.py with path("", include("flutterwave.urls")). The start view keeps Django’s CSRF check, so call it from your own pages with the X-CSRFToken header. The response carries redirect_url: send the customer there to approve, and Flutterwave sends them back to your FLW_REDIRECT_URL, where your page polls /payments/flutterwave/<tx_ref>/.
Got a webhook you want to inspect? Paste it into the Flutterwave Webhook Checker.
Catch webhooks that never arrive
Flutterwave recommends not relying on webhooks alone. This command re-checks every payment that’s been pending for more than two minutes:
# flutterwave/management/commands/reconcile_flutterwave.py
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from flutterwave.client import FlutterwaveError
from flutterwave.models import FlutterwavePayment
from flutterwave.services import confirm_payment
class Command(BaseCommand):
help = "Re-check Flutterwave payments that are still pending."
def handle(self, *args, **options):
now = timezone.now()
pending = FlutterwavePayment.objects.filter(
status=FlutterwavePayment.Status.PENDING,
created_at__lt=now - timedelta(minutes=2),
created_at__gt=now - timedelta(days=1),
).values_list("tx_ref", flat=True)
settled = 0
for tx_ref in pending:
try:
payment = confirm_payment(tx_ref)
except FlutterwaveError as exc:
self.stderr.write(f"{tx_ref}: {exc}")
continue
if payment and payment.status != FlutterwavePayment.Status.PENDING:
settled += 1
self.stdout.write(f"Settled {settled} pending payment(s).")
Create empty __init__.py files in flutterwave/management/ and flutterwave/management/commands/, then run the command every five minutes from cron:
*/5 * * * * cd /srv/shop && /srv/shop/.venv/bin/python manage.py reconcile_flutterwave
Test it without calling Flutterwave
These tests patch requests with unittest.mock, so they run offline and in CI with no extra packages. They check the charge format, that the webhook body is never trusted, that a wrong verif-hash is refused and CSRF doesn’t block real webhooks, that short or wrong-currency payments go to review, that a settled payment never changes, and that timeouts and server errors leave payments pending.
# flutterwave/tests.py
import json
from datetime import timedelta
from io import StringIO
from unittest import mock
import requests
from django.core.management import call_command
from django.test import Client, TestCase, override_settings
from django.utils import timezone
from .client import network
from .models import FlutterwavePayment
FLW = {
"BASE_URL": "https://api.flutterwave.com",
"SECRET_KEY": "FLWSECK_TEST-secret",
"SECRET_HASH": "test-secret-hash-0123456789",
"REDIRECT_URL": "https://shop.example.com/orders/thanks?ref={tx_ref}",
}
CHARGED = {"status": "success", "message": "Charge initiated",
"meta": {"authorization": {"mode": "redirect", "redirect": "https://checkout.flutterwave.example/approve"}}}
def reply(status, body):
res = mock.Mock(status_code=status, ok=200 <= status < 300)
res.json.return_value = body
return res
@override_settings(FLUTTERWAVE=FLW)
class FlutterwaveTests(TestCase):
def setUp(self):
self.tx = None # what verify_by_reference returns; None = no such transaction
self.verify_status = 200
self.charge = (200, CHARGED)
self.calls = []
patcher = mock.patch("flutterwave.client.requests.request", side_effect=self.fake)
patcher.start()
self.addCleanup(patcher.stop)
def fake(self, method, url, json=None, headers=None, timeout=None):
self.calls.append({"method": method, "url": url, "json": json, "headers": headers})
if "/v3/charges" in url:
if isinstance(self.charge, Exception):
raise self.charge
return reply(*self.charge)
if self.verify_status >= 500:
return reply(self.verify_status, {})
if self.tx is None:
return reply(400, {"status": "error", "message": "No transaction was found", "data": None})
return reply(200, {"status": "success", "data": self.tx})
def start(self, phone="0772 123 456", amount=15000):
res = self.client.post("/payments/flutterwave/", json.dumps(
{"order_id": "1001", "amount": amount, "phone": phone, "email": "buyer@example.com"}), content_type="application/json")
self.assertEqual(res.status_code, 201, res.content)
return res.json()
def webhook(self, tx_ref, hash_="test-secret-hash-0123456789", client=None):
body = json.dumps({"event": "charge.completed", "data": {"tx_ref": str(tx_ref), "status": "successful"}})
headers = {"HTTP_VERIF_HASH": hash_} if hash_ is not None else {}
return (client or self.client).post("/webhooks/flutterwave/", body, content_type="application/json", **headers)
def successful(self, tx_ref, **overrides):
return {"id": 285959875, "tx_ref": str(tx_ref), "flw_ref": "FLW-MOCK-1", "amount": 15000,
"currency": "UGX", "status": "successful", **overrides}
def payment(self):
return FlutterwavePayment.objects.get()
def test_charge_is_sent_correctly_and_settled_from_verify_not_the_webhook(self):
out = self.start()
tx_ref = out["tx_ref"]
self.assertEqual(out["redirect_url"], "https://checkout.flutterwave.example/approve")
call = self.calls[0]
self.assertIn("/v3/charges?type=mobile_money_uganda", call["url"])
self.assertEqual(call["headers"]["Authorization"], "Bearer FLWSECK_TEST-secret")
self.assertEqual(call["json"], {
"tx_ref": tx_ref, "amount": 15000, "currency": "UGX", "phone_number": "256772123456", "network": "MTN",
"email": "buyer@example.com", "redirect_url": f"https://shop.example.com/orders/thanks?ref={tx_ref}",
})
self.tx = self.successful(tx_ref, status="pending") # the body says successful; Flutterwave doesn't
self.assertEqual(self.webhook(tx_ref).status_code, 200)
self.assertEqual(self.payment().status, "pending")
self.tx = self.successful(tx_ref)
self.webhook(tx_ref)
self.assertEqual([self.payment().status, self.payment().flw_ref], ["paid", "FLW-MOCK-1"])
self.assertEqual(self.client.get(f"/payments/flutterwave/{tx_ref}/").json(), {"status": "paid"})
def test_webhook_without_the_right_verif_hash_is_refused(self):
tx_ref = self.start()["tx_ref"]
self.tx = self.successful(tx_ref)
self.assertEqual(self.webhook(tx_ref, "wrong").status_code, 401)
self.assertEqual(self.webhook(tx_ref, None).status_code, 401)
self.assertFalse([c for c in self.calls if "verify_by_reference" in c["url"]])
self.assertEqual(self.payment().status, "pending")
def test_webhook_needs_no_csrf_token(self):
tx_ref = self.start()["tx_ref"]
self.tx = self.successful(tx_ref)
self.assertEqual(self.webhook(tx_ref, client=Client(enforce_csrf_checks=True)).status_code, 200)
self.assertEqual(self.payment().status, "paid")
def test_airtel_numbers_and_bad_numbers(self):
self.start("0752 123 456")
self.assertEqual(self.calls[0]["json"]["network"], "AIRTEL")
self.assertEqual([network("256798123456"), network("256724012345"), network("256720123456")], [None, None, None])
for phone in ("0798 123 456", "12"):
res = self.client.post("/payments/flutterwave/", json.dumps(
{"order_id": "1", "amount": 500, "phone": phone, "email": "a@b.co"}), content_type="application/json")
self.assertEqual(res.status_code, 422)
def test_short_payment_or_wrong_currency_goes_to_review(self):
for override in ({"amount": 1500}, {"currency": "KES"}, {"amount": "lots"}):
with self.subTest(override=override):
tx_ref = self.start()["tx_ref"]
self.tx = self.successful(tx_ref, **override)
self.webhook(tx_ref)
self.assertEqual(FlutterwavePayment.objects.get(tx_ref=tx_ref).status, "review")
def test_a_transaction_for_another_tx_ref_is_ignored(self):
tx_ref = self.start()["tx_ref"]
self.tx = self.successful("00000000-0000-4000-8000-000000000000")
self.webhook(tx_ref)
self.assertEqual(self.payment().status, "pending")
def test_failed_and_never_changed_again(self):
tx_ref = self.start()["tx_ref"]
self.tx = self.successful(tx_ref, status="failed", processor_response="Insufficient funds")
self.webhook(tx_ref)
self.assertEqual([self.payment().status, self.payment().failure_reason], ["failed", "Insufficient funds"])
self.tx = self.successful(tx_ref) # a late duplicate with another answer
self.webhook(tx_ref)
self.assertEqual(self.payment().status, "failed")
def test_reconcile_waits_for_a_missing_transaction_then_fails_it_after_an_hour(self):
self.start()
FlutterwavePayment.objects.update(created_at=timezone.now() - timedelta(minutes=5))
call_command("reconcile_flutterwave", stdout=StringIO())
self.assertEqual(self.payment().status, "pending")
FlutterwavePayment.objects.update(created_at=timezone.now() - timedelta(minutes=61))
call_command("reconcile_flutterwave", stdout=StringIO())
self.assertEqual([self.payment().status, self.payment().failure_reason], ["failed", "not_found"])
def test_server_errors_prove_nothing(self):
tx_ref = self.start()["tx_ref"]
FlutterwavePayment.objects.update(created_at=timezone.now() - timedelta(hours=2))
self.verify_status = 502
call_command("reconcile_flutterwave", stdout=StringIO(), stderr=StringIO())
self.assertEqual(self.payment().status, "pending")
self.assertEqual(self.webhook(tx_ref).status_code, 503) # so Flutterwave retries the webhook
def test_charge_timeout_stays_pending_and_reconcile_settles_it(self):
self.charge = requests.Timeout("read timed out")
out = self.start()
self.assertIsNone(out["redirect_url"])
self.assertEqual(self.payment().status, "pending")
self.tx = self.successful(out["tx_ref"])
FlutterwavePayment.objects.update(created_at=timezone.now() - timedelta(minutes=5))
call_command("reconcile_flutterwave", stdout=StringIO())
self.assertEqual(self.payment().status, "paid")
def test_a_clear_refusal_fails_at_once(self):
self.charge = (400, {"status": "error", "message": "Invalid phone number"})
res = self.client.post("/payments/flutterwave/", json.dumps(
{"order_id": "1", "amount": 15000, "phone": "0772123456", "email": "a@b.co"}), content_type="application/json")
self.assertEqual(res.status_code, 502)
self.assertEqual(self.payment().status, "failed")
Run them with python manage.py test flutterwave.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Webhooks get 403 |
CSRF blocks the view | Keep @csrf_exempt on the webhook view only |
Webhooks get 401 |
FLW_SECRET_HASH differs from the dashboard |
Use exactly the same value in both places |
401 from Flutterwave’s API |
Test key in live mode, or the reverse | Use the secret key for the mode you’re in |
Payments stay pending |
No webhook arrived and the command isn’t scheduled | Check the dashboard webhook URL, and add the cron entry |
review |
Paid amount or currency differs from the order | Check the transaction in the dashboard and decide: give value or refund |
Going to production
- Complete Flutterwave’s business verification, switch to your live secret key, and set a new live secret hash in both places.
- Load the amount from your order on the server. Never take it from the request body, as the example does for brevity.
- Use Postgres or MySQL in production, so
select_for_update()really locks the row across workers. - Send one small live payment on MTN and one on Airtel before launch, to confirm the phone format and your webhook.
Prefer to go direct? See MTN MoMo Collections in Django. Kenya? See M-Pesa STK push in Django.
Frequently asked questions
Why does my Django Flutterwave webhook return 403 Forbidden?
Django's CSRF middleware blocks it: Flutterwave can't send a CSRF token. Mark the webhook view with @csrf_exempt and check the verif-hash header instead, as this guide does.
How do I verify a Flutterwave webhook in Django?
Compare the verif-hash header with the secret hash from your dashboard using hmac.compare_digest, and answer 401 if they differ. Then confirm the payment with verify_by_reference (or the verify-by-id endpoint) and use that answer, not the webhook body.
What should the webhook return when Flutterwave can't be reached?
Anything but 200, for example 503. Flutterwave treats it as a failed delivery and, if retries are on in your dashboard, retries 3 times, 30 minutes apart. The reconcile command settles the payment either way.
How do I test Flutterwave in Django without real money?
Patch requests with unittest.mock in your tests, as this guide does, and use your test mode secret key for manual runs. Flutterwave says Uganda mobile money payments in test mode are authorised automatically after a few seconds.