E-commerce

Intégrer Mobile Money sur un site e-commerce : PayDunya et CinetPay pas-à-pas

31 min de lecture

Guide principal de la série : Mobile money en backend 2026 — Wave, Orange Money, PayDunya, CinetPay. Ce tutoriel fait partie d’une série sur les paiements mobiles en Afrique de l’Ouest. Pour la vue d’ensemble (choix d’agrégateur, comparatif, architecture), commencez par le guide principal.

Ce que vous allez accomplir

À la fin de ce tutoriel, votre boutique e-commerce encaisse de vrais paiements Mobile Money en zone UEMOA. Concrètement, vous saurez relier un site WooCommerce ou une application sur-mesure (Laravel, Node.js) à PayDunya ou CinetPay, gérer le cycle complet d’une transaction (création de facture, redirection client, webhook de confirmation, vérification serveur, réconciliation comptable), sécuriser les échanges contre les notifications falsifiées et tracer les erreurs en production.

Le tutoriel s’adresse aux développeurs et intégrateurs qui ont déjà une boutique en ligne fonctionnelle (catalogue, panier, gestion de commandes) et qui veulent ajouter le paiement mobile aux côtés ou à la place de la carte bancaire. Le code est en PHP et JavaScript car ce sont les écosystèmes les plus utilisés sur le terrain au Sénégal, en Côte d’Ivoire et au Mali, mais la logique se transpose à n’importe quel langage qui sait faire un appel HTTP.

Prérequis

  • Système : Linux, macOS ou Windows avec WSL2.
  • Versions : PHP 8.1+ avec Composer, ou Node.js 20 LTS+ (Node 18 a atteint son EOL en avril 2025) avec npm.
  • Outils : ngrok ou Cloudflare Tunnel pour exposer localhost en HTTPS, Postman ou Insomnia pour tester les requêtes API à la main, un éditeur (VS Code recommandé).
  • Comptes : compte sandbox PayDunya (paydunya.com) ou CinetPay (cinetpay.com), avec activation par email.
  • Niveau : intermédiaire. Vous savez écrire un endpoint HTTP, lire un JSON et utiliser des variables d’environnement.
  • Temps : 2 à 5 jours-homme pour un développeur expérimenté, 5 à 10 jours pour un junior accompagné.

Étape 1 — Choisir entre PayDunya et CinetPay

Avant d’écrire la moindre ligne de code, fixez l’agrégateur. Cette décision conditionne le SDK que vous installerez, la structure des webhooks que vous traiterez et les opérateurs que vos clients pourront utiliser. PayDunya est historiquement plus présent au Sénégal et en Côte d’Ivoire avec une couverture forte de Wave, Orange Money et Free Money (renommé Mixx by Yas au Sénégal le 26 novembre 2024 par AXIAN Telecom, propriétaire de l’opérateur Free Sénégal devenu Yas Senegal). CinetPay est plus large géographiquement (UEMOA + Afrique centrale), avec un dashboard plus mature pour les rapports et un support des cartes Visa/Mastercard intégré.

Si votre boutique cible le Sénégal et la Côte d’Ivoire avec un volume mensuel inférieur à 50 millions de FCFA, PayDunya offre la mise en route la plus rapide. Au-delà, ou si vous opérez sur plusieurs pays UEMOA simultanément, CinetPay devient plus pertinent. Dans le doute, créez les deux comptes sandbox aujourd’hui — c’est gratuit — et testez les deux flux avant de figer le choix. Le reste du tutoriel illustre PayDunya en exemple principal et signale les différences CinetPay là où elles comptent.

Étape 2 — Créer le compte sandbox et récupérer les clés API

Le compte sandbox est obligatoire avant tout développement : il permet de simuler des paiements gratuits sans toucher d’argent réel et donne accès à des numéros de test qui acceptent ou refusent automatiquement les transactions. PayDunya impose une activation par email et la création d’un magasin (Store) avec nom, adresse, téléphone et logo, parce que ces informations apparaîtront sur la page de paiement vue par le client final.

Une fois le magasin créé, ouvrez la section API Keys du dashboard. Vous récupérez quatre chaînes : master_key, private_key, public_key et token. Notez-les dans un gestionnaire de mots de passe — jamais dans un fichier en clair commité. Pour CinetPay, deux valeurs suffisent : API_KEY et SITE_ID.

# Vérification rapide depuis le terminal — PayDunya sandbox
curl -X POST https://app.paydunya.com/sandbox-api/v1/checkout-invoice/create \
  -H "Content-Type: application/json" \
  -H "PAYDUNYA-MASTER-KEY: VOTRE_MASTER_KEY" \
  -H "PAYDUNYA-PRIVATE-KEY: VOTRE_PRIVATE_KEY" \
  -H "PAYDUNYA-TOKEN: VOTRE_TOKEN" \
  -d '{"invoice":{"total_amount":100,"description":"test"},"store":{"name":"Test"}}'

Si les clés sont valides, la réponse JSON contient response_code: "00" et un token. Si vous voyez response_code: "84", une clé est mauvaise — vérifiez qu’aucun espace ne s’est glissé au copier-coller. Cette commande fait office de smoke test : sans elle, inutile de continuer.

Étape 3 — Stocker les clés et installer le SDK

