Skip to content

Paystack · Django

Paystack Mobile Money Payouts in Django: Ghana and Kenya Transfers

Send payouts to MTN, Telecel, AT and M-Pesa wallets with the Paystack Transfers API in Django, never paying twice, with reversals handled and tests.

Published

Skip the boilerplate → Paste a transfer webhook into the Paystack Webhook Checker to check its signature and see what it means.

This guide sends money from your Paystack balance to mobile money wallets in Ghana (MTN, Telecel, AT) and Kenya (M-Pesa) from a Django app, with Paystack’s Transfers API: refunds, seller payouts, salaries. You’ll build:

  • a client.py: save a recipient, start a transfer, look one up by your reference,
  • a PaystackPayout model and a services.py that never pays twice, even after a timeout, a crash or a double click, and handles reversals,
  • a webhook view that checks x-paystack-signature and settles payouts only from the looked-up transfer,
  • a check_paystack_payouts command that settles payouts and safely resends only the ones Paystack never received,
  • tests that run every case without calling Paystack.

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 Paystack’s transfer documentation, checked in November 2026. The Node.js version of this guide has the same design. To receive mobile money, see Paystack mobile money in Django.

How a payout works

  1. Once per customer: create a transfer recipient for their wallet and store the recipient_code (RCP_…).
  2. You save the payout with your own reference, then call POST /transfer.
  3. Paystack answers "Transfer has been queued", with status pending (or otp if transfers need an OTP). That is not a payout yet.
  4. Paystack sends transfer.success, transfer.failed or transfer.reversed. You confirm with GET /transfer/verify/:reference, the source of truth.
new ──send──▶ sending ──queued──▶ pending ──verify: success──▶ paid ──verify: reversed──▶ reversed
                 │                    └──verify: failed / abandoned / blocked / rejected──▶ failed
                 ├──refused, and verify finds nothing──▶ failed
                 └──timeout / 5xx──▶ unknown ──verify finds it──▶ pending / paid / failed
                                         └──verify: never seen──▶ sent again, same reference

Two facts shape the design:

  • You can look a transfer up by your own reference. So a lost response is recoverable: look it up, and if Paystack has never seen it, send it again with the same reference. Paystack’s docs say to retry with the same reference to avoid double crediting.
  • success isn’t the end. Paystack says a successful transfer “doesn’t equate to instant credit”, and a transfer can later be reversed, with the money returned to your balance.

Before you start

  1. A registered business. Paystack says only registered businesses can use transfers. A starter business must be upgraded first.
  2. Turn off OTP for API transfers. In the dashboard’s Preferences, untick Confirm transfers before sending. Otherwise every transfer waits for an OTP and is abandoned after 30 minutes.
  3. Fund your balance. It must cover the amount plus Paystack’s transfer fee.
  4. Set your webhook URL under Settings → API Keys & Webhooks. Paystack signs webhooks with your secret key; there’s no separate webhook secret.
  5. Install requests, create the app and set your secret key in the environment. Never commit it. In test mode, Paystack says transfers always succeed.
pip install "django>=5.2" requests
python manage.py startapp paystack_payouts
mkdir -p paystack_payouts/management/commands
touch paystack_payouts/management/__init__.py paystack_payouts/management/commands/__init__.py
export PAYSTACK_SECRET_KEY=sk_test_your_secret_key

Add "paystack_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

PAYSTACK = {
    "BASE_URL": "https://api.paystack.co",
    "SECRET_KEY": os.environ.get("PAYSTACK_SECRET_KEY", ""),
}

The model

# paystack_payouts/models.py
import uuid

from django.db import models


def new_reference():
    # Paystack wants lowercase letters, digits, - and _, 16 to 50 characters, and suggests a v4 UUID.
    return str(uuid.uuid4())


