ITSkillsCenter
Business Digital

Flutterwave Standard Checkout en Django : intégration pas-à-pas

14 دقائق للقراءة

📍 Lecture connexe : Stripe, Paystack, Flutterwave et Wave en 2026 : intégrer un processeur de paiement — pour la vue d’ensemble du paysage paiement.

Flutterwave a la couverture pays la plus large du marché africain en 2026 : 34 pays acceptés en encaissement et plus de 150 devises supportées selon la documentation développeur officielle. Pour un site qui vend à des clients répartis sur plusieurs marchés, son Standard Checkout offre un avantage rare : un seul appel API qui présente automatiquement les méthodes de paiement adaptées au pays du client. Carte au Nigeria, mobile money en Côte d’Ivoire, USSD au Ghana, Apple Pay en Afrique du Sud — tout passe par le même endpoint avec un routage interne. Ce tutoriel construit pas à pas une intégration Django de zéro, depuis la création du projet jusqu’à la vérification du paiement par webhook.

Prérequis

  • Python 3.12 ou supérieur, vérifier avec python --version
  • Django 5.1 ou supérieur, installé via pip install Django
  • Compte Flutterwave en mode test, créé sur app.flutterwave.com/signup
  • Une URL HTTPS publique (Cloudflare Tunnel, ngrok ou domaine de staging) pour les webhooks
  • Niveau attendu : intermédiaire — vous avez déjà créé un projet Django
  • Temps estimé : environ 80 minutes

Étape 1 — Comprendre le flux Standard Checkout

Le Standard Checkout fonctionne en cinq temps. Premier temps, le serveur reçoit l’intention d’achat depuis le front-end (panier validé, montant, devise, infos client). Deuxième temps, il appelle POST /v3/payments avec une référence unique tx_ref et reçoit en réponse une URL hébergée. Troisième temps, le client est redirigé vers cette URL et choisit sa méthode de paiement parmi celles que Flutterwave présente. Quatrième temps, le paiement est effectué et Flutterwave redirige le client vers la redirect_url du commerçant. Cinquième temps, en parallèle, Flutterwave envoie un webhook au serveur pour notifier l’issue.

La clé de cohérence de tout ce flux est le tx_ref. C’est l’identifiant que le commerçant attribue à la tentative de paiement et qui voyage dans toutes les communications. Il doit être unique côté commerçant : un UUID v4 préfixé d’un identifiant métier (order-42-abc123def) marche bien et facilite le debugging.

Étape 2 — Créer le projet Django

On démarre un projet neuf et une app dédiée aux paiements. Cette app contiendra les vues, les modèles et la logique de réconciliation, isolée du reste du domaine métier.

python -m venv .venv
source .venv/bin/activate  # Windows : .venv\Scripts\activate
pip install Django requests python-dotenv
django-admin startproject monsite .
python manage.py startapp payments

On ajoute payments à INSTALLED_APPS dans monsite/settings.py et on configure la base de données. Pour ce tutoriel, SQLite suffit ; en production, on préférera PostgreSQL pour les contraintes UNIQUE sur les colonnes critiques (UUID de transaction).

Étape 3 — Définir les modèles de transaction

Une intégration paiement sérieuse stocke chaque tentative dans une table dédiée. Ce n’est pas optionnel : sans cette trace, on ne peut ni réconcilier, ni rembourser, ni tracer les fraudes.

# payments/models.py
import uuid
from django.db import models

