Guide d'intégration complet
Ce guide construit pas à pas une mini-boutique qui encaisse avec MafPay, de A à Z : le client clique sur « Payer », voit le lien et le QR code, paie, et votre serveur est prévenu automatiquement.
Choisissez votre technologie avec les onglets : Next.js (App Router, TypeScript) ou Django (Python). Les deux versions font exactement la même chose, étape par étape. Votre choix est retenu pour toute la page.
Ce que fait la boutique
Navigateur ──1. clic sur « Payer »──► votre serveur ──2. initiate──► MafPay ▲ │ │◄────3. lien + QR code─────────────────┘ │ │ (le client paie : page de paiement de test en sandbox, application de l'opérateur en live) │ │ MafPay ──4. notification signée──► votre serveur │ (commande → PAYÉE) └──5. interroge sa commande toutes les 4 s ──► « Paiement reçu, merci ! »Préparer le projet
Créez un dossier pour votre boutique et installez ce qu’il faut. Il vous faut Node.js 20.9 ou plus pour Next.js, Python 3.10 ou plus pour Django.
mkdir ma-boutique && cd ma-boutiquemkdir -p app/api/checkout "app/api/orders/[id]" app/api/mafpay/webhook libCréez le fichier package.json, puis le fichier tsconfig.json (la configuration TypeScript). Installez ensuite les dépendances avec npm install.
{ "name": "ma-boutique-nextjs", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }, "dependencies": { "next": "latest", "react": "latest", "react-dom": "latest" }, "devDependencies": { "typescript": "latest", "@types/node": "latest", "@types/react": "latest", "@types/react-dom": "latest" }}{ "compilerOptions": { "target": "ES2017", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": [ "./*" ] } }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" ]}mkdir ma-boutique && cd ma-boutiquepython3 -m venv .venvsource .venv/bin/activate # sous Windows : .venv\Scripts\activatepip install django requests python-dotenvdjango-admin startproject config .python manage.py startapp shopmkdir -p shop/templates/shopconfig est le projet Django, shop est l’application qui contient votre boutique.
Renseigner vos clés
Créez le fichier de configuration à la racine du projet. Il contient votre secret : ne l’envoyez jamais dans un dépôt Git public (les deux projets d’exemple l’excluent déjà via .gitignore).
MAFPAY_URL=http://localhost:8000MAFPAY_ENDPOINT_KEY=votre_endpoint_keyMAFPAY_PK=pk_test_xxxxxxxxMAFPAY_SK=sk_test_xxxxxxxxNext.js lit .env.local tout seul. Ces variables ne sont lues que côté serveur : elles ne sont jamais envoyées au navigateur.
MAFPAY_URL=http://localhost:8000MAFPAY_ENDPOINT_KEY=votre_endpoint_keyMAFPAY_PK=pk_test_xxxxxxxxMAFPAY_SK=sk_test_xxxxxxxxPuis modifiez config/settings.py pour lire ce fichier, autoriser vos adresses et déclarer l’application shop :
# --- en haut du fichier ---import osfrom pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env") # lit vos clés MafPay dans le fichier .env
# --- plus bas : les adresses depuis lesquelles Django accepte les requêtes ---# (.ngrok-free.app : le tunnel utilisé pour recevoir les notifications de MafPay)ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,.ngrok-free.app").split(",")
# --- dans INSTALLED_APPS : ajoutez votre application ---INSTALLED_APPS = [ # ... les applications déjà présentes ... "shop",]Et branchez les routes de la boutique dans config/urls.py :
from django.contrib import adminfrom django.urls import include, path
urlpatterns = [ path("admin/", admin.site.urls), path("", include("shop.urls")), # les routes de votre boutique]Remplacez les valeurs par les vôtres : votre endpoint_key et vos clés _test_ (voir Démarrage rapide). Configurez aussi Wave avec les valeurs de test (liste).
Parler à MafPay
Cette étape regroupe trois outils : un petit client qui ajoute vos clés à chaque appel, la vérification de signature des notifications, et une « base de données » des commandes.
Le client MafPay et la vérification de signature :
// lib/mafpay.ts : tout ce qui parle à MafPay (côté serveur uniquement)import crypto from "node:crypto";
const { MAFPAY_URL, MAFPAY_ENDPOINT_KEY, MAFPAY_PK, MAFPAY_SK } = process.env;const API = `${MAFPAY_URL}/api/v1/${MAFPAY_ENDPOINT_KEY}`;
// Ce que MafPay renvoie à la création d'un paiement (les champs utilisés dans ce guide).export type Payment = { reference: string; status: string; amount: string; deep_links: Record<string, string>; qr_code: string; expires_at: string;};
// Appelle l'API MafPay avec vos clés ; lève une erreur lisible si MafPay refuse la requête.export async function mafpay<T>(path: string, options: RequestInit = {}): Promise<T> { const response = await fetch(`${API}${path}`, { ...options, headers: { "X-Api-Key": MAFPAY_PK!, "X-Api-Secret": MAFPAY_SK!, "Content-Type": "application/json", }, }); const body = await response.json(); if (!response.ok) throw new Error(`MafPay ${response.status} : ${body.message}`); return body as T;}
// Vérifie la signature d'une notification (voir la page « Webhooks »).export function verifySignature(rawBody: string, header: string | null, toleranceSeconds = 300): boolean { if (!header) return false; const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))) as Record<string, string>; if (!parts.t || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false; const expected = crypto.createHmac("sha256", MAFPAY_SK!).update(`${parts.t}.`).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && crypto.timingSafeEqual(a, b);}La « base de données » des commandes (ici en mémoire, à remplacer par MySQL, Postgres, MongoDB…) :
// lib/orders.ts : votre "base de données". Ici en mémoire, à remplacer par MySQL, Postgres, MongoDB…export type OrderStatus = "PENDING" | "PAID" | "UNPAID";export type Order = { id: string; amount: number; status: OrderStatus; reference: string | null };
// globalThis : Next.js peut charger ce fichier plusieurs fois en développement ; ainsi toutes les routes partagent les mêmes données.const globalStore = globalThis as unknown as { __boutique?: { orders: Map<string, Order>; processedEvents: Set<string> } };const store = (globalStore.__boutique ??= { orders: new Map(), processedEvents: new Set() });
export const orders = store.orders; // orderId -> commandeexport const processedEvents = store.processedEvents; // ids de notifications déjà traitées (idempotence)Le client MafPay et la vérification de signature :
# shop/mafpay.py : tout ce qui parle à MafPayimport hashlibimport hmacimport osimport time
import requests
class MafPayError(Exception): """MafPay a refusé la requête (le message est celui de MafPay, lisible)."""
def _api() -> str: return f"{os.environ['MAFPAY_URL']}/api/v1/{os.environ['MAFPAY_ENDPOINT_KEY']}"
# Appelle l'API MafPay avec vos clés ; lève MafPayError si MafPay refuse la requête.def call(path: str, method: str = "GET", payload: dict | None = None) -> dict: response = requests.request( method, f"{_api()}{path}", json=payload, headers={"X-Api-Key": os.environ["MAFPAY_PK"], "X-Api-Secret": os.environ["MAFPAY_SK"]}, timeout=20, ) body = response.json() if not response.ok: raise MafPayError(f"MafPay {response.status_code} : {body['message']}") return body
# Vérifie la signature d'une notification (voir la page « Webhooks »).def verify_signature(raw_body: bytes, header: str, tolerance: int = 300) -> bool: if not header: return False parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) timestamp, received = parts.get("t"), parts.get("v1") if not timestamp or not received: return False if abs(time.time() - int(timestamp)) > tolerance: # rejeu return False secret = os.environ["MAFPAY_SK"].encode() expected = hmac.new(secret, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received)Les modèles de la base de données (des commandes, et des notifications déjà traitées) :
# shop/models.py : votre base de données (SQLite par défaut ; changez-la dans settings.py pour PostgreSQL ou MySQL)from django.db import models
class Order(models.Model): PENDING, PAID, UNPAID = "PENDING", "PAID", "UNPAID"
amount = models.DecimalField(max_digits=12, decimal_places=2) status = models.CharField(max_length=10, default=PENDING) reference = models.CharField(max_length=50, null=True, blank=True) # la référence MafPay (MAFPAY-XXXXXXXX)
class ProcessedEvent(models.Model): # Une notification peut arriver plusieurs fois : on retient son id (unique) pour ne la traiter qu'une fois. event_id = models.CharField(max_length=100, unique=True)Puis créez les tables :
python manage.py makemigrations shoppython manage.py migrateCréer le paiement
Quand le client clique sur « Payer », votre serveur crée la commande, puis demande le paiement à MafPay en y joignant l’identifiant de la commande dans metadata. MafPay répond avec la reference, le lien de paiement et le QR code.
// POST /api/checkout : le client clique sur « Payer » -> on crée la commande puis le paiement MafPayimport { NextResponse } from "next/server";import { mafpay, type Payment } from "@/lib/mafpay";import { orders, type Order } from "@/lib/orders";
export async function POST(request: Request) { const { amount, provider = "WAVE_SN", phone } = await request.json(); const order: Order = { id: String(orders.size + 1), amount: Number(amount), status: "PENDING", reference: null }; orders.set(order.id, order);
try { const payment = await mafpay<Payment>("/payments/initiate/", { method: "POST", body: JSON.stringify({ provider, amount: String(order.amount), currency: "XOF", description: `Commande #${order.id}`, customer_phone: phone, metadata: { order_id: order.id }, // le lien entre MafPay et votre base }), }); order.reference = payment.reference; // à conserver ! return NextResponse.json( { orderId: order.id, payLink: Object.values(payment.deep_links)[0], // bouton « Payer » sur mobile qrCode: `data:image/png;base64,${payment.qr_code}`, // <img src="…"> sur ordinateur expiresAt: payment.expires_at, }, { status: 201 } ); } catch (error) { order.status = "UNPAID"; return NextResponse.json({ error: (error as Error).message }, { status: 502 }); }}Ouvrez shop/views.py : le début du fichier contient la page d’accueil et la création du paiement.
# shop/views.pyimport jsonfrom decimal import Decimal
from django.http import HttpResponse, JsonResponsefrom django.shortcuts import renderfrom django.views.decorators.csrf import csrf_exemptfrom django.views.decorators.http import require_GET, require_POST
from . import mafpayfrom .models import Order, ProcessedEvent
@require_GETdef home(request): return render(request, "shop/index.html")
# 1) Le client clique sur « Payer » : on crée la commande puis le paiement MafPay@require_POSTdef checkout(request): data = json.loads(request.body) order = Order.objects.create(amount=Decimal(str(data["amount"]))) payload = { "provider": data.get("provider", "WAVE_SN"), "amount": str(int(order.amount)), "currency": "XOF", "description": f"Commande #{order.id}", "metadata": {"order_id": order.id}, # le lien entre MafPay et votre base } if data.get("phone"): payload["customer_phone"] = data["phone"] # facultatif : on ne l'envoie que s'il existe try: payment = mafpay.call("/payments/initiate/", "POST", payload) except mafpay.MafPayError as error: order.status = Order.UNPAID order.save() return JsonResponse({"error": str(error)}, status=502) order.reference = payment["reference"] # à conserver ! order.save() return JsonResponse({ "orderId": order.id, "payLink": next(iter(payment["deep_links"].values())), # bouton « Payer » sur mobile "qrCode": f"data:image/png;base64,{payment['qr_code']}", # <img src="…"> sur ordinateur "expiresAt": payment["expires_at"], }, status=201)Recevoir la notification
Quand le paiement change d’état, MafPay appelle votre serveur. Cette route vérifie la signature, ignore les doublons, contrôle le montant, puis met la commande à jour. C’est ici que vous livrez.
// POST /api/mafpay/webhook : MafPay vous prévient qu'un paiement a changé d'étatimport { verifySignature } from "@/lib/mafpay";import { orders, processedEvents } from "@/lib/orders";
export async function POST(request: Request) { const rawBody = await request.text(); // corps BRUT : la signature porte dessus if (!verifySignature(rawBody, request.headers.get("x-mafpay-signature"))) { return new Response("signature invalide", { status: 401 }); } const event = JSON.parse(rawBody); if (processedEvents.has(event.id)) return new Response(null, { status: 200 }); // doublon : déjà traité
const order = orders.get(String(event.data.metadata?.order_id)); // On vérifie que la commande existe et que le montant correspond avant de valider if (order && event.event === "transaction.success" && Number(event.data.amount) === order.amount) { order.status = "PAID"; // <- ici : livrer, envoyer l'email de confirmation, décrémenter le stock… } else if (order && ["transaction.failed", "transaction.cancelled", "transaction.expired"].includes(event.event)) { order.status = "UNPAID"; } processedEvents.add(event.id); return new Response(null, { status: 200 }); // répondre vite}request.text() renvoie le corps brut : indispensable, car la signature porte dessus.
Ajoutez à la suite dans shop/views.py :
# 2) MafPay vous prévient qu'un paiement a changé d'état.# csrf_exempt : cet appel vient du serveur de MafPay, pas d'un navigateur ; c'est la signature qui l'authentifie.@csrf_exempt@require_POSTdef webhook(request): raw = request.body # corps BRUT : la signature porte dessus if not mafpay.verify_signature(raw, request.headers.get("X-MafPay-Signature", "")): return HttpResponse("signature invalide", status=401) event = json.loads(raw) if ProcessedEvent.objects.filter(event_id=event["id"]).exists(): # doublon : déjà traité return HttpResponse(status=200)
order = Order.objects.filter(pk=(event["data"].get("metadata") or {}).get("order_id")).first() # On vérifie que la commande existe et que le montant correspond avant de valider if order and event["event"] == "transaction.success" and Decimal(event["data"]["amount"]) == order.amount: order.status = Order.PAID # <- ici : livrer, envoyer l'email de confirmation, décrémenter le stock… order.save() elif order and event["event"] in ("transaction.failed", "transaction.cancelled", "transaction.expired"): order.status = Order.UNPAID order.save() ProcessedEvent.objects.create(event_id=event["id"]) return HttpResponse(status=200) # répondre viterequest.body est le corps brut : indispensable, car la signature porte dessus. @csrf_exempt est nécessaire : l’appel vient du serveur de MafPay et non d’un navigateur, c’est la signature qui l’authentifie.
Suivre la commande
La page du client demande régulièrement si sa commande est payée. Si la notification tarde, la route interroge directement MafPay : c’est votre filet de sécurité.
// GET /api/orders/1 : la page du client demande si sa commande est payéeimport { NextResponse } from "next/server";import { mafpay } from "@/lib/mafpay";import { orders } from "@/lib/orders";
type PaymentStatus = { status: string; amount: string };
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; // dans Next.js récent, params est une promesse const order = orders.get(id); if (!order) return NextResponse.json({ error: "Commande introuvable" }, { status: 404 });
// Filet de sécurité : si la notification tarde, on demande directement à MafPay if (order.status === "PENDING" && order.reference) { const payment = await mafpay<PaymentStatus>(`/payments/${order.reference}/status/`).catch(() => null); if (payment?.status === "SUCCESS" && Number(payment.amount) === order.amount) order.status = "PAID"; if (payment && ["FAILED", "CANCELLED", "EXPIRED"].includes(payment.status)) order.status = "UNPAID"; } return NextResponse.json({ id: order.id, status: order.status });}Ajoutez à la suite dans shop/views.py :
# 3) La page du client demande si sa commande est payée@require_GETdef order_status(request, order_id): order = Order.objects.filter(pk=order_id).first() if order is None: return JsonResponse({"error": "Commande introuvable"}, status=404) # Filet de sécurité : si la notification tarde, on demande directement à MafPay if order.status == Order.PENDING and order.reference: try: payment = mafpay.call(f"/payments/{order.reference}/status/") if payment["status"] == "SUCCESS" and Decimal(payment["amount"]) == order.amount: order.status = Order.PAID order.save() elif payment["status"] in ("FAILED", "CANCELLED", "EXPIRED"): order.status = Order.UNPAID order.save() except mafpay.MafPayError: pass return JsonResponse({"id": order.id, "status": order.status})Afficher le paiement au client
La page affiche le bouton « Payer avec Wave », le lien de paiement et le QR code, puis attend que la commande soit payée.
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Ma boutique" };
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="fr"> <body style={{ fontFamily: "sans-serif", maxWidth: 420, margin: "40px auto" }}>{children}</body> </html> );}"use client";import { useState } from "react";
type Checkout = { orderId: string; payLink: string; qrCode: string; expiresAt: string };
export default function Home() { const [payment, setPayment] = useState<Checkout | null>(null); const [message, setMessage] = useState("En attente du paiement…"); const [disabled, setDisabled] = useState(false);
async function pay() { const res = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: 5000, provider: "WAVE_SN" }), }); const data = await res.json(); if (!res.ok) return alert(data.error); setPayment(data); setDisabled(true);
// On demande toutes les 4 secondes si la commande est payée const timer = setInterval(async () => { const order = await (await fetch(`/api/orders/${data.orderId}`)).json(); if (order.status === "PAID") { clearInterval(timer); setMessage("Paiement reçu, merci !"); } else if (order.status === "UNPAID") { clearInterval(timer); setMessage("Le paiement n'a pas abouti. Vous pouvez réessayer."); setDisabled(false); } }, 4000); }
return ( <main> <h1>Panier : 5 000 FCFA</h1> <button onClick={pay} disabled={disabled}>Payer avec Wave</button> {payment && ( <div> <p><a href={payment.payLink}>Ouvrir Wave (sur mobile)</a></p> <p>ou scannez ce QR code :</p> {/* eslint-disable-next-line @next/next/no-img-element */} <img src={payment.qrCode} alt="QR code de paiement" width={220} /> <p>{message}</p> </div> )} </main> );}Les routes de l’application :
# shop/urls.pyfrom django.urls import path
from . import views
urlpatterns = [ path("", views.home), path("checkout/", views.checkout), path("mafpay/webhook/", views.webhook), path("orders/<int:order_id>/", views.order_status),]Et la page (un gabarit Django) :
<!doctype html><html lang="fr"><head> <meta charset="utf-8"> <title>Ma boutique</title></head><body style="font-family: sans-serif; max-width: 420px; margin: 40px auto;"> <h1>Panier : 5 000 FCFA</h1> <button id="pay">Payer avec Wave</button>
<div id="payment" hidden> <p><a id="payLink" href="#">Ouvrir Wave (sur mobile)</a></p> <p>ou scannez ce QR code :</p> <img id="qr" alt="QR code de paiement" width="220"> <p id="message">En attente du paiement…</p> </div>
<script> document.getElementById("pay").onclick = async () => { const res = await fetch("/checkout/", { method: "POST", // Django exige le jeton CSRF pour les requêtes POST venant de vos propres pages headers: { "Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}" }, body: JSON.stringify({ amount: 5000, provider: "WAVE_SN" }), }); const payment = await res.json(); if (!res.ok) { alert(payment.error); return; }
document.getElementById("payLink").href = payment.payLink; document.getElementById("qr").src = payment.qrCode; document.getElementById("payment").hidden = false; document.getElementById("pay").disabled = true;
// On demande toutes les 4 secondes si la commande est payée const timer = setInterval(async () => { const order = await (await fetch(`/orders/${payment.orderId}/`)).json(); if (order.status === "PAID") { clearInterval(timer); document.getElementById("message").textContent = "Paiement reçu, merci !"; } else if (order.status === "UNPAID") { clearInterval(timer); document.getElementById("message").textContent = "Le paiement n'a pas abouti. Vous pouvez réessayer."; document.getElementById("pay").disabled = false; } }, 4000); }; </script></body></html>Django exige un jeton CSRF pour les requêtes POST venant de vos propres pages : le gabarit l’envoie dans l’en-tête X-CSRFToken.
Déclarer l’adresse de notification
MafPay doit savoir où envoyer les notifications. Dans le portail, ouvrez votre application et renseignez webhook_url avec l’adresse publique de votre route :
| Technologie | Route de notification | Exemple d’adresse |
|---|---|---|
| Next.js | /api/mafpay/webhook |
https://xxxx.ngrok-free.app/api/mafpay/webhook |
| Django | /mafpay/webhook/ |
https://xxxx.ngrok-free.app/mafpay/webhook/ |
En local, MafPay ne peut pas joindre localhost : utilisez un tunnel comme ngrok (ngrok http 3000), qui donne une adresse https://… publique à coller dans webhook_url.
Lancer et tester
npm run devpython manage.py runserver 3000- Ouvrez
http://localhost:3000et cliquez sur Payer avec Wave : le lien et le QR code apparaissent. - Payez comme un client : ouvrez le lien de paiement (ou scannez le QR code). La page de paiement de test MafPay affiche le montant ; cliquez sur Payer.
- En quelques secondes, votre serveur reçoit la notification, la commande passe à « payée », et la page de votre boutique affiche « Paiement reçu, merci ! ».
- Recommencez et cliquez sur Annuler : votre commande doit rester « non payée ».
D’autres cas (montant erroné, expiration) sont décrits dans Tester en sandbox.
Passer en production
Quand tout fonctionne en sandbox, la mise en production ne demande aucun changement de code : uniquement vos clés et votre configuration.
- Déposez votre dossier KYB et passez l’application en live (Sandbox et live).
- Configurez vos moyens de paiement live avec vos vrais identifiants opérateur (Moyens de paiement).
- Remplacez, sur votre serveur de production, les clés
_test_par les clés_live_(variables d’environnement). - Vérifiez que votre
webhook_urlest en HTTPS, puis faites un premier vrai paiement d’un petit montant.
Ce qu’il faut retenir du code
| Point | Pourquoi |
|---|---|
metadata: { order_id } à l’initiation |
Le seul lien fiable entre le paiement MafPay et votre commande. Il revient dans la notification. |
Conserver la reference de la réponse |
Vous en avez besoin pour interroger le statut plus tard. |
| Lire le corps brut dans la route de notification | La signature porte sur les octets exacts reçus ; un JSON déjà analysé la casse. |
| Vérifier la signature | Sans elle, n’importe qui pourrait déclarer votre commande « payée ». |
| Garder les identifiants de notifications déjà traités | Une notification peut arriver plusieurs fois : on ne livre qu’une fois. |
| Comparer le montant à celui de la commande | On ne valide que si le montant payé est le bon. |
Interroger GET /payments/{reference}/status/ en secours |
Si votre serveur a raté une notification, la commande se met quand même à jour. |
Répondre 200 tout de suite |
MafPay réessaie si vous répondez lentement ou en erreur. |
De ce mini-exemple à la vraie vie
Ce mini-exemple est volontairement simple. Pour un vrai site :
- avec Next.js, les commandes sont ici en mémoire (elles disparaissent au redémarrage) : utilisez une vraie base de données. Avec Django, elles sont déjà en base (SQLite) : passez à PostgreSQL ou MySQL en production ;
- une contrainte d’unicité sur l’
idde notification (comme dans le modèle Django) évite les doublons même avec plusieurs serveurs ; - créez la commande avant le paiement (statut « en attente »), comme ici ;
- ajoutez la livraison et l’email de confirmation à l’endroit marqué
<- ici; - suivez la liste de contrôle de mise en production de Sandbox et live.