class PaystackPayout(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. paid is not final: Paystack can still reverse it.
    FINAL = ("failed", "reversed", "review")
    IN_FLIGHT = ("sending", "unknown", "pending")

    reference = models.CharField(max_length=50, unique=True, default=new_reference, editable=False)
    amount = models.PositiveBigIntegerField()  # subunits: pesewas for GHS, cents for KES
    currency = models.CharField(max_length=3)
    recipient_code = models.CharField(max_length=64)  # RCP_...
    reason = models.CharField(max_length=100)
    status = models.CharField(max_length=10, default="new")  # new | sending | pending | unknown | paid | failed | reversed | review
    transfer_code = models.CharField(max_length=64, blank=True)  # TRF_...
    note = 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 paystack_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. A 4xx is a refusal.

# paystack_payouts/client.py
import hashlib
import hmac
from urllib.parse import quote

import requests
from django.conf import settings


class PaystackTransferError(Exception):
    """ambiguous=True: the transfer may exist. Look it up; never send it with a new reference."""

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


def call(method, path, **kwargs):
    cfg = settings.PAYSTACK
    try:
        res = requests.request(method, cfg["BASE_URL"].rstrip("/") + path, timeout=30,
                               headers={"Authorization": f"Bearer {cfg['SECRET_KEY']}", "Accept": "application/json"}, **kwargs)
    except requests.RequestException as exc:
        raise PaystackTransferError(f"Paystack 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 PaystackTransferError(f"Paystack HTTP {res.status_code}", res.status_code, ambiguous=True,
                                    body=data if isinstance(data, dict) else {})
    return res, data


def create_recipient(name, phone, bank_code, currency):
    """Saves a mobile money wallet as a transfer recipient. Store the recipient_code with the customer."""
    res, data = call("POST", "/transferrecipient", json={
        "type": "mobile_money", "name": name, "account_number": phone, "bank_code": bank_code, "currency": currency,
    })
    code = data["data"].get("recipient_code") if isinstance(data.get("data"), dict) else None
    if res.ok and data.get("status") is True and isinstance(code, str):
        return code
    raise PaystackTransferError(f"Recipient refused: {data.get('message') or res.status_code}", res.status_code, body=data)


def initiate(payout):
    """Starts a transfer from your balance. Returns Paystack's data: transfer_code, reference, status. Not paid yet."""
    res, data = call("POST", "/transfer", json={
        "source": "balance",
        "amount": payout.amount,  # subunits
        "currency": payout.currency,
        "recipient": payout.recipient_code,
        "reason": payout.reason,
        "reference": payout.reference,  # saved before this call
    })
    if res.ok and data.get("status") is True and isinstance(data.get("data"), dict):
        return data["data"]
    raise PaystackTransferError(f"Transfer refused: {data.get('message') or res.status_code}", res.status_code, body=data)


def verify(reference):
    """The source of truth for a reference, or None if Paystack has no such transfer."""
    res, data = call("GET", f"/transfer/verify/{quote(reference, safe='')}")
    if res.ok and data.get("status") is True and isinstance(data.get("data"), dict):
        return data["data"]
    if res.status_code == 404:
        return None
    raise PaystackTransferError(f"Transfer lookup failed: {res.status_code}", res.status_code, ambiguous=True, body=data)


def signature_ok(raw, signature, secret):
    """x-paystack-signature is an HMAC SHA512 of the raw body, keyed with your secret key."""
    if not secret or not isinstance(signature, str):
        return False
    expected = hmac.new(secret.encode(), raw, hashlib.sha512).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())

Recipients. For a personal wallet, type is mobile_money, account_number is the phone number, and bank_code is the network: MTN, VOD (Telecel, formerly Vodafone) or ATL (AT, formerly AirtelTigo) in Ghana, and MPESA in Kenya. You can list the codes for your account with GET /bank?currency=GHS&type=mobile_money. Paystack’s example writes the Ghana number in local format (0551234987); the docs don’t state the format for Kenya, so this guide uses the local format there too. Confirm it with one small live payout.

Never pay twice

# paystack_payouts/services.py
import re

from django.utils import timezone

from . import client
from .models import PaystackPayout