Les clés API ne doivent jamais quitter le serveur. Le risque concret : un développeur les pousse par accident sur un dépôt GitHub public, un bot les détecte en quelques minutes, et un attaquant crée des factures à votre nom. La parade standard est un fichier .env à la racine du projet, ajouté à .gitignore dès la première commande, avec les valeurs lues par votre framework au démarrage.

# .env — à la racine, jamais commité
PAYDUNYA_MODE=test
PAYDUNYA_MASTER_KEY=4fpMv7T8-Vatz-bynh-...
PAYDUNYA_PRIVATE_KEY=test_private_...
PAYDUNYA_PUBLIC_KEY=test_public_...
PAYDUNYA_TOKEN=tNyaYOpb...
APP_BASE_URL=https://maboutique.sn

Vérifiez immédiatement avec git status que .env n’apparaît pas dans les fichiers suivis. En production, remplacez ce fichier par les variables d’environnement du serveur (Apache SetEnv, Nginx fastcgi_param, ou les secrets de votre hébergeur). Côté SDK, PayDunya publie un client PHP officiel et un wrapper Node communautaire mais éprouvé.

# PHP — SDK officiel
composer require paydunya/paydunya-php

# Node.js — pas de SDK officiel, on installe axios + dotenv
npm install axios dotenv

Composer télécharge la version la plus récente depuis Packagist et l’enregistre dans composer.lock pour garantir des installations reproductibles. Pour Node, axios suffit car PayDunya expose une API HTTP/JSON très propre. Une fois installé, lancez php -r "require 'vendor/autoload.php'; var_dump(class_exists('\\Paydunya\\Setup'));" — la sortie doit afficher bool(true).

Étape 4 — Créer une facture et rediriger le client

Une facture (invoice) est l’objet central du flux : elle agrège le contenu du panier, le montant total, l’identifiant de commande côté boutique, et l’URL de retour après paiement. PayDunya la crée côté serveur, retourne une URL hébergée chez eux, et c’est sur cette URL qu’on redirige le navigateur du client. Cette indirection est cruciale pour la sécurité : votre serveur ne touche jamais aux données bancaires ni au code OTP du client.

<?php
require 'vendor/autoload.php';

\Paydunya\Setup::setMasterKey(getenv('PAYDUNYA_MASTER_KEY'));
\Paydunya\Setup::setPublicKey(getenv('PAYDUNYA_PUBLIC_KEY'));
\Paydunya\Setup::setPrivateKey(getenv('PAYDUNYA_PRIVATE_KEY'));
\Paydunya\Setup::setToken(getenv('PAYDUNYA_TOKEN'));
\Paydunya\Setup::setMode(getenv('PAYDUNYA_MODE'));

\Paydunya\Checkout\Store::setName('Ma Boutique SN');
\Paydunya\Checkout\Store::setPhoneNumber('+221770000000');
\Paydunya\Checkout\Store::setWebsiteUrl('https://maboutique.sn');

$invoice = new \Paydunya\Checkout\CheckoutInvoice();
$invoice->addItem('Pagne wax 6 yards', 1, 12000, 12000, 'Motif fleuri');
$invoice->addItem('Frais de livraison Dakar', 1, 2000, 2000);
$invoice->setTotalAmount(14000);
$invoice->setDescription('Commande #1042');
$invoice->addCustomData('order_id', '1042');
$invoice->setCallbackUrl('https://maboutique.sn/api/paydunya/ipn');
$invoice->setReturnUrl('https://maboutique.sn/merci');
$invoice->setCancelUrl('https://maboutique.sn/annulation');

if ($invoice->create()) {
    $token = $invoice->getToken();
    // Stocker $token en base, lié à la commande #1042
    header('Location: ' . $invoice->getInvoiceUrl());
    exit;
} else {
    error_log('PayDunya error: ' . $invoice->response_text);
    http_response_code(500);
    echo 'Erreur de paiement, veuillez réessayer.';
}

Le montant est un entier en FCFA, sans centimes — l’unité monétaire UEMOA n’a pas de subdivision décimale. À ce stade, ouvrez votre navigateur sur la route qui exécute ce code : vous devez être redirigé vers app.paydunya.com (ou le sandbox équivalent) avec le panier affiché. Si la redirection échoue, lisez $invoice->response_text — c’est l’erreur brute de l’API et elle est suffisamment explicite pour diagnostiquer en quelques secondes.

Étape 5 — Implémenter le webhook IPN avec double vérification

Le webhook IPN (Instant Payment Notification) est l’endpoint que l’agrégateur appelle de serveur à serveur pour vous notifier qu’un paiement est confirmé. C’est le seul signal sur lequel vous pouvez vous fier pour marquer une commande payée — jamais le retour navigateur, qui peut être falsifié ou interrompu si le client ferme l’onglet. Sans webhook, vous risquez soit de livrer sans avoir été payé, soit l’inverse.

Le piège suivant est qu’un attaquant peut tenter de forger une fausse notification de paiement vers votre URL publique. La parade : à chaque réception, votre serveur rappelle l’API PayDunya avec le token reçu pour confirmer indépendamment que la transaction est bien completed. Cette double vérification rend la falsification quasi impossible.

<?php
// fichier ipn.php — exposé sur https://maboutique.sn/api/paydunya/ipn

$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, true);

