Aller au contenu

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.

Terminal
mkdir ma-boutique && cd ma-boutique
mkdir -p app/api/checkout "app/api/orders/[id]" app/api/mafpay/webhook lib

Créez le fichier package.json, puis le fichier tsconfig.json (la configuration TypeScript). Installez ensuite les dépendances avec npm install.

package.json
{
"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" }
}
tsconfig.json
{
"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"
]
}
Terminal
mkdir ma-boutique && cd ma-boutique
python3 -m venv .venv
source .venv/bin/activate # sous Windows : .venv\Scripts\activate
pip install django requests python-dotenv
django-admin startproject config .
python manage.py startapp shop
mkdir -p shop/templates/shop

config 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).

.env.local
MAFPAY_URL=http://localhost:8000
MAFPAY_ENDPOINT_KEY=votre_endpoint_key
MAFPAY_PK=pk_test_xxxxxxxx
MAFPAY_SK=sk_test_xxxxxxxx

Next.js lit .env.local tout seul. Ces variables ne sont lues que côté serveur : elles ne sont jamais envoyées au navigateur.

.env
MAFPAY_URL=http://localhost:8000
MAFPAY_ENDPOINT_KEY=votre_endpoint_key
MAFPAY_PK=pk_test_xxxxxxxx
MAFPAY_SK=sk_test_xxxxxxxx

Puis modifiez config/settings.py pour lire ce fichier, autoriser vos adresses et déclarer l’application shop :

config/settings.py
# --- en haut du fichier ---
import os
from 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 :

config/urls.py
from django.contrib import admin
from 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
// 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
// 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 -> commande
export const processedEvents = store.processedEvents; // ids de notifications déjà traitées (idempotence)

Le client MafPay et la vérification de signature :

shop/mafpay.py
# shop/mafpay.py : tout ce qui parle à MafPay
import hashlib
import hmac
import os
import 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
# 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 :

Terminal
python manage.py makemigrations shop
python manage.py migrate

Cré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.

app/api/checkout/route.ts
// POST /api/checkout : le client clique sur « Payer » -> on crée la commande puis le paiement MafPay
import { 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.py
# shop/views.py
import json
from decimal import Decimal
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST
from . import mafpay
from .models import Order, ProcessedEvent
@require_GET
def home(request):
return render(request, "shop/index.html")
# 1) Le client clique sur « Payer » : on crée la commande puis le paiement MafPay
@require_POST
def 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.

app/api/mafpay/webhook/route.ts
// POST /api/mafpay/webhook : MafPay vous prévient qu'un paiement a changé d'état
import { 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 :

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_POST
def 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 vite

request.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é.

app/api/orders/[id]/route.ts
// GET /api/orders/1 : la page du client demande si sa commande est payée
import { 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 :

shop/views.py
# 3) La page du client demande si sa commande est payée
@require_GET
def 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.

app/layout.tsx
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>
);
}
app/page.tsx
"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.py
# shop/urls.py
from 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) :

shop/templates/shop/index.html
<!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

Terminal
npm run dev
Terminal
python manage.py runserver 3000
  1. Ouvrez http://localhost:3000 et cliquez sur Payer avec Wave : le lien et le QR code apparaissent.
  2. 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.
  3. 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 ! ».
  4. 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.

  1. Déposez votre dossier KYB et passez l’application en live (Sandbox et live).
  2. Configurez vos moyens de paiement live avec vos vrais identifiants opérateur (Moyens de paiement).
  3. Remplacez, sur votre serveur de production, les clés _test_ par les clés _live_ (variables d’environnement).
  4. Vérifiez que votre webhook_url est 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’id de 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.