# Bank codes from Paystack's recipient docs. Prefixes from Google libphonenumber; the longest match wins.
NETWORKS = {
    "GHS": ("233", {"24": "MTN", "25": "MTN", "53": "MTN", "54": "MTN", "55": "MTN", "59": "MTN",
                    "20": "VOD", "50": "VOD", "23": "ATL", "26": "ATL", "27": "ATL", "56": "ATL", "57": "ATL"}),
    "KES": ("254", {"11": "MPESA", "14": "MPESA", "18": "MPESA", "70": "MPESA", "71": "MPESA", "72": "MPESA",
                    "74": "MPESA", "744": None, "747": None, "757": "MPESA", "758": "MPESA", "759": "MPESA",
                    "768": "MPESA", "769": "MPESA", "79": "MPESA"}),
}
FAILED = ("failed", "abandoned", "blocked", "rejected")


def wallet(raw, currency):
    """'+233 55 123 4987' -> ('0551234987', 'MTN')."""
    if currency not in NETWORKS:
        raise ValueError("Paystack mobile money payouts are GHS or KES")
    cc, prefixes = NETWORKS[currency]
    digits = re.sub(r"\D", "", str(raw))
    if digits.startswith(cc):
        digits = digits[len(cc):]
    if digits.startswith("0"):
        digits = digits[1:]
    if not re.fullmatch(r"\d{9}", digits):
        raise ValueError("That phone number has the wrong number of digits")
    matches = sorted((p for p in prefixes if digits.startswith(p)), key=len, reverse=True)
    bank_code = prefixes[matches[0]] if matches else None
    if bank_code is None:
        raise ValueError("Enter a Ghanaian MTN, Telecel or AT number" if currency == "GHS" else "Enter a Kenyan Safaricom (M-Pesa) number")
    return "0" + digits, bank_code


def to_subunits(amount):
    """'25.50' -> 2550. Exact: no floating point."""
    m = re.fullmatch(r"(\d{1,12})(?:\.(\d{1,2}))?", str(amount).strip())
    if not m:
        raise ValueError("Amounts have at most 2 decimal places")
    return int(m.group(1)) * 100 + int((m.group(2) or "").ljust(2, "0"))


def save_recipient(name, phone, currency):
    """Saves a customer's wallet with Paystack. Store the returned RCP_ code; you need it for every payout."""
    number, bank_code = wallet(phone, currency)
    return client.create_recipient(name, number, bank_code, currency)


def create(amount, currency, recipient_code, reason):
    """Create the payout record. Its reference is fixed for life. Nothing is sent yet."""
    if currency not in ("GHS", "KES"):
        raise ValueError("Currency must be GHS or KES")
    if not re.fullmatch(r"RCP_\w+", str(recipient_code)):
        raise ValueError("Use the recipient_code (RCP_...), not a phone number")
    subunits = to_subunits(amount)
    if subunits <= 0:
        raise ValueError("Amount must be more than zero")
    return PaystackPayout.objects.create(amount=subunits, currency=currency, recipient_code=recipient_code, reason=str(reason)[:100])


def claim(payout, from_statuses, to):
    """Atomically move a payout between statuses. False when another process got there first."""
    return PaystackPayout.objects.filter(pk=payout.pk, status__in=from_statuses).update(status=to, updated_at=timezone.now()) == 1