class Transaction(models.Model):
    STATUS = [
        ('pending', 'Pending'),
        ('successful', 'Successful'),
        ('failed', 'Failed'),
        ('cancelled', 'Cancelled'),
    ]
    tx_ref = models.CharField(max_length=64, unique=True, db_index=True)
    flw_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
    amount = models.DecimalField(max_digits=12, decimal_places=2)
    currency = models.CharField(max_length=3)
    customer_email = models.EmailField()
    status = models.CharField(max_length=16, choices=STATUS, default='pending')
    raw_payload = models.JSONField(blank=True, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    @classmethod
    def new_ref(cls, prefix='tx'):
        return f'{prefix}-{uuid.uuid4().hex[:16]}'

La méthode new_ref centralise la génération du tx_ref, ce qui garantit l’unicité et le format. Le champ flw_id stocke l’identifiant assigné par Flutterwave une fois le paiement initié, indispensable pour les remboursements ultérieurs. raw_payload garde la dernière réponse Flutterwave en JSON pour les besoins de debug et d’audit. On lance ensuite python manage.py makemigrations et python manage.py migrate.

Étape 4 — Configurer les clés et la session

Les clés Flutterwave se trouvent dans Settings → API du tableau de bord. Trois clés existent : public, secret, et encryption. Pour le Standard Checkout server-to-server, seule la clé secrète est utilisée. On les charge via un fichier .env.

# monsite/settings.py
import os
from dotenv import load_dotenv
load_dotenv()

FLW_SECRET_KEY = os.environ['FLW_SECRET_KEY']
FLW_SECRET_HASH = os.environ['FLW_SECRET_HASH']
FLW_BASE_URL = 'https://api.flutterwave.com/v3'
APP_URL = os.environ.get('APP_URL', 'http://localhost:8000')

La FLW_SECRET_HASH n’est pas une clé d’API : c’est une chaîne arbitraire qu’on choisit (par exemple un UUID), qu’on configure dans Settings → Webhooks de Flutterwave, et qui sert à vérifier l’authenticité des webhooks entrants. C’est le mécanisme spécifique de Flutterwave, plus simple qu’un HMAC mais qui exige que la valeur reste secrète.

Étape 5 — Créer la vue de checkout

La vue de checkout reçoit les paramètres du panier, crée une Transaction, appelle Flutterwave, et redirige le client vers l’URL hébergée.

# payments/views.py
import requests
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from .models import Transaction

@require_POST
@csrf_exempt
def initiate_checkout(request):
    amount = request.POST['amount']
    currency = request.POST.get('currency', 'XOF')
    email = request.POST['email']

    tx = Transaction.objects.create(
        tx_ref=Transaction.new_ref(),
        amount=amount,
        currency=currency,
        customer_email=email,
    )

    payload = {
        'tx_ref': tx.tx_ref,
        'amount': str(amount),
        'currency': currency,
        'redirect_url': f'{settings.APP_URL}/payments/return/',
        'customer': {'email': email},
        'customizations': {
            'title': 'Boutique en ligne',
            'description': f'Commande {tx.tx_ref}',
        },
    }
    r = requests.post(
        f'{settings.FLW_BASE_URL}/payments',
        headers={'Authorization': f'Bearer {settings.FLW_SECRET_KEY}'},
        json=payload,
        timeout=15,
    )
    r.raise_for_status()
    data = r.json()['data']
    return JsonResponse({'authorization_url': data['link']})

Plusieurs détails comptent. Le timeout de 15 secondes est essentiel : sans lui, une indisponibilité de Flutterwave bloquerait l’utilisateur indéfiniment. Le champ redirect_url doit pointer vers une vue qui re-vérifie la transaction côté serveur ; on n’accorde jamais de service à un utilisateur uniquement parce qu’il a atterri sur cette URL, car un utilisateur malicieux peut la forger. Et on stocke tx_ref en base AVANT l’appel Flutterwave : si l’appel échoue après l’enregistrement, on peut retenter ; si on enregistrait après, une erreur réseau laisserait le système incohérent.

Étape 6 — Vérifier la transaction au retour

Quand l’utilisateur revient sur redirect_url, Flutterwave passe trois paramètres en query string : status, tx_ref, transaction_id. On ne fait JAMAIS confiance à ces valeurs : on appelle l’API Flutterwave pour vérifier l’état réel de la transaction.

def payment_return(request):
    tx_ref = request.GET.get('tx_ref')
    transaction_id = request.GET.get('transaction_id')

    r = requests.get(
        f'{settings.FLW_BASE_URL}/transactions/{transaction_id}/verify',
        headers={'Authorization': f'Bearer {settings.FLW_SECRET_KEY}'},
        timeout=15,
    )
    data = r.json()['data']
    tx = Transaction.objects.get(tx_ref=tx_ref)

    if (data['status'] == 'successful'
            and float(data['amount']) == float(tx.amount)
            and data['currency'] == tx.currency):
        tx.status = 'successful'
        tx.flw_id = str(data['id'])
        tx.raw_payload = data
        tx.save()
        return JsonResponse({'ok': True, 'tx_ref': tx_ref})

    tx.status = 'failed'
    tx.save()
    return JsonResponse({'ok': False}, status=400)

La triple vérification (statut, montant, devise) est ce qui empêche la fraude la plus basique : un attaquant qui tenterait de payer 1 XOF mais de modifier le query string pour réclamer un service à 100 000 XOF. Tant qu’on compare le montant retourné par l’API Flutterwave avec le montant stocké en base, cette attaque est neutralisée.

Étape 7 — Recevoir le webhook

Le webhook Flutterwave est tiré indépendamment du retour navigateur, ce qui est crucial : si l’utilisateur ferme son navigateur juste avant d’être redirigé, le webhook arrive quand même. C’est lui la source de vérité.

# payments/views.py
import json
from django.http import HttpResponse

@csrf_exempt
def webhook(request):
    if request.headers.get('verif-hash') != settings.FLW_SECRET_HASH:
        return HttpResponse(status=401)

    event = json.loads(request.body)
    if event.get('event') == 'charge.completed':
        data = event['data']
        tx = Transaction.objects.filter(tx_ref=data['tx_ref']).first()
        if not tx:
            return HttpResponse(status=200)  # ack même si on ne connaît pas

        if tx.status == 'successful':
            return HttpResponse(status=200)  # idempotence

        if (data['status'] == 'successful'
                and float(data['amount']) == float(tx.amount)):
            tx.status = 'successful'
            tx.flw_id = str(data['id'])
            tx.raw_payload = data
            tx.save()
            # Ici : déclencher la livraison du service / produit
    return HttpResponse(status=200)

Trois points sécurité critiques. La comparaison du verif-hash est non négociable : sans elle, n’importe qui sur Internet peut forger des charges. Le check tx.status == 'successful' rend le handler idempotent : un même webhook reçu deux fois ne déclenche qu’une seule fois la livraison du service. Et on retourne 200 même quand le tx_ref est inconnu : si on retournait 4xx, Flutterwave retenterait inutilement, polluant les logs.

Étape 8 — Tester avec les cartes sandbox

Flutterwave fournit des cartes de test documentées dans Testing Helpers. Pour le mode test, la carte 5531 8866 5214 2950 avec CVV 564, expiration 09/32 et OTP 12345 simule un paiement par carte avec 3DS réussi. Pour tester le mobile money sandbox, on utilise les numéros fournis par la documentation : Wave, MTN MoMo, Airtel Money, chacun avec son OTP de test.

Pour le développement local sans serveur public, on utilise cloudflared tunnel --url http://localhost:8000. La commande affiche une URL HTTPS aléatoire qu’on configure dans Settings → Webhooks de Flutterwave. Tout webhook tiré pendant la session sera forwardé vers le serveur Django local.

Étape 9 — Logger et observer

L’observabilité est ce qui différencie une intégration robuste d’une intégration qui craque sous la charge. On instrumente trois métriques minimales. La latence des appels Flutterwave : un wrapper autour de requests.post qui mesure le temps avant chaque appel et l’envoie à Prometheus ou un service de monitoring. Le taux de succès : pourcentage de transactions status='successful' sur les 24 heures glissantes ; un effondrement de cette métrique est le premier signal d’une panne. Le délai webhook-traitement : différence entre le timestamp du payload Flutterwave et l’horodatage d’enregistrement en base ; une divergence supérieure à 30 secondes indique un goulot d’étranglement applicatif.

Côté logs, on log chaque appel sortant et chaque webhook entrant avec un identifiant de corrélation (le tx_ref est tout indiqué). On évite de logger les payloads complets sans masquage : ils contiennent des emails et parfois des partial card numbers. La discipline est de ne logger que les champs strictement nécessaires au debugging, jamais les secrets.

Étape 10 — Vérifier le scénario complet

On déroule un scénario type. Premier appel : le front-end POST sur /payments/initiate/ avec montant et email, retour avec authorization_url. On ouvre cette URL dans un onglet, on choisit la carte test, on saisit l’OTP. Flutterwave redirige vers /payments/return/ qui appelle l’API verify et marque la transaction. En parallèle, le webhook arrive sur /payments/webhook/ et fait le même travail si nécessaire. La transaction en base passe en status='successful', le service est livré, l’utilisateur reçoit la confirmation. Tous ces signaux doivent s’enchaîner sans conflit grâce au check d’idempotence.

Étape 11 — Préparer la production

Avant le passage en mode live, plusieurs ajustements. Régénérer les clés API en mode live (les clés FLWPUBK_TEST et FLWSECK_TEST deviennent FLWPUBK et FLWSECK sans le suffixe). Configurer une FLW_SECRET_HASH spécifique à la production, différente de celle de staging. Installer un certificat HTTPS valide sur l’URL de webhook : Flutterwave refuse les certificats auto-signés en production. Et activer la double validation des webhooks : compter les retries pour détecter une attaque de force brute sur le hash.

Étape 12 — Réconciliation et rapports

Une intégration paiement en production exige une réconciliation quotidienne entre la base applicative et l’historique Flutterwave. Le mécanisme est simple : un job nocturne appelle GET /v3/transactions avec un filtre temporel sur les dernières 24 heures, on parcourt la page et on compare chaque transaction Flutterwave avec son équivalent en base. Tout écart (transaction Flutterwave sans contrepartie en base, ou inversement, ou montants différents) lève une alerte qui réveille un opérateur.

L’API supporte la pagination via ?page=N et retourne par défaut 100 transactions par page. Pour un commerçant à fort volume, on segmente la requête par fenêtres horaires d’une heure pour éviter de récupérer plusieurs milliers de transactions d’un coup. Le résultat est stocké dans une table flw_reconciliation avec colonnes flw_id, amount, status, matched_internal_tx_id. Un dashboard simple compte les écarts par jour et par catégorie.

Côté reporting, Flutterwave expose les exports CSV directement depuis le tableau de bord, mais ces exports ne contiennent pas la jointure avec les commandes commerçant. La table de réconciliation locale, elle, donne accès à toute la traçabilité : commande, panier, montant attendu, montant reçu, frais Flutterwave, frais bancaires éventuels. C’est cette table qui alimente la comptabilité de fin de mois.

Étape 13 — Spécificités multi-pays

Une vraie force de Flutterwave est de servir 34 pays africains, mais cela introduit des subtilités. La devise XOF (UEMOA) ne supporte pas les centimes : on envoie amount=5000 pour 5000 XOF, sans multiplication. La devise NGN, en revanche, est exprimée en kobo : amount=500000 pour 5000 NGN. Une variable de configuration par devise centralise cette logique.

Les méthodes de paiement disponibles dépendent du couple (pays acheteur, devise). En Côte d’Ivoire en XOF, on a Wave, Orange Money et MTN MoMo affichés automatiquement. Au Nigeria en NGN, on voit cartes locales et USSD. En Afrique du Sud en ZAR, Apple Pay et cartes. La page hébergée filtre automatiquement, mais on peut forcer une méthode via le champ payment_options dans la requête initiale (par exemple "card,mobilemoneyfranco").

Pour les commerçants qui veulent restreindre certaines méthodes (par exemple n’accepter que les rails locaux pour réduire les frais), c’est ce paramètre qu’on configure. Une UX optimale propose au client de choisir sa méthode préférée en amont, puis pré-sélectionne le bon canal Flutterwave.

Erreurs fréquentes

Erreur Cause Solution
Webhook 401 systématique FLW_SECRET_HASH côté code différent de celui du dashboard Synchroniser les deux et redéployer
tx_ref already used Génération non unique côté commerçant Utiliser uuid.uuid4() + préfixe métier dans new_ref
Montant facturé différent du panier Pas de vérification au retour Comparer data.amount avec tx.amount dans la vue verify
Webhook reçu mais pas traité Erreur silencieuse dans le handler Logger l’exception et retourner 200, traiter en async via une queue
Transaction pending qui ne passe jamais Webhook perdu lors d’une indisponibilité Job nocturne qui rappelle /transactions/{id}/verify sur les pending vieilles de plus d’une heure

Étape 14 — Stratégie de retry et tâches périodiques

Les transactions Flutterwave qui restent en statut pending de manière prolongée sont les premières sources d’incohérence en production. Plusieurs causes possibles : un webhook perdu lors d’une indisponibilité serveur, un client qui a payé mais a fermé son navigateur avant la redirection, une transaction réellement abandonnée par le client. Une tâche périodique permet de trancher.

Le job balaie la table toutes les 30 minutes pour trouver les transactions en pending de plus d’une heure et appelle l’endpoint /v3/transactions/{id}/verify pour chacune. Selon la réponse, on bascule en successful, failed ou on laisse en pending si Flutterwave répond elle-même pending. Au-delà de 24 heures sans résolution, on bascule automatiquement en failed avec un motif timeout ; cela libère le panier client et permet une nouvelle tentative.

Cette boucle de réconciliation rétablit l’état même après une panne prolongée du système applicatif. Sans elle, les transactions orphelines s’accumulent et créent des incohérences entre l’historique Flutterwave et la base interne, ce qui complique fortement les audits comptables et la résolution de litiges client.

Ressources

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é