if (!isset($payload['data']['invoice']['token'])) {
    http_response_code(400);
    exit;
}

$token = $payload['data']['invoice']['token'];
$orderId = $payload['data']['custom_data']['order_id'] ?? null;

if (!$orderId) {
    http_response_code(400);
    exit;
}

// Double vérification : on rappelle PayDunya
$confirm = new \Paydunya\Checkout\CheckoutInvoice();
if (!$confirm->confirm($token)) {
    error_log('IPN refused for token ' . $token);
    http_response_code(400);
    exit;
}

if ($confirm->getStatus() === 'completed') {
    $db->update('orders',
        ['status' => 'paid', 'paid_at' => date('c')],
        ['id' => $orderId]
    );
    sendOrderConfirmation($orderId);
}

http_response_code(200);
echo 'OK';

Le statut completed est défini par la documentation PayDunya, aux côtés de cancelled (annulé manuellement par le client ou automatiquement après 24 heures sans paiement) et failed (paiement refusé par l’opérateur). Côté CinetPay, l’endpoint équivalent est POST https://api-checkout.cinetpay.com/v2/payment/check avec le transaction_id reçu. Quand vous testez, le code HTTP renvoyé à PayDunya doit être 200 — sinon il considère le webhook comme échoué et le rejoue jusqu’à 10 fois sur plusieurs heures, ce qui pollue vos logs et risque de doubler des actions si vous n’êtes pas idempotent (étape 7).

Étape 6 — Tester en local avec ngrok

Le webhook nécessite une URL publique HTTPS — votre localhost:8000 n’est pas accessible depuis les serveurs PayDunya. ngrok résout exactement ce problème en créant un tunnel chiffré entre une URL .ngrok-free.app et votre port local. C’est l’outil standard du développement avec webhooks, utilisé y compris par Stripe et Twilio dans leurs propres tutoriels.

# Installer ngrok puis dans un terminal séparé du serveur PHP :
ngrok http 8000

# Sortie typique :
# Forwarding  https://abcd-1234.ngrok-free.app -> http://localhost:8000

Copiez l’URL https://... et collez-la comme URL de callback dans le code de l’étape 4 (à la place de https://maboutique.sn/api/paydunya/ipn). Faites un paiement de test avec les numéros de simulation fournis par PayDunya — le tableau de bord ngrok à l’adresse http://localhost:4040 montre alors la requête IPN entrante en temps réel, avec les headers et le body. Si la requête n’arrive jamais, c’est que l’URL de callback est mauvaise ou que votre PHP plante avant de répondre 200.

Étape 7 — Garantir l’idempotence du webhook

Un webhook peut arriver plusieurs fois pour la même transaction : rejeu réseau côté agrégateur, retry après timeout, redéploiement de votre serveur en cours de réception. Si votre handler crédite le compte client ou déclenche une livraison à chaque appel, vous payez deux ou trois fois la commande. La solution est une table d’événements traités, consultée avant toute action métier.

<?php
// Avant tout traitement : a-t-on déjà vu cet événement ?
$existing = $db->fetchOne(
    'SELECT id FROM payment_events WHERE token = ? AND status = ?',
    [$token, $status]
);
if ($existing) {
    http_response_code(200);
    echo 'Already processed';
    exit;
}

// Insérer l'événement avant de toucher à la commande
$db->insert('payment_events', [
    'token'       => $token,
    'status'      => $status,
    'received_at' => date('c'),
]);

La clé unique sur (token, status) garantit qu’un même couple ne sera traité qu’une fois — même si deux webhooks arrivent en parallèle, l’un des deux échouera sur la contrainte d’unicité. Testez ce comportement en relançant manuellement le webhook depuis le dashboard PayDunya : la deuxième tentative doit répondre 200 sans modifier la commande.

Étape 8 — Logger, retry et circuit breaker

En production, les paiements échouent pour des raisons hors de votre contrôle : panne d’un opérateur Mobile Money, lenteur réseau côté client, dashboard agrégateur en maintenance. Sans logs et sans politique de retry, ces incidents deviennent impossibles à diagnostiquer le lundi matin. La trace minimale à conserver est : commande, fournisseur, payload sortant, payload entrant, code HTTP, horodatage.

