ITSkillsCenter
Blog

PayDunya en Django : encaisser via tous les wallets pas-à-pas

16 min de lecture

📍 Guide principal de la série : Mobile money en backend 2026 — Wave, Orange Money, PayDunya, CinetPay

Introduction

PayDunya est l’agrégateur dakarois historique du paiement mobile money. Son API DMP (Demande de Paiement) permet d’encaisser via Wave, Orange Money, Mixx by Yas et carte bancaire avec un seul code, en moins de 200 lignes Python. Ce tutoriel construit l’intégration en Django 5 : modèle de transaction, vue class-based pour initier le paiement, handler IPN signé, transition atomique de la commande, tests sandbox. La cible est un produit prêt pour la production qui sert un public multi-wallet et qui veut un time-to-market court.

Prérequis

  • Python 3.12 ou 3.13
  • Django 5.0+
  • Un compte business PayDunya activé sur paydunya.com
  • Accès à developers.paydunya.com avec une application créée en sandbox
  • Une base de données accessible (PostgreSQL recommandé en prod, SQLite acceptable pour le dev)
  • Un tunnel HTTPS pour le dev (Cloudflare Tunnel ou ngrok) — PayDunya doit pouvoir atteindre votre IPN
  • Niveau intermédiaire en Django (Models, Views, URLs, Migrations)
  • Temps estimé : 2 heures de code, 1 journée de validation sandbox

Étape 1 — Créer le compte PayDunya et générer les clés sandbox

L’inscription business sur paydunya.com est gratuite et prend dix minutes. Renseignez les informations habituelles d’une entreprise (raison sociale, NINEA pour le Sénégal, adresse, téléphone, email) et joignez les pièces justificatives demandées. Les clés sandbox sont délivrées immédiatement à la création du compte ; les clés de production exigent une validation manuelle qui prend en général deux à cinq jours ouvrés selon la qualité du dossier.

Une fois connecté à votre tableau de bord PayDunya, ouvrez la section « Intégrez notre API » et créez une nouvelle application. Le formulaire vous demande l’URL de votre site, l’URL de retour client après paiement (return_url) et l’URL de notification IPN (callback_url). Mettez ici les URLs de votre tunnel HTTPS pendant le développement. À la création, PayDunya vous expose quatre clés que vous noterez dans un endroit sûr : MASTER-KEY, PRIVATE-KEY, PUBLIC-KEY, et TOKEN.

La distinction entre PRIVATE-KEY (utilisée en production côté serveur) et PUBLIC-KEY (utilisable côté client si SOFTPAY est activé) est cruciale. Ne confondez pas les deux : exposer la PRIVATE-KEY côté frontend équivaut à laisser tomber votre clé maître dans la rue.

Étape 2 — Initialiser le projet Django et configurer les variables

Démarrez un projet propre avec un module dédié au paiement. La commande Django de base suffit, vous ajoutez ensuite requests pour les appels HTTP et python-dotenv pour la gestion des secrets.

python -m venv .venv
source .venv/bin/activate  # ou .venv\Scripts\activate sur Windows
pip install "django>=5.0,<6.0" requests python-dotenv
django-admin startproject ecommerce .
python manage.py startapp payments

Une fois le projet initialisé, ajoutez payments à la liste des apps dans ecommerce/settings.py, puis créez un fichier .env à la racine pour les credentials PayDunya. Le fichier .gitignore doit l’exclure dès le départ — sinon les clés sandbox finissent dans GitHub et un bot les exploite avant la fin de la journée.

# ecommerce/settings.py — extrait
from pathlib import Path
from dotenv import load_dotenv
import os

load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "payments",
]