def save(queryset, **fields):
    """queryset.update() skips auto_now, so set updated_at here: the check command waits a minute after 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 PaystackPayout.objects.filter(pk=payout.pk).exclude(status__in=PaystackPayout.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 not claim(payout, ["new"], "sending"):  # saved BEFORE the network call
        return fresh(payout)  # sent, or maybe sent: never again from here
    try:
        data = client.initiate(payout)
    except client.PaystackTransferError as exc:
        if exc.ambiguous:
            save(still_open(payout), status="unknown", note="NO_RESPONSE")  # check() looks it up
            return fresh(payout)
        # A refusal. After an earlier lost response, Paystack may already have this reference, so look first.
        try:
            transfer = client.verify(payout.reference)
        except client.PaystackTransferError:
            save(still_open(payout), status="unknown", note="NO_RESPONSE")
            return fresh(payout)
        if transfer is not None:
            return settle(payout, transfer)
        save(still_open(payout), status="failed", note=str(exc.body.get("message") or f"HTTP_{exc.status}")[:200])
        return fresh(payout)
    save(PaystackPayout.objects.filter(pk=payout.pk, status="sending"), status="pending",  # queued is not paid
         transfer_code=str(data.get("transfer_code") or "")[:64], note="NEEDS_OTP" if data.get("status") == "otp" else "")
    return fresh(payout)


def settle(payout, transfer):
    """Applies a transfer from client.verify() to its payout."""
    if transfer.get("reference") != payout.reference:
        return fresh(payout)  # not this payout
    try:
        same = int(transfer.get("amount")) == payout.amount and str(transfer.get("currency") or "").upper() == payout.currency
    except (TypeError, ValueError):
        same = False
    if not same:
        save(still_open(payout), status="review", note=f"TRANSFER {transfer.get('amount')} {transfer.get('currency')}"[:200])
        return fresh(payout)
    status = str(transfer.get("status") or "").lower()
    code = str(transfer.get("transfer_code") or payout.transfer_code)[:64]
    if status == "reversed":  # can follow a success: the money is back in your balance
        save(still_open(payout), status="reversed", transfer_code=code, note="REVERSED")
    elif status == "success":
        save(still_open(payout).exclude(status="paid"), status="paid", transfer_code=code, note="")
    elif status in FAILED:
        save(still_open(payout).exclude(status="paid"), status="failed", transfer_code=code, note=status.upper())
    else:  # pending, received, or otp (waiting for someone to approve it in the dashboard)
        save(PaystackPayout.objects.filter(pk=payout.pk, status__in=PaystackPayout.IN_FLIGHT), status="pending",
             transfer_code=code, note="NEEDS_OTP" if status == "otp" else "")
    return fresh(payout)


def check(payout):
    """Scheduled check for payouts in flight. A payout Paystack never saw is sent again, same reference."""
    if payout.status not in PaystackPayout.IN_FLIGHT:
        return payout  # a reversal of a paid payout arrives by webhook
    try:
        transfer = client.verify(payout.reference)
    except client.PaystackTransferError:
        return payout  # Paystack unreachable: try again on the next run
    if transfer is not None:
        return settle(payout, transfer)
    if claim(payout, ["sending", "unknown"], "new"):
        return send(fresh(payout))
    return fresh(payout)


def handle_webhook(body):
    """Handles a transfer.* webhook whose signature you already checked. The body only says which transfer to look up."""
    data = body.get("data") if isinstance(body.get("data"), dict) else {}
    reference = data.get("reference")
    if not str(body.get("event") or "").startswith("transfer.") or not isinstance(reference, str):
        return None  # charge.* and other events are not payouts
    payout = PaystackPayout.objects.filter(reference=reference).first()
    if payout is None:
        return None  # not ours
    transfer = client.verify(payout.reference)  # raises if Paystack is unreachable: answer 500 so it retries
    return payout if transfer is None else settle(payout, transfer)

The rules:

  1. Create the reference once, save it, then send. The model’s default makes a v4 UUID, which is what Paystack suggests.
  2. Claim before sending. claim() is a conditional UPDATE ... WHERE status IN (...). If two requests or workers race, only one changes the row, and only that one sends.
  3. Look before you conclude. A timeout leaves the payout unknown; the next check looks it up and only resends, with the same reference, if Paystack has never seen it. Even a refusal is checked first.
  4. Only the looked-up transfer counts. The webhook body only says which reference to look up; the reference, amount and currency must match, or the payout goes to review.
  5. failed, reversed and review are final. paid can only become reversed. Every update excludes final payouts, so a stale copy of the model can’t undo a result.

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

from paystack_payouts import services

recipient = services.save_recipient("Ama Mensah", "0551234987", "GHS")  # once: store it with the customer
services.send(services.create("25.50", "GHS", recipient, "Refund for order 7"))  # pending, unknown or failed

The webhook

# paystack_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  # Paystack can't send a CSRF token; the signature protects this view instead
@require_POST
def webhook(request):
    raw = request.body  # the signature covers these exact bytes
    if not client.signature_ok(raw, request.headers.get("x-paystack-signature"), settings.PAYSTACK.get("SECRET_KEY") or ""):
        return HttpResponse(status=401)
    try:
        body = json.loads(raw)
    except ValueError:
        return HttpResponse(status=200)  # signed but not JSON: nothing to do
    if not isinstance(body, dict):
        return HttpResponse(status=200)
    try:
        services.handle_webhook(body)
    except client.PaystackTransferError as exc:
        log.warning("Paystack transfer webhook not processed: %s", exc)
        return HttpResponse(status=500)  # Paystack retries
    return HttpResponse(status=200)
# paystack_payouts/urls.py
from django.urls import path

from . import views

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

Include it in your project’s urls.py with path("", include("paystack_payouts.urls")). Paystack sends every event for your account to one URL. If you already receive charge webhooks (the mobile money guide also uses webhooks/paystack/), don’t add a second route: in that view, after the signature check, also call services.handle_webhook(body). It ignores anything that isn’t transfer.*.

Settle payouts from cron

# paystack_payouts/management/commands/check_paystack_payouts.py
from datetime import timedelta

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

from paystack_payouts import services
from paystack_payouts.models import PaystackPayout


class Command(BaseCommand):
    help = "Settle Paystack payouts in flight. Run it every minute from cron."

    def handle(self, *args, **options):
        # A minute old, so we never look up a payout that is still being sent.
        due = (PaystackPayout.objects.filter(status__in=PaystackPayout.IN_FLIGHT, updated_at__lte=timezone.now() - timedelta(minutes=1))
               .order_by("id")[:100])
        counts = {}
        for payout in due:
            status = services.check(payout).status
            counts[status] = counts.get(status, 0) + 1
        needs_otp = PaystackPayout.objects.filter(status="pending", note="NEEDS_OTP").count()
        if needs_otp:
            self.stderr.write(f"{needs_otp} payout(s) wait for an OTP: turn off 'Confirm transfers before sending'.")
        self.stdout.write(", ".join(f"{n} {s}" for s, n in counts.items()) or "Nothing to check.")

Run it every minute: * * * * * cd /srv/app && .venv/bin/python manage.py check_paystack_payouts. A payout that Paystack never received is resent by check() itself, with its original reference.

Test it without calling Paystack

These tests patch requests with unittest.mock. Like the real Paystack, the fake treats a repeated reference as the same transfer. They check that a payout is saved before it’s sent and sent only once, that the webhook signature is checked and its body never trusted, that a lost response is found by its reference, that a payout Paystack never saw is resent with the same reference, that a refusal is checked before failing, that a wrong amount goes to review, and that only a reversal changes a paid payout.

# paystack_payouts/tests.py
import hashlib
import hmac
import json
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 PaystackPayout

SECRET = "sk_test_secret"
RCP = "RCP_m7ljkv8leesep7p"


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(PAYSTACK={"BASE_URL": "https://paystack.test", "SECRET_KEY": SECRET})
class PaystackPayoutTests(TestCase):
    def setUp(self):
        self.ps = {}  # transfers by reference, the way Paystack keeps them
        self.posts = []  # each POST /transfer, with our payout's status at that moment
        self.next = "ok"  # next POST /transfer: ok, otp, timeout_before, timeout_after or 400
        self.down = False  # lookups answer 503
        patcher = mock.patch("paystack_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"] == f"Bearer {SECRET}"
        path = url.removeprefix("https://paystack.test")
        if method == "POST" and path == "/transferrecipient":
            self.recipient_request = json
            return reply(201, {"status": True, "message": "Transfer recipient created successfully", "data": {"recipient_code": RCP}})
        if method == "POST" and path == "/transfer":
            ref, mode, self.next = json["reference"], self.next, "ok"
            self.posts.append({"json": json, "status": PaystackPayout.objects.get(reference=ref).status})
            if mode == 400:
                return reply(400, {"status": False, "message": "Your balance is not enough to fulfil this request"})
            if mode == "timeout_before":
                return reply(503)
            if ref not in self.ps:  # a repeated reference is the same transfer
                self.ps[ref] = {"reference": ref, "amount": json["amount"], "currency": json["currency"],
                                "status": "otp" if mode == "otp" else "pending", "transfer_code": f"TRF_{len(self.ps) + 1}"}
            if mode == "timeout_after":
                return reply(504)
            return reply(200, {"status": True, "message": "Transfer has been queued", "data": dict(self.ps[ref])})
        if method == "GET" and path.startswith("/transfer/verify/"):
            if self.down:
                return reply(503)
            t = self.ps.get(path.rsplit("/", 1)[1])
            return reply(200, {"status": True, "message": "Transfer retrieved", "data": dict(t)}) if t else \
                reply(404, {"status": False, "message": "Transfer not found"})
        raise AssertionError(f"unexpected {method} {url}")

    def payout(self, amount="25.50", currency="GHS"):
        return services.create(amount, currency, RCP, "Refund for order 7")

    def webhook(self, body, key=SECRET):
        raw = json.dumps(body).encode()
        sig = hmac.new(key.encode(), raw, hashlib.sha512).hexdigest()
        return self.client.post("/webhooks/paystack/", raw, content_type="application/json", HTTP_X_PAYSTACK_SIGNATURE=sig)

    def test_wallets_amounts_recipients_and_input_checks(self):
        self.assertEqual(services.wallet("+233 55 123 4987", "GHS"), ("0551234987", "MTN"))
        self.assertEqual(services.wallet("0201234567", "GHS")[1], "VOD")
        self.assertEqual(services.wallet("+254 710 000 000", "KES"), ("0710000000", "MPESA"))
        self.assertEqual(services.wallet("0757123456", "KES")[1], "MPESA")
        for phone, currency in [("0744123456", "KES"), ("0731234567", "KES"), ("12345", "GHS"), ("0551234987", "NGN")]:
            with self.assertRaises(ValueError, msg=phone):
                services.wallet(phone, currency)
        self.assertEqual([services.to_subunits("25.5"), services.to_subunits("100"), services.to_subunits("0.29")], [2550, 10000, 29])

        self.assertEqual(services.save_recipient("Ama Mensah", "+233 55 123 4987", "GHS"), RCP)
        self.assertEqual(self.recipient_request, {"type": "mobile_money", "name": "Ama Mensah", "account_number": "0551234987",
                                                  "bank_code": "MTN", "currency": "GHS"})
        p = self.payout()
        self.assertRegex(p.reference, r"^[a-z0-9-]{16,50}$")
        self.assertEqual((p.amount, p.status), (2550, "new"))
        self.assertNotEqual(p.reference, self.payout().reference)
        for amount, currency, recipient in [("1.234", "GHS", RCP), ("0", "GHS", RCP), ("5", "NGN", RCP), ("5", "GHS", "0551234987")]:
            with self.assertRaises(ValueError):
                services.create(amount, currency, recipient, "x")

    def test_saved_before_sending_sent_once_and_paid_only_from_verify(self):
        p = self.payout()
        sent = services.send(p)
        self.assertEqual((sent.status, sent.transfer_code), ("pending", "TRF_1"))
        services.send(PaystackPayout.objects.get(pk=p.pk))  # a double click
        stale = PaystackPayout.objects.get(pk=p.pk)
        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"], {"source": "balance", "amount": 2550, "currency": "GHS", "recipient": RCP,
                                                 "reason": "Refund for order 7", "reference": p.reference})
        self.ps[p.reference]["status"] = "success"
        self.assertEqual(self.webhook({"event": "transfer.success", "data": {"reference": p.reference}}).status_code, 200)
        self.assertEqual(PaystackPayout.objects.get(pk=p.pk).status, "paid")

    def test_the_webhook_checks_the_signature_and_never_trusts_the_body(self):
        p = services.send(self.payout())
        body = {"event": "transfer.success", "data": {"reference": p.reference, "status": "success", "amount": 2550}}
        self.assertEqual(self.webhook(body, "sk_test_wrong").status_code, 401)
        self.assertEqual(self.client.post("/webhooks/paystack/", json.dumps(body), content_type="application/json").status_code, 401)
        self.assertEqual(self.webhook(body).status_code, 200)
        self.assertEqual(PaystackPayout.objects.get(pk=p.pk).status, "pending")  # Paystack still says "pending"
        self.assertEqual(self.webhook({"event": "transfer.success", "data": {"reference": "not-ours"}}).status_code, 200)
        self.ps[p.reference]["status"] = "success"
        self.assertEqual(self.webhook({"event": "charge.success", "data": {"reference": p.reference}}).status_code, 200)
        self.assertEqual(PaystackPayout.objects.get(pk=p.pk).status, "pending")  # charge events aren't payouts
        with override_settings(PAYSTACK={"BASE_URL": "https://paystack.test", "SECRET_KEY": ""}):
            self.assertEqual(self.webhook(body, "").status_code, 401)  # no key configured: refuse everything

    def test_a_different_amount_currency_or_reference_is_not_paid(self):
        p = services.send(self.payout())
        self.assertEqual(services.settle(p, {**self.ps[p.reference], "reference": "someone-else", "status": "success"}).status, "pending")
        self.ps[p.reference].update(status="success", amount=255)
        self.assertEqual(services.check(p).status, "review")
        q = services.send(self.payout())
        self.ps[q.reference].update(status="success", currency="KES")
        self.assertEqual(services.check(q).status, "review")
        self.ps[q.reference]["currency"] = "GHS"
        self.assertEqual(services.settle(q, self.ps[q.reference]).status, "review")  # final

    def test_failures_fail_otp_waits_and_only_a_reversal_changes_a_paid_payout(self):
        for status in ("failed", "abandoned", "blocked", "rejected"):
            p = services.send(self.payout())
            self.ps[p.reference]["status"] = status
            done = services.check(p)
            self.assertEqual((done.status, done.note), ("failed", status.upper()))
        self.next = "otp"
        p = services.send(self.payout())
        self.assertEqual((p.status, p.note), ("pending", "NEEDS_OTP"))
        q = services.send(self.payout())
        self.ps[q.reference]["status"] = "otp"
        self.assertEqual(services.check(q).note, "NEEDS_OTP")

        self.ps[p.reference]["status"] = "success"
        self.assertEqual(services.check(p).status, "paid")
        for stale in ("pending", "failed"):  # a stale answer can't un-pay it
            self.ps[p.reference]["status"] = stale
            self.assertEqual(services.settle(p, self.ps[p.reference]).status, "paid")
        self.ps[p.reference]["status"] = "reversed"  # but a reversal can
        self.assertEqual(self.webhook({"event": "transfer.reversed", "data": {"reference": p.reference}}).status_code, 200)
        self.assertEqual(PaystackPayout.objects.get(pk=p.pk).status, "reversed")
        self.ps[p.reference]["status"] = "success"
        self.webhook({"event": "transfer.success", "data": {"reference": p.reference}})
        self.assertEqual(PaystackPayout.objects.get(pk=p.pk).status, "reversed")

    def test_a_timeout_after_paystack_created_it_is_found_by_reference_and_never_sent_twice(self):
        self.next = "timeout_after"
        p = services.send(self.payout())
        self.assertEqual((p.status, p.note), ("unknown", "NO_RESPONSE"))
        self.ps[p.reference]["status"] = "success"
        self.assertEqual(services.check(p).status, "paid")
        self.assertEqual(len(self.posts), 1)

    def test_a_timeout_before_paystack_saw_it_resends_with_the_same_reference(self):
        self.next = "timeout_before"
        p = services.send(self.payout())
        self.assertEqual(p.status, "unknown")
        again = services.check(p)
        self.assertEqual((again.status, len(self.posts), list(self.ps)), ("pending", 2, [p.reference]))
        services.check(again)
        self.assertEqual((len(self.posts), len(self.ps)), (2, 1))

        crashed = self.payout()  # the process died during the call
        PaystackPayout.objects.filter(pk=crashed.pk).update(status="sending")
        self.assertEqual(services.check(PaystackPayout.objects.get(pk=crashed.pk)).status, "pending")
        self.assertEqual(len(self.ps), 2)

    def test_a_pending_payout_is_never_resent_even_if_a_lookup_misses_it(self):
        p = services.send(self.payout())
        del self.ps[p.reference]
        self.assertEqual(services.check(p).status, "pending")
        self.assertEqual(len(self.posts), 1)

    def test_a_refusal_fails_only_when_paystack_has_no_transfer_under_the_reference(self):
        self.next = 400
        p = services.send(self.payout())
        self.assertEqual((p.status, p.note), ("failed", "Your balance is not enough to fulfil this request"))

        q = self.payout()  # it went through on an earlier try whose answer we lost
        self.ps[q.reference] = {"reference": q.reference, "amount": 2550, "currency": "GHS", "status": "success", "transfer_code": "TRF_x"}
        self.next = 400
        self.assertEqual(services.send(q).status, "paid")

        self.next, self.down = 400, True
        self.assertEqual(services.send(self.payout()).status, "unknown")

    def test_an_unreachable_paystack_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({"event": "transfer.success", "data": {"reference": p.reference}}).status_code, 500)
        self.assertEqual(PaystackPayout.objects.get(pk=p.pk).status, "pending")

    def test_the_command_settles_payouts_in_flight_and_leaves_recent_ones(self):
        a = services.send(self.payout())
        self.next = "timeout_before"
        b = services.send(self.payout())
        self.ps[a.reference]["status"] = "success"
        call_command("check_paystack_payouts", stdout=StringIO())
        self.assertEqual((PaystackPayout.objects.get(pk=a.pk).status, PaystackPayout.objects.get(pk=b.pk).status), ("pending", "unknown"))
        PaystackPayout.objects.update(updated_at=timezone.now() - timedelta(minutes=2))
        call_command("check_paystack_payouts", stdout=StringIO(), stderr=StringIO())
        self.assertEqual((PaystackPayout.objects.get(pk=a.pk).status, PaystackPayout.objects.get(pk=b.pk).status), ("paid", "pending"))
python manage.py test paystack_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 Paystack error reference.

Symptom Likely cause Fix
Every transfer is refused The business isn’t registered, or transfers aren’t enabled on the account Complete Paystack’s business verification
Payouts stay pending with NEEDS_OTP OTP is still on Untick Confirm transfers before sending in Preferences
failed with a balance message The balance can’t cover the amount plus the fee Fund the balance before a payout run
Recipient refused Wrong bank_code for the number, or wrong currency Check the network; list codes with GET /bank?currency=GHS&type=mobile_money
403 on the webhook CSRF blocks the view Keep @csrf_exempt on the webhook view
401 on the webhook The secret key is from the other mode, or the body was changed before the check Use the secret key of the same mode (test or live); check request.body
review The transfer’s amount or currency differs from the payout Look at it in the dashboard
A paid payout becomes reversed The wallet couldn’t receive the money The money is back in your balance; contact the recipient, then create a new payout

Going to production

  • Switch to your live secret key (sk_live_…). It also signs the live webhooks.
  • 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.
  • Keep OTP off only if those controls exist. Paystack also offers a transfer approval URL that your server answers to approve or reject each transfer; if it doesn’t answer in time, the transfer is blocked.
  • Send one small live payout per network you’ll use, to confirm the phone format, especially in Kenya.
  • Reconcile daily against your Paystack 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 Flutterwave payouts too? The Django starter kit includes them.

Using Laravel? See Paystack payouts in Laravel.

Frequently asked questions

How do I send money to a mobile money wallet with Paystack in Django?

Create a transfer recipient once (POST /transferrecipient with type mobile_money, the phone number as account_number, and bank_code MTN, VOD or ATL in Ghana, or MPESA in Kenya) and store its recipient_code. Then POST /transfer with source balance, the amount in subunits, the recipient_code, the currency and your own reference, saved in your database first.

How do I stop Django from sending the same Paystack transfer twice?

Save the payout with its reference before calling Paystack, then claim it with a conditional update: PaystackPayout.objects.filter(pk=..., status="new").update(status="sending"). Only the caller whose update changed a row sends it. After a timeout, look the transfer up by its reference and only resend, with the same reference, if Paystack has never seen it.

Why does my Paystack webhook view return 403 in Django?

CSRF protection. Paystack can't send a CSRF token, so mark the webhook view with @csrf_exempt and check the x-paystack-signature header against request.body instead.

Is a "success" Paystack transfer final?

Not quite. Paystack says success doesn't equal instant credit to the customer, and a transfer can still be reversed: the money comes back to your balance and you get a transfer.reversed webhook. So a paid payout must still be able to become reversed.

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.