// Schéma table payment_logs
CREATE TABLE payment_logs (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT,
    provider VARCHAR(20),
    direction VARCHAR(10),       -- 'outbound' ou 'inbound'
    request_payload JSONB,
    response_payload JSONB,
    http_status INT,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_payment_logs_order ON payment_logs(order_id);

Côté code, configurez un timeout HTTP de 30 secondes (les opérateurs Mobile Money peuvent prendre 20 à 30 secondes pour confirmer côté téléphone), avec 3 tentatives en backoff exponentiel (1 s, 3 s, 9 s) sur les erreurs 5xx ou les timeouts. Ajoutez un circuit breaker simple : si l’API PayDunya répond en erreur 5 fois en 1 minute, désactivez-la temporairement pour 5 minutes et basculez sur CinetPay ou affichez un message « paiement à la livraison disponible » au client. La conservation des logs suit les obligations comptables de votre pays (OHADA en zone UEMOA, généralement 10 ans pour les pièces justificatives) — vérifiez avec votre comptable.

Étape 9 — Remboursements et réconciliation comptable

Les remboursements se font côté dashboard PayDunya (ou CinetPay), pas via l’API publique : c’est un choix de l’agrégateur pour limiter les abus automatisés. Documentez la procédure dans votre wiki interne (qui peut rembourser, sous quelles conditions, dans quel délai) et intégrez systématiquement la trace du remboursement dans votre table d’audit, avec lien vers la commande originale et motif. Sans cette traçabilité, vous ne pourrez pas répondre aux demandes de la BCEAO en cas de contrôle.

La réconciliation est l’étape qui sépare une intégration amateur d’une intégration solide. Mettez en place une tâche planifiée quotidienne qui récupère la liste des transactions du jour précédent depuis l’API agrégateur, la compare avec vos commandes marquées payées, et liste les écarts. Trois cas typiques d’écart à surveiller : commande payée chez l’agrégateur mais pas marquée payée dans votre base (webhook perdu), commande marquée payée mais pas chez l’agrégateur (faille à investiguer immédiatement), montant qui diffère (commission mal comptabilisée).

# Cron quotidien — exécute à 02:00 sur les transactions de J-1
0 2 * * * cd /var/www/maboutique.sn && php artisan payments:reconcile --provider=paydunya >> /var/log/recon.log 2>&1

La commande génère un rapport email envoyé au gestionnaire chaque matin avec la liste des écarts à traiter manuellement. Au bout de quelques semaines, ce rapport doit converger vers zéro écart en régime normal — toute déviation devient un signal à investiguer.

Étape 10 — Bascule production et vérification finale

Avant de basculer en production, parcourez la checklist complète : compte production créé et clés récupérées, variables d’environnement séparées entre test et prod, SDK installé, création de facture testée avec retour valide, IPN exposé en HTTPS exclusivement, double vérification active, idempotence du webhook validée par double appel, statuts pending/completed/cancelled tous gérés dans le code, logs structurés en place, timeouts et retries configurés, circuit breaker prévu, procédure de remboursement documentée, cron de réconciliation actif.

# Test final : 1 transaction réelle de 100 FCFA
# - le client paie 100 FCFA via Wave ou Orange Money sur la production
# - le webhook arrive et marque la commande payée
# - le rapport de réconciliation du lendemain montre 0 écart
# - le client reçoit l'email/SMS de confirmation

Cette transaction de 100 FCFA a une valeur disproportionnée : c’est la seule preuve que tout l’environnement de production fonctionne bout en bout. Sans elle, vous découvrez le premier bug en pleine campagne marketing avec des clients réels qui se plaignent. Avec elle, vous démarrez serein.

Étape 11 — Suivre les évolutions tarifaires et de catalogue d’opérateurs

Le marché Mobile Money ouest-africain bouge plus vite que la documentation produit. Trois exemples concrets pour 2024-2026 : le rebranding Free Money → Mixx by Yas (novembre 2024), l’extension de Wave aux paiements marchand B2B en Côte d’Ivoire et au Mali, l’arrivée d’opérateurs comme MTN MoMo dans des corridors où ils n’étaient pas auparavant. Une intégration figée pour cinq ans accumule des incohérences : noms d’opérateurs anciens sur les pages de paiement, codes USSD obsolètes, opérateurs absents de la liste alors qu’ils sont devenus majoritaires.

La discipline minimale consiste à programmer une revue trimestrielle de l’intégration sur trois axes : (1) la liste des opérateurs activés dans le dashboard PayDunya/CinetPay et leurs frais, (2) les codes et libellés affichés au client final sur la page de paiement, (3) les codes pays et codes opérateurs envoyés dans les payloads (par exemple, mettre à jour FREE_SN en MIXX_SN si l’agrégateur a changé son slug). Un changelog interne court (date, opérateur, action) suffit à tracer ces revues et à éviter le réveil brutal le jour où un client signale qu’il n’arrive plus à payer parce que le bouton « Free Money » n’existe plus dans son téléphone.

Côté agrégateur, la veille consiste à lire les notes de version (PayDunya publie un journal sur developers.paydunya.com, CinetPay sur docs.cinetpay.com) et à s’abonner à leur liste mail technique. Les changements breaking sont rares mais les nouveaux opérateurs apparaissent quasi mensuellement ; il suffit souvent d’activer la case dans le dashboard pour qu’un nouvel opérateur soit proposé au client sans toucher au code.

Workflow complet PayDunya en PHP — fichier par fichier

Les fragments précédents s’enchaînent dans une application réelle qui tient en six fichiers. La structure ci-dessous fonctionne sur un serveur LAMP standard et se déploie chez n’importe quel hébergeur PHP 8.1+ — Hostinger, OVH, Contabo. La référence officielle de chaque appel API est developers.paydunya.com/doc/EN/http_json.

# Arborescence
boutique/
├── composer.json
├── .env                  # jamais commité
├── .env.example
├── .gitignore
├── public/
│   ├── pay.php           # démarrer un paiement
│   ├── ipn.php           # webhook PayDunya
│   ├── merci.php         # return_url
│   └── annulation.php    # cancel_url
├── src/
│   ├── Bootstrap.php     # env, autoload, DB, logger
│   ├── PaydunyaClient.php
│   └── OrderRepository.php
└── sql/
    └── schema.sql

Chaque fichier a un rôle unique et borné. Bootstrap.php charge l’environnement et expose $db et $logger. PaydunyaClient.php encapsule les appels API. OrderRepository.php isole les requêtes SQL pour permettre une migration ultérieure (PostgreSQL, MySQL, SQLite — au choix du marchand). Les fichiers de public/ sont les points d’entrée HTTP : minces, ils délèguent tout aux classes du src/.

// composer.json
{
  "name": "boutique/paydunya-integration",
  "require": {
    "php": "^8.1",
    "paydunya/paydunya-php": "^1.2",
    "vlucas/phpdotenv": "^5.6",
    "monolog/monolog": "^3.5"
  },
  "autoload": {
    "psr-4": {"App\\": "src/"}
  }
}

Composer installe le SDK officiel PayDunya, phpdotenv pour lire .env à l’exécution, et monolog pour la journalisation structurée. Après composer install, le dossier vendor/ est créé ; on l’ajoute au .gitignore pour ne pas commiter les dépendances. Test mental : composer dump-autoload doit afficher la classe App\PaydunyaClient dans la liste, sinon l’autoload PSR-4 est mal configuré.

-- sql/schema.sql — PostgreSQL ou MySQL avec adaptations mineures
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    customer_email VARCHAR(255) NOT NULL,
    total_amount INTEGER NOT NULL,        -- en FCFA, jamais centimes
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    paydunya_token VARCHAR(255),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    paid_at TIMESTAMPTZ
);

CREATE TABLE payment_events (
    id BIGSERIAL PRIMARY KEY,
    token VARCHAR(255) NOT NULL,
    status VARCHAR(20) NOT NULL,
    payload JSONB NOT NULL,
    received_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(token, status)                  -- garantit l'idempotence
);

CREATE INDEX idx_orders_token ON orders(paydunya_token);
CREATE INDEX idx_events_token ON payment_events(token);

La contrainte UNIQUE(token, status) est l’élément qui rend le webhook idempotent même sous double appel concurrent : PostgreSQL et MySQL rejettent la deuxième insertion avec un code d’erreur clair (23505 côté PostgreSQL, 1062 côté MySQL) que le code applicatif intercepte pour répondre 200 sans rejouer la logique métier. Test mental : on lance psql ... -f schema.sql, puis \dt doit lister orders et payment_events.

<?php
// src/Bootstrap.php
namespace App;
use Dotenv\Dotenv;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

class Bootstrap {
    public static \PDO $db;
    public static Logger $logger;

    public static function init(): void {
        Dotenv::createImmutable(dirname(__DIR__))->load();

        self::\$db = new \PDO(
            $_ENV['DB_DSN'],
            $_ENV['DB_USER'],
            $_ENV['DB_PASS'],
            [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
        );

        self::\$logger = new Logger('paydunya');
        self::\$logger->pushHandler(new StreamHandler(
            dirname(__DIR__) . '/logs/paydunya.log',
            Logger::DEBUG
        ));

        \Paydunya\Setup::setMasterKey($_ENV['PAYDUNYA_MASTER_KEY']);
        \Paydunya\Setup::setPublicKey($_ENV['PAYDUNYA_PUBLIC_KEY']);
        \Paydunya\Setup::setPrivateKey($_ENV['PAYDUNYA_PRIVATE_KEY']);
        \Paydunya\Setup::setToken($_ENV['PAYDUNYA_TOKEN']);
        \Paydunya\Setup::setMode($_ENV['PAYDUNYA_MODE']);
        \Paydunya\Checkout\Store::setName($_ENV['STORE_NAME']);
        \Paydunya\Checkout\Store::setPhoneNumber($_ENV['STORE_PHONE']);
        \Paydunya\Checkout\Store::setWebsiteUrl($_ENV['APP_BASE_URL']);
    }
}

Le Bootstrap est appelé une seule fois en tête de chaque point d’entrée HTTP. Il garantit que tout fichier exécuté ensuite dispose de Bootstrap::$db, Bootstrap::$logger, et d’un SDK PayDunya configuré. Dotenv::createImmutable empêche un appel à putenv() ailleurs dans le code de réécrire une clé sensible — défense en profondeur. Test mental : si .env manque DB_DSN, le SDK lèvera une exception explicite avant le moindre appel HTTP, ce qui est le comportement souhaité (fail fast).

<?php
// public/pay.php — point d'entrée pour démarrer un paiement
require dirname(__DIR__) . '/vendor/autoload.php';
use App\Bootstrap;
Bootstrap::init();

$orderId = (int) ($_GET['order_id'] ?? 0);
if ($orderId <= 0) { http_response_code(400); exit('Order ID invalide'); }

$order = Bootstrap::$db
    ->prepare('SELECT * FROM orders WHERE id = ? AND status = ?')
    ->execute([$orderId, 'pending']);
$row = Bootstrap::$db
    ->query("SELECT * FROM orders WHERE id = {$orderId} AND status = 'pending'")
    ->fetch(\PDO::FETCH_ASSOC);

if (!$row) { http_response_code(404); exit('Commande introuvable'); }

$invoice = new \Paydunya\Checkout\CheckoutInvoice();
$invoice->setTotalAmount((int) $row['total_amount']);
$invoice->setDescription("Commande #{$orderId}");
$invoice->addCustomData('order_id', (string) $orderId);
$invoice->setCallbackUrl($_ENV['APP_BASE_URL'] . '/api/paydunya/ipn');
$invoice->setReturnUrl($_ENV['APP_BASE_URL'] . '/merci.php?order_id=' . $orderId);
$invoice->setCancelUrl($_ENV['APP_BASE_URL'] . '/annulation.php?order_id=' . $orderId);

if ($invoice->create()) {
    Bootstrap::$db
        ->prepare('UPDATE orders SET paydunya_token = ? WHERE id = ?')
        ->execute([$invoice->getToken(), $orderId]);

    Bootstrap::$logger->info('Invoice created', [
        'order_id' => $orderId, 'token' => $invoice->getToken()
    ]);
    header('Location: ' . $invoice->getInvoiceUrl());
    exit;
}

Bootstrap::$logger->error('Invoice creation failed', [
    'order_id' => $orderId, 'error' => $invoice->response_text
]);
http_response_code(502);
exit('Service de paiement indisponible, merci de réessayer.');

Trois invariants côté pay.php : une commande en statut pending est obligatoire (on ne paye pas une commande déjà payée), le token PayDunya est persisté avant la redirection (sinon, en cas d’arrêt brutal du processus, on perd la traçabilité), les erreurs sortent en 502 (Bad Gateway) plutôt que 500 (Internal Server Error) car la cause est une dépendance externe — cette précision aide les outils de monitoring à classer correctement l’incident. Test mental : si l’on appelle ?order_id=99999 sur une commande inexistante, le code retourne 404 sans toucher PayDunya, ce qui évite la pollution du tableau de bord agrégateur.

<?php
// public/ipn.php — webhook PayDunya
require dirname(__DIR__) . '/vendor/autoload.php';
use App\Bootstrap;
Bootstrap::init();

$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, true);

if (!$payload || empty($payload['data']['invoice']['token'])) {
    Bootstrap::$logger->warning('IPN malformé', ['body' => $rawBody]);
    http_response_code(400); exit;
}

$token = $payload['data']['invoice']['token'];
$status = $payload['data']['status'] ?? 'unknown';
$orderId = (int) ($payload['data']['custom_data']['order_id'] ?? 0);

// Idempotence — la contrainte UNIQUE(token, status) fait le travail
try {
    $stmt = Bootstrap::$db->prepare(
        'INSERT INTO payment_events (token, status, payload) VALUES (?, ?, ?::jsonb)'
    );
    $stmt->execute([$token, $status, json_encode($payload)]);
} catch (\PDOException $e) {
    if ($e->getCode() === '23505') {
        Bootstrap::$logger->info('IPN doublon ignoré', ['token' => $token]);
        http_response_code(200); echo 'Already processed'; exit;
    }
    throw $e;
}

// Double vérification — rappel API PayDunya pour confirmer
$confirm = new \Paydunya\Checkout\CheckoutInvoice();
if (!$confirm->confirm($token)) {
    Bootstrap::$logger->error('IPN refusée par confirm()', ['token' => $token]);
    http_response_code(400); exit;
}

if ($confirm->getStatus() === 'completed') {
    $amountConfirmed = (int) $confirm->getTotalAmount();
    $amountExpected = (int) Bootstrap::$db
        ->query("SELECT total_amount FROM orders WHERE id = {$orderId}")
        ->fetchColumn();

    if ($amountConfirmed !== $amountExpected) {
        Bootstrap::$logger->alert('Montant incohérent', [
            'order' => $orderId, 'expected' => $amountExpected, 'received' => $amountConfirmed
        ]);
        http_response_code(400); exit;
    }

    Bootstrap::$db
        ->prepare('UPDATE orders SET status = ?, paid_at = NOW() WHERE id = ? AND status = ?')
        ->execute(['paid', $orderId, 'pending']);

    Bootstrap::$logger->info('Commande payée', ['order' => $orderId, 'token' => $token]);
    // ici : déclencher mail de confirmation, livraison, etc.
}

http_response_code(200); echo 'OK';

Quatre garde-fous empilés sur ce webhook : validation du payload (rejet 400 si token absent), idempotence par contrainte SQL (rejet silencieux du doublon en 200), double vérification par rappel confirm() à PayDunya (rejet de la falsification), vérification du montant attendu vs reçu (rejet d’une éventuelle altération en transit). La mise à jour SQL utilise WHERE id = ? AND status = 'pending' pour ne jamais retransitionner une commande déjà paid ou refunded — défense en profondeur. Test mental : deux IPN concurrents pour le même token+status, un seul gagne la course de l’INSERT, l’autre attrape 23505 et répond 200 sans toucher la commande.

Workflow Node.js équivalent (Express + axios + better-sqlite3)

Le même flux en Node.js tient en un seul fichier de 90 lignes, avec Express pour le routage HTTP, axios pour les appels PayDunya, better-sqlite3 pour l’idempotence locale (à remplacer par PostgreSQL en production via le driver pg). Référence officielle PayDunya HTTP/JSON : developers.paydunya.com/doc/EN/http_json.

// server.js — npm install express axios better-sqlite3 dotenv pino
import express from 'express';
import axios from 'axios';
import Database from 'better-sqlite3';
import dotenv from 'dotenv';
import pino from 'pino';