PAYDUNYA = {
    "MODE": os.getenv("PAYDUNYA_MODE", "test"),
    "MASTER_KEY": os.getenv("PAYDUNYA_MASTER_KEY"),
    "PRIVATE_KEY": os.getenv("PAYDUNYA_PRIVATE_KEY"),
    "TOKEN": os.getenv("PAYDUNYA_TOKEN"),
    "STORE_NAME": os.getenv("PAYDUNYA_STORE_NAME", "Boutique"),
    "RETURN_URL": os.getenv("PAYDUNYA_RETURN_URL"),
    "CALLBACK_URL": os.getenv("PAYDUNYA_CALLBACK_URL"),
}

Le mode test pointe sur le sandbox PayDunya (https://app.paydunya.com/sandbox-api/v1/), le mode live pointe sur la production (https://app.paydunya.com/api/v1/). On centralise ce choix dans une seule variable pour éviter qu’un environnement de pré-production ne pointe par erreur sur la production.

Étape 3 — Créer le modèle PayDunyaTransaction

Avant d’appeler l’API, on persiste la transaction côté Django. Le modèle stocke le token PayDunya retourné après création de la facture, le statut courant (mappé sur les statuts PayDunya), un identifiant interne (reference côté merchant, jamais réutilisé), et le payload de la dernière réponse pour audit.

Créez payments/models.py :

from django.db import models
from django.utils import timezone

class PayDunyaTransaction(models.Model):
    STATUS_PENDING = "pending"
    STATUS_COMPLETED = "completed"
    STATUS_CANCELLED = "cancelled"
    STATUS_FAILED = "failed"

    STATUS_CHOICES = [
        (STATUS_PENDING, "En attente"),
        (STATUS_COMPLETED, "Confirmé"),
        (STATUS_CANCELLED, "Annulé"),
        (STATUS_FAILED, "Échec"),
    ]

    reference = models.CharField(max_length=64, unique=True, db_index=True)
    paydunya_token = models.CharField(max_length=64, unique=True, null=True, blank=True)
    amount_xof = models.PositiveIntegerField()
    description = models.CharField(max_length=255)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
    last_response = models.JSONField(default=dict, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    confirmed_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        indexes = [
            models.Index(fields=["status", "created_at"]),
        ]

    def mark_completed(self, response):
        self.status = self.STATUS_COMPLETED
        self.last_response = response
        self.confirmed_at = timezone.now()
        self.save(update_fields=["status", "last_response", "confirmed_at"])

Lancez la migration avec python manage.py makemigrations payments && python manage.py migrate. La table est maintenant prête. L’index composite status + created_at accélère le scan périodique qu’on mettra en place plus tard pour récupérer les transactions bloquées en pending.

Étape 4 — Implémenter le service PayDunya

Le service centralise les appels à l’API PayDunya. On l’isole pour pouvoir le mocker en test et le réutiliser depuis n’importe quelle vue ou commande de management. La méthode create_invoice crée la facture et retourne l’URL de redirection.

# payments/services.py
import requests
from django.conf import settings
from django.utils.crypto import get_random_string
from .models import PayDunyaTransaction

class PayDunyaService:
    @property
    def base_url(self):
        if settings.PAYDUNYA["MODE"] == "live":
            return "https://app.paydunya.com/api/v1"
        return "https://app.paydunya.com/sandbox-api/v1"

    def _headers(self):
        return {
            "Content-Type": "application/json",
            "PAYDUNYA-MASTER-KEY": settings.PAYDUNYA["MASTER_KEY"],
            "PAYDUNYA-PRIVATE-KEY": settings.PAYDUNYA["PRIVATE_KEY"],
            "PAYDUNYA-TOKEN": settings.PAYDUNYA["TOKEN"],
        }

    def create_invoice(self, amount_xof: int, description: str) -> PayDunyaTransaction:
        reference = get_random_string(24)
        tx = PayDunyaTransaction.objects.create(
            reference=reference,
            amount_xof=amount_xof,
            description=description[:255],
        )
        payload = {
            "invoice": {
                "total_amount": amount_xof,
                "description": description[:255],
            },
            "store": {"name": settings.PAYDUNYA["STORE_NAME"]},
            "actions": {
                "return_url": settings.PAYDUNYA["RETURN_URL"],
                "callback_url": settings.PAYDUNYA["CALLBACK_URL"],
                "cancel_url": settings.PAYDUNYA["RETURN_URL"],
            },
            "custom_data": {"reference": reference},
        }
        resp = requests.post(
            f"{self.base_url}/checkout-invoice/create",
            json=payload,
            headers=self._headers(),
            timeout=20,
        )
        resp.raise_for_status()
        data = resp.json()
        if data.get("response_code") != "00":
            tx.status = PayDunyaTransaction.STATUS_FAILED
            tx.last_response = data
            tx.save(update_fields=["status", "last_response"])
            raise RuntimeError(f"PayDunya error: {data.get('response_text')}")
        tx.paydunya_token = data["token"]
        tx.last_response = data
        tx.save(update_fields=["paydunya_token", "last_response"])
        return tx

    def confirm(self, token: str) -> dict:
        resp = requests.get(
            f"{self.base_url}/checkout-invoice/confirm/{token}",
            headers=self._headers(),
            timeout=15,
        )
        resp.raise_for_status()
        return resp.json()

Trois choix de design valent l’explication. La reference est un identifiant aléatoire de 24 caractères généré côté Django et embarqué dans custom_data — PayDunya nous le renverra dans l’IPN, ce qui nous permet de retrouver la transaction même si le token PayDunya est manquant. Le timeout=20 borne la latence : au-delà, on échoue plutôt que de bloquer un thread Django pendant une minute. Le response_code PayDunya est retourné en chaîne ("00" pour succès, autres valeurs pour erreur), pas en code HTTP — d’où la double vérification (raise_for_status() pour les erreurs HTTP, puis comparaison du response_code).

Étape 5 — Implémenter la vue d’initiation du paiement

La vue accepte un POST avec le montant et la description, appelle le service, redirige vers l’URL PayDunya. On utilise une class-based view (View de Django) pour rester explicite — la LoginRequiredMixin n’est pas indispensable ici mais quasi systématique en pratique.

# payments/views.py
from django.http import HttpResponseRedirect
from django.views.generic import View
from django.shortcuts import render, get_object_or_404
from .services import PayDunyaService
from .models import PayDunyaTransaction

class InitiatePaymentView(View):
    def post(self, request):
        amount = int(request.POST.get("amount", 0))
        description = request.POST.get("description", "Commande")
        if amount < 100:
            return render(request, "payments/error.html", {"msg": "Montant minimum 100 XOF"})
        try:
            tx = PayDunyaService().create_invoice(amount, description)
        except RuntimeError as e:
            return render(request, "payments/error.html", {"msg": str(e)})
        redirect_url = tx.last_response.get("response_text")
        if not redirect_url:
            return render(request, "payments/error.html", {"msg": "URL manquante"})
        return HttpResponseRedirect(redirect_url)


class PaymentReturnView(View):
    def get(self, request):
        token = request.GET.get("token")
        if not token:
            return render(request, "payments/return.html", {"status": "unknown"})
        tx = get_object_or_404(PayDunyaTransaction, paydunya_token=token)
        if tx.status == PayDunyaTransaction.STATUS_PENDING:
            data = PayDunyaService().confirm(token)
            if data.get("status") == "completed":
                tx.mark_completed(data)
        return render(request, "payments/return.html", {"tx": tx})

Le minimum de 100 XOF est arbitraire mais reflète le seuil pratique des wallets pour éviter les transactions de 1 ou 2 XOF qui coûtent plus en frais qu’en valeur. Sur le retour client, on rappelle l’API PayDunya pour récupérer le statut courant — le confirm interroge la facture par son token et retourne completed, pending ou cancelled. Cette double vérification (callback + pull au retour) compense la latence éventuelle de l’IPN.

Câblez ces vues dans payments/urls.py :

from django.urls import path
from .views import InitiatePaymentView, PaymentReturnView, IPNView

app_name = "payments"
urlpatterns = [
    path("initiate/", InitiatePaymentView.as_view(), name="initiate"),
    path("return/", PaymentReturnView.as_view(), name="return"),
    path("ipn/", IPNView.as_view(), name="ipn"),
]

Et dans ecommerce/urls.py, ajoutez path("payments/", include("payments.urls")). Vos URLs de RETURN_URL et CALLBACK_URL côté .env doivent pointer respectivement sur /payments/return/ et /payments/ipn/.

Étape 6 — Sécuriser le handler IPN

L’IPN PayDunya arrive en POST sur votre callback_url. Le payload contient le statut final, le token de la facture, et les données de la transaction. PayDunya ne signe pas nativement l’IPN avec HMAC ; la vérification recommandée est de rappeler PayDunya après réception pour confirmer le statut — c’est ce qui garantit que l’IPN reçu n’est pas forgé par un attaquant qui aurait deviné votre URL.

# payments/views.py — suite
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.http import JsonResponse
from django.db import transaction as db_transaction
import logging

logger = logging.getLogger(__name__)

@method_decorator(csrf_exempt, name="dispatch")
class IPNView(View):
    def post(self, request):
        token = request.POST.get("data[invoice][token]") or request.POST.get("token")
        if not token:
            return JsonResponse({"error": "missing token"}, status=400)

        confirmed = PayDunyaService().confirm(token)
        if confirmed.get("response_code") != "00":
            logger.warning("IPN reçu mais confirm a échoué", extra={"token": token})
            return JsonResponse({"error": "confirm failed"}, status=400)

        with db_transaction.atomic():
            try:
                tx = PayDunyaTransaction.objects.select_for_update().get(
                    paydunya_token=token
                )
            except PayDunyaTransaction.DoesNotExist:
                logger.error("IPN pour transaction inconnue", extra={"token": token})
                return JsonResponse({"error": "unknown"}, status=404)

            if tx.status == PayDunyaTransaction.STATUS_COMPLETED:
                return JsonResponse({"received": True, "dedup": True})

            paydunya_status = confirmed.get("status")
            if paydunya_status == "completed":
                tx.mark_completed(confirmed)
            elif paydunya_status == "cancelled":
                tx.status = PayDunyaTransaction.STATUS_CANCELLED
                tx.last_response = confirmed
                tx.save(update_fields=["status", "last_response"])

        return JsonResponse({"received": True})

Quatre points méritent attention. Le décorateur csrf_exempt est obligatoire — PayDunya ne connaît pas votre token CSRF, et sans cette exemption Django rejette le POST. Le select_for_update() pose un verrou en base pour empêcher deux IPN concurrents de transitionner deux fois la même transaction (le rejeu par PayDunya peut arriver). Le rappel à confirm(token) côté serveur est ce qui valide que l’IPN n’est pas forgé : un attaquant qui POST sur votre /ipn/ ne sait pas répondre à un appel PayDunya en retour. La déduplication par état (if tx.status == STATUS_COMPLETED) garantit que les effets métier ne se déclenchent qu’une seule fois même si l’IPN est rejoué.

Le logger.warning et logger.error sont essentiels en production — sans logs, vous ne saurez pas si un IPN a échoué et un client paiera sans voir sa commande validée.

Étape 7 — Tester en sandbox de bout en bout

Le test final exige le tunnel HTTPS pour que PayDunya atteigne votre IPN. Lancez Django et le tunnel en parallèle.

python manage.py runserver 0.0.0.0:8000 &
cloudflared tunnel --url http://localhost:8000

Récupérez l’URL Cloudflare générée et mettez-la dans votre .env comme PAYDUNYA_RETURN_URL=https://xxx.trycloudflare.com/payments/return/ et PAYDUNYA_CALLBACK_URL=https://xxx.trycloudflare.com/payments/ipn/. Mettez à jour les URLs côté tableau de bord PayDunya sandbox également — sinon les anciennes URLs sont utilisées et vos tests échouent silencieusement.

Soumettez un paiement de 1 000 XOF avec une description quelconque depuis votre formulaire. Vous êtes redirigé sur le checkout PayDunya sandbox, qui vous laisse choisir un wallet de test (Wave, Orange Money, Mixx by Yas) et simuler la confirmation. Au retour, votre vue PaymentReturnView récupère le statut et l’affiche. Quelques secondes plus tard, l’IPN arrive et la transaction passe à completed en base si ce n’était pas déjà fait. Vérifiez avec python manage.py shell que la PayDunyaTransaction correspondante est bien à completed et que confirmed_at est rempli.

Erreurs fréquentes

Erreur Cause Solution
response_code "01" au create Mode test/live pas en cohérence avec les clés Vérifier que PAYDUNYA_MODE correspond aux clés (sandbox ou prod)
IPN reçu mais transaction non mise à jour paydunya_token pas stocké à la création Vérifier le code de create_invoice qui doit save() après réception du token
Doublon de mise à jour à chaque IPN rejoué Pas de check sur l’état déjà completed Ajouter if tx.status == STATUS_COMPLETED: return dedup
403 sur l’IPN csrf_exempt non appliqué Décorateur @method_decorator(csrf_exempt, name="dispatch") au-dessus de la classe
confirm() retourne 404 Token inconnu côté PayDunya, parfois après expiration Logger le token, ne pas planter, retourner 200 pour acquitter l’IPN
Transaction PayDunya OK mais commande Django pas validée Effets métier déclenchés en dehors de la transaction atomic Encapsuler la validation de commande dans le même with db_transaction.atomic()
Montant rejeté en sandbox Devise pas en XOF ou montant en flottant Forcer int(amount), devise XOF par défaut, pas de centimes en mobile money

Tutoriels frères

FAQ

PayDunya prend-il une commission sur le paiement ?

Oui. Le tarif marchand standard est public sur paydunya.com et tourne typiquement autour de 1 à 3 % du montant selon la méthode de paiement et le volume mensuel. La commission est prélevée à la source — vous recevez sur votre wallet PayDunya le montant net.

Comment retirer l’argent de mon compte PayDunya ?

Le tableau de bord PayDunya propose un retrait vers votre compte bancaire ou vers un wallet mobile money. Le délai standard est de 24 à 72 heures après la demande, plus rapide pour les comptes avec un historique établi.

SOFTPAY est-il accessible à tout le monde ?

Non. SOFTPAY (paiement carte direct sans redirection) exige une certification PCI-DSS de votre entreprise. La plupart des marchands utilisent DMP avec redirection, qui n’a pas cette contrainte parce que la collecte carte se fait sur les serveurs PayDunya.

Que faire si le client paie mais l’IPN est perdu ?

Implémentez une commande de management qui scanne les PayDunyaTransaction en pending depuis plus de 10 minutes et appelle PayDunyaService().confirm(tx.paydunya_token) pour récupérer le statut courant. Lancez cette commande toutes les 5 minutes via cron.

PayDunya supporte-t-il la facturation récurrente ?

PayDunya expose une API CRM qui inclut des fonctionnalités de facturation. Pour de la souscription/abonnement, l’usage le plus propre reste de stocker le numéro de téléphone du client après une première transaction réussie et de relancer manuellement à chaque échéance — la déduction automatique sur mobile money n’est pas couverte sans accord spécifique avec l’opérateur.

Comment passer en production ?

Une fois votre compte business validé et les pièces déposées, PayDunya active vos clés live. Vous changez PAYDUNYA_MODE=live dans .env, vous remplacez les clés sandbox par les clés live, et vous mettez à jour RETURN_URL et CALLBACK_URL avec votre domaine de production. Testez avec un montant minimal (100-500 XOF) pour valider que le flux production fonctionne avant de basculer tout le trafic.

Pour aller plus loin

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité