Skip to content

Flutterwave · Python / Django

Flutterwave Mobile Money Uganda in Django: MTN and Airtel

Accept MTN and Airtel mobile money in Uganda with Flutterwave in Django 5.2 and 6.x: charges, verif-hash webhooks, verified settlement and tests.

Published

Skip the boilerplate → Paste a webhook into the Flutterwave Webhook Checker to check its verif-hash and get the verify request.

This guide accepts MTN Mobile Money and Airtel Money in Uganda through Flutterwave’s v3 API, in a Django app. You’ll build:

  • a flutterwave app with a payments model that is saved before each charge,
  • a small requests client: phone formatting, network detection, the charge and verification by your own reference,
  • a confirm_payment function 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

  1. In your Flutterwave dashboard, switch to test mode and copy the test secret key.
  2. In the webhook settings, set the URL to https://your-domain/webhooks/flutterwave/ and a long random secret hash. Generate one with python -c "import secrets; print(secrets.token_urlsafe(32))". Turn on webhook retries. For local development, use a tunnel such as cloudflared or ngrok.
  3. Install requests and 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.

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.