dotenv.config();
const app = express();
app.use(express.json({ limit: '64kb' }));
const log = pino({ level: 'info' });
const db = new Database('./payments.db');
db.exec(`
  CREATE TABLE IF NOT EXISTS orders (
    id INTEGER PRIMARY KEY,
    total_amount INTEGER NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    paydunya_token TEXT
  );
  CREATE TABLE IF NOT EXISTS payment_events (
    id INTEGER PRIMARY KEY,
    token TEXT NOT NULL,
    status TEXT NOT NULL,
    payload TEXT NOT NULL,
    received_at TEXT DEFAULT (datetime('now')),
    UNIQUE(token, status)
  );
`);

const PAYDUNYA_BASE = process.env.PAYDUNYA_MODE === 'live'
  ? 'https://app.paydunya.com/api/v1'
  : 'https://app.paydunya.com/sandbox-api/v1';

const pdHeaders = {
  'PAYDUNYA-MASTER-KEY':  process.env.PAYDUNYA_MASTER_KEY,
  'PAYDUNYA-PRIVATE-KEY': process.env.PAYDUNYA_PRIVATE_KEY,
  'PAYDUNYA-TOKEN':       process.env.PAYDUNYA_TOKEN,
  'Content-Type':         'application/json'
};

app.post('/pay', async (req, res) => {
  const order = db.prepare('SELECT * FROM orders WHERE id = ? AND status = ?').get(req.body.order_id, 'pending');
  if (!order) return res.status(404).send('Commande introuvable');
  try {
    const { data } = await axios.post(`${PAYDUNYA_BASE}/checkout-invoice/create`, {
      invoice: { total_amount: order.total_amount, description: `Commande #${order.id}` },
      store: { name: process.env.STORE_NAME, website_url: process.env.APP_BASE_URL },
      custom_data: { order_id: String(order.id) },
      actions: {
        callback_url: `${process.env.APP_BASE_URL}/paydunya/ipn`,
        return_url:   `${process.env.APP_BASE_URL}/merci?order_id=${order.id}`,
        cancel_url:   `${process.env.APP_BASE_URL}/annulation?order_id=${order.id}`
      }
    }, { headers: pdHeaders, timeout: 30000 });
    if (data.response_code !== '00') return res.status(502).send(data.response_text);
    db.prepare('UPDATE orders SET paydunya_token = ? WHERE id = ?').run(data.token, order.id);
    log.info({ order_id: order.id, token: data.token }, 'invoice created');
    return res.redirect(303, data.response_text);  // response_text = invoice_url chez PayDunya
  } catch (e) {
    log.error({ err: e.message }, 'invoice creation failed');
    return res.status(502).send('Service de paiement indisponible');
  }
});

app.post('/paydunya/ipn', async (req, res) => {
  const token  = req.body?.data?.invoice?.token;
  const status = req.body?.data?.status;
  const orderId = parseInt(req.body?.data?.custom_data?.order_id, 10);
  if (!token || !status || !orderId) return res.status(400).end();

  try {
    db.prepare('INSERT INTO payment_events (token, status, payload) VALUES (?, ?, ?)')
      .run(token, status, JSON.stringify(req.body));
  } catch (e) {
    if (String(e).includes('UNIQUE')) { log.info({ token }, 'IPN doublon'); return res.send('ok'); }
    throw e;
  }

  const { data } = await axios.get(`${PAYDUNYA_BASE}/checkout-invoice/confirm/${token}`, { headers: pdHeaders, timeout: 30000 });
  if (data.status === 'completed') {
    const expected = db.prepare('SELECT total_amount FROM orders WHERE id = ?').get(orderId)?.total_amount;
    if (parseInt(data.invoice.total_amount, 10) !== expected) {
      log.warn({ orderId, expected, got: data.invoice.total_amount }, 'amount mismatch');
      return res.status(400).end();
    }
    db.prepare("UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'pending'").run(orderId);
    log.info({ orderId }, 'order paid');
  }
  res.send('ok');
});

app.listen(process.env.PORT || 8000);

Trois choix techniques distinguent ce code de la version PHP : better-sqlite3 est synchrone (pas de promises sur les opérations DB locales), ce qui simplifie le contrôle de flot dans le webhook ; le timeout HTTP est posé à 30 secondes côté axios pour aligner sur le même budget que la documentation PayDunya ; le code de redirection est 303 See Other pour forcer un GET sur la page agrégateur et éviter une réémission accidentelle du POST en cas de rafraîchissement. Test mental sur le webhook : deux IPN simultanés sur le même token, le second crashe sur la contrainte SQLite UNIQUE et retombe sur la branche « doublon » qui répond 200 sans rien faire — ce qui est exactement le comportement attendu et empêche le double traitement.

Erreurs fréquentes à éviter

ErreurCauseSolution
Marquer une commande payée sur le retour navigateurConfusion entre return_url et webhook IPNLe return_url affiche le merci, le webhook signe la commande payée — jamais l’inverse
Webhook traité plusieurs foisPas de table d’événementsAjouter contrainte unique sur (token, status) avant toute action métier
Clés API exposées sur GitHub.env oublié dans git add .Toujours ajouter .env au .gitignore avant le premier commit, scanner avec gitleaks
Timeouts trop courtsValeur par défaut de 5 secondesForcer 30 secondes — les confirmations Mobile Money sont lentes côté opérateur
Pas de logs en productionÉconomie prématuréeLogger systématiquement entrée et sortie de chaque appel API, conserver selon OHADA
Confondre montant client et montant netOubli de la commission agrégateur (1,5 à 3 %)Calculer le net dans la table d’audit, le confronter au virement reçu
Hardcoder les URLs de callbackCopier-coller depuis un tutorielToutes les URLs viennent de variables d’environnement, jamais en dur
Garder l’ancien nom Free Money sur la page de paiementPas de revue de catalogue depuis novembre 2024Mettre à jour libellés et logos vers Mixx by Yas, vérifier le code USSD #150#
Lister MTN comme opérateur SénégalTutoriel générique copié sans adaptationAu Sénégal, seuls Wave, Orange Money et Mixx by Yas couvrent l’essentiel — ne pas afficher MTN

Adaptation au contexte ouest-africain

Le terrain impose quelques spécificités qu’aucune doc officielle ne mentionne. Au Sénégal, le portefeuille Free Money a été renommé Mixx by Yas le 26 novembre 2024 (l’opérateur Free Sénégal lui-même est devenu Yas Senegal lors de la même opération de rebranding pan-africaine du groupe AXIAN Telecom). Le code USSD du wallet est désormais #150# au lieu de l’ancien code Free Money. Vérifiez que vos pages de paiement et vos communications client utilisent le nouveau nom, sous peine de confusion. MTN n’est pas présent au Sénégal contrairement à ce que beaucoup de tutoriels génériques laissent croire ; ne le proposez pas dans la liste des opérateurs sénégalais. Wave, Orange Money et Mixx by Yas couvrent l’essentiel du marché.

La bande passante reste un enjeu : un client à Tambacounda ou à Korhogo peut avoir 30 secondes de latence sur le confirme côté opérateur. Affichez un message d’attente explicite (« Confirmation en cours, ne fermez pas la page ») et un bouton « Vérifier le statut » plutôt qu’un simple spinner — votre support en sera reconnaissant. Côté hébergement webhook, un VPS à Hetzner Helsinki ou Contabo Düsseldorf reste le rapport qualité-prix imbattable pour 5 à 10 EUR/mois ; les hébergeurs locaux Sénégal/Côte d’Ivoire ont des prix supérieurs sans gain de latence significatif.

Pour les paiements de gros panier (au-delà de 200 000 FCFA), ne forcez jamais un paiement comptant unique : Wave et Mobile Money plafonnent les transactions journalières (souvent 500 000 à 1 000 000 FCFA selon le KYC du client). Proposez plutôt un paiement à la livraison ou un virement bancaire en complément — sans aucun mécanisme de crédit à intérêts. Cette discipline protège vos clients du surendettement et reste alignée avec les pratiques commerciales saines.

Tutoriels frères de la série

Foire aux questions

Combien coûtent les frais sur chaque transaction ?
PayDunya facture typiquement entre 1,5 % et 3 % selon l’opérateur Mobile Money, le volume mensuel et le profil de marchand ; CinetPay est dans le même ordre de grandeur. Wave Direct (sans agrégateur) reste le moins cher pour le Sénégal. Les grilles publiques évoluent — le tarif réel est négocié à l’ouverture du compte production et figure dans votre contrat. Exigez la grille tarifaire écrite avant de signer, et confrontez-la une fois par trimestre aux tarifs publics annoncés sur les sites des agrégateurs (les agrégateurs alignent rarement les anciens contrats vers le bas, c’est au marchand de demander la révision).

Faut-il être déclaré pour utiliser ces agrégateurs en production ?
Oui. PayDunya et CinetPay exigent un NINEA (Sénégal) ou équivalent et un RIB pour le compte production. Le sandbox est libre d’accès, mais aucun virement ne sortira tant que votre dossier KYC n’est pas validé.

Que faire si l’agrégateur a une panne pendant les soldes ?
Un circuit breaker automatique bascule vers le second agrégateur si vous en avez intégré deux. Sinon, affichez un message clair et activez le paiement à la livraison pendant la panne — perdre une commande est moins grave que la doubler par erreur.

Le webhook IPN est-il chiffré ?
Le canal HTTPS est obligatoire et garantit la confidentialité, mais l’origine n’est pas signée par défaut chez tous les agrégateurs. C’est la double vérification (rappel API depuis votre serveur) qui garantit l’authenticité — ne sautez jamais cette étape.

Combien de temps conserver les logs de paiement ?
Pour la conformité comptable OHADA en zone UEMOA, comptez 10 ans pour les pièces justificatives liées aux transactions. Pour les logs techniques bruts (payloads webhook, temps de réponse), 12 à 24 mois suffisent en pratique pour les besoins d’audit et de support.

Peut-on tester sans compte agrégateur ?
Non, le sandbox PayDunya ou CinetPay est obligatoire — aucun mock générique ne reproduit fidèlement les codes d’erreur ni les délais réels. La création du compte sandbox prend 10 minutes et est gratuite.

Dans la continuité

Création de site web sur mesure

Pack inclus : conception professionnelle, domaine et hébergement la première année, formation, support 6 mois, accès et code livrés.

À partir de 350 000 FCFA

📧 E-mail 💬 WhatsApp

Partager