Business Digital

API Mobile Money Afrique de l’Ouest 2026 : guide complet (Wave, Orange Money, Free Money, MTN)

31 min de lecture

En 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer), le paiement Mobile Money n’est plus une particularité africaine — c’est devenu le moyen de paiement dominant en Afrique de l’Ouest, devant la carte bancaire dans la plupart des pays CEDEAO. Au Sénégal, plus de 70 % des transactions B2C passent par Wave, Orange Money ou Mixx by Yas (ex-Free Money). En Côte d’Ivoire, c’est Orange Money et MTN MoMo qui dominent. Au Mali, Burkina Faso, Bénin et Togo, des écosystèmes similaires existent. Pour toute application e-commerce, SaaS B2C, marketplace ou outil métier qui veut être pris au sérieux dans la sous-région, intégrer Mobile Money n’est pas optionnel. Voici le guide pratique 2026 pour le faire correctement.

Ce guide général couvre tout : les acteurs, les API disponibles en 2026, les architectures de paiement, la conformité, la sécurité, et l’adaptation aux différents pays. Les articles connexes détaillent chaque API individuellement : intégration Wave Node.js, Orange Money Sénégal et Côte d’Ivoire, Mixx by Yas (ex-Free Money) Sénégal, et réconciliation multi-providers.

Panorama des acteurs Mobile Money en Afrique de l’Ouest

  • Wave — leader au Sénégal et en Côte d’Ivoire en 2026, transferts gratuits ou à coût très faible, app moderne, API marchand mature. Couvre aussi le Mali, Ouganda, Burkina Faso.
  • Orange Money — historique, présent dans tous les pays CEDEAO francophones (Sénégal, Côte d’Ivoire, Mali, Burkina, Guinée, Cameroun…). API marchand « Orange Developer » stable.
  • Mixx by Yas (ex-Free Money) — opérateur Free au Sénégal, parts de marché en croissance, API en développement actif
  • MTN MoMo — leader anglophone et Côte d’Ivoire, API « MoMo Developer » très bien documentée
  • Moov Money — présent en Côte d’Ivoire, Bénin, Togo
  • Wizall, M-Pesa Vodacom — niches

Pour le Sénégal, le combo gagnant en 2026 c’est Wave + Orange Money + Mixx by Yas (ex-Free Money) couvrant ~95 % de la population bancarisée mobile. Pour la Côte d’Ivoire, Wave + Orange Money + MTN MoMo. Pour les marchés mixtes, considérez aussi un agrégateur comme PayDunya, CinetPay ou Touch (qui rassemblent plusieurs providers en une seule API).

Direct vs agrégateur : quel choix

Deux approches d’intégration :

  • Intégration directe : vous appelez les API officielles de chaque opérateur (Wave, Orange, Free, MTN). Avantages : pas de marge intermédiaire, contrôle total, fiabilité maximum. Inconvénients : 3-5 intégrations à maintenir, comptes marchands séparés, KYC chez chaque opérateur.
  • Agrégateur : vous appelez UNE seule API (PayDunya, CinetPay, Touch, Paystack Africa…), qui route vers tous les providers. Avantages : intégration unique, tableau de bord unifié, KYC simplifié. Inconvénients : marge prélevée (1-3 %), dépendance à un tiers, parfois moins de contrôle sur la latence et les erreurs.

Recommandation : direct si vous avez du volume (> 1M FCFA/mois) et un développeur dédié ; agrégateur pour démarrer rapidement ou si volume modeste. Vous pouvez aussi commencer agrégateur et basculer en direct quand le volume justifie l’effort.

Architecture de paiement type

L’architecture standard d’un paiement Mobile Money en 2026 :

  1. Initiation côté client : votre app mobile/web envoie une requête à votre backend (montant, numéro client, référence commande)
  2. Création de transaction : votre backend appelle l’API de l’opérateur, reçoit un ID de transaction et une URL ou push à valider par l’utilisateur
  3. Validation utilisateur : l’utilisateur reçoit une notification dans son app Wave/Orange/etc., entre son code PIN, valide
  4. Webhook de confirmation : l’opérateur appelle votre URL de callback avec le statut (success / failed / pending)
  5. Traitement final : votre backend met à jour la commande, déclenche la livraison, envoie un reçu

Critique : ne validez JAMAIS la commande côté frontend (l’utilisateur peut tricher), toujours attendre le webhook de l’opérateur et vérifier sa signature.

Prérequis

  • Une entité légale enregistrée (entreprise/SARL au registre de commerce du pays cible)
  • RIB ou compte mobile money pour recevoir les fonds après agrégation
  • KYC : pièce d’identité du gérant, registre de commerce, parfois contrat de bail commercial
  • Une API backend (Node.js, Python, PHP, Go…) accessible publiquement en HTTPS pour recevoir les webhooks
  • Niveau attendu : intermédiaire à avancé
  • Temps : 1 à 2 semaines pour la première intégration end-to-end (incluant KYC)

Étape 1 — Créer votre compte marchand

Chaque opérateur a sa procédure :

  • Wave Sénégal/CI : portail business.wave.com → demande de compte marchand → KYC → contrat → activation. Délai : 5-15 jours.
  • Orange Money : passer par Orange Developer (developer.orange.com) pour les API + contrat marchand auprès de l’agence Orange Money B2B. Délai : 2-4 semaines.
  • Mixx by Yas (ex-Free Money) : contact commercial Free SA, dossier KYC, contrat. Délai : 2-3 semaines.
  • MTN MoMo : momodeveloper.mtn.com → sandbox immédiat → demande de production avec KYC. Délai : 2-3 semaines.
  • PayDunya / CinetPay / Touch (agrégateurs) : KYC en ligne, activation en quelques jours, signature numérique du contrat.

Démarrez les KYC en parallèle dès le début du projet. C’est souvent le chemin critique du planning.

Étape 2 — Sandbox et premier appel API

Chaque provider a son propre flow d’authentification, son format de payload et sa convention de redirection. La règle commune : tester d’abord en sandbox avec des montants symboliques, puis bascule production une fois l’idempotence et la réconciliation validées. Trois workflows complets ci-dessous, prêts à transposer en production.

Workflow Wave (Sénégal / Côte d’Ivoire / Mali / Burkina / Cameroun / Ouganda)

Wave utilise un Bearer token simple (clé API préfixée wave_sn_test_ ou wave_sn_prod_) et un schéma de redirection : on crée une checkout session, on redirige l’utilisateur vers la wave_launch_url retournée, et Wave envoie ensuite un webhook signé à votre callback. Aucun OAuth, aucun refresh token.

# Création d'une session checkout (sandbox)
curl -X POST https://api.wave.com/v1/checkout/sessions \
  -H "Authorization: Bearer $WAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "5000",
    "currency": "XOF",
    "success_url": "https://exemple.sn/wave/success",
    "error_url":   "https://exemple.sn/wave/error",
    "client_reference": "CMD-2026-001234"
  }'

# Réponse type :
# {
#   "id": "cs_AAAAA...",
#   "wave_launch_url": "https://pay.wave.com/c/cs_AAAAA...",
#   "status": "open",
#   ...
# }

Le champ client_reference est votre identifiant interne — il apparaît dans les rapports Wave et dans le webhook ultérieur, ce qui simplifie la réconciliation. La wave_launch_url est l’URL à laquelle vous redirigez le navigateur de l’utilisateur (mobile ou desktop). À la fin du paiement, Wave envoie un webhook signé HMAC-SHA256 — voir l’étape 3.

Workflow Orange Money (Sénégal, Côte d’Ivoire, Mali, Burkina, Cameroun)

Orange Developer expose une API REST avec OAuth 2.0 Client Credentials. Vous obtenez d’abord un access_token de courte durée (≈ 1 heure), puis vous l’utilisez pour initier un web payment qui renvoie une URL de paiement hébergée par Orange.

# Étape A — Obtenir un access_token OAuth 2.0
curl -X POST https://api.orange.com/oauth/v3/token \
  -H "Authorization: Basic $(echo -n $ORANGE_CLIENT_ID:$ORANGE_CLIENT_SECRET | base64)" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials"

# Réponse : { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600 }

# Étape B — Initier un Web Payment (montant en XOF, sandbox)
curl -X POST https://api.orange.com/orange-money-webpay/dev/v1/webpayment \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "merchant_key": "abcd1234",
    "currency": "OUV",
    "order_id": "CMD-2026-001234",
    "amount": 5000,
    "return_url":  "https://exemple.sn/orange/success",
    "cancel_url":  "https://exemple.sn/orange/cancel",
    "notif_url":   "https://exemple.sn/orange/webhook",
    "lang": "fr",
    "reference":   "BoutiqueXYZ"
  }'

# Réponse :
# { "status": 201, "payment_url": "https://webpayment.orange.com/...", "pay_token": "MP...", "notif_token": "..." }

La devise OUV correspond à XOF en sandbox Orange (test). En production, la devise effective est XOF ou OUV selon le pays et la version de l’API négociée — toujours vérifier sur l’agence Orange Money B2B locale. Le pay_token sert ensuite à interroger le statut via GET /orange-money-webpay/dev/v1/transactionstatus.

Workflow MTN MoMo Collections (Côte d’Ivoire, Ghana, Ouganda, Cameroun)

MTN MoMo a un flow plus complexe : (1) créer un API User en sandbox, (2) générer une API Key, (3) obtenir un Bearer token via Collections token endpoint, (4) initier le paiement avec requestToPay.

# Variables sandbox
BASE=https://sandbox.momodeveloper.mtn.com
SUB_KEY=$MOMO_COLLECTIONS_PRIMARY_KEY     # depuis le portail Developer
USER_ID=$(uuidgen)                         # UUID v4 que vous générez

# Étape A — Créer l'API User (sandbox uniquement, prod la créera Anthropic ahem MTN)
curl -X POST $BASE/v1_0/apiuser \
  -H "X-Reference-Id: $USER_ID" \
  -H "Ocp-Apim-Subscription-Key: $SUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"providerCallbackHost": "exemple.sn"}'

# Étape B — Générer une API Key pour cet utilisateur
curl -X POST $BASE/v1_0/apiuser/$USER_ID/apikey \
  -H "Ocp-Apim-Subscription-Key: $SUB_KEY"
# Réponse : { "apiKey": "..." }

# Étape C — Obtenir un Bearer token (Collections)
curl -X POST $BASE/collection/token/ \
  -H "Authorization: Basic $(echo -n $USER_ID:$API_KEY | base64)" \
  -H "Ocp-Apim-Subscription-Key: $SUB_KEY"
# Réponse : { "access_token": "eyJ...", "token_type": "access_token", "expires_in": 3600 }

# Étape D — Demander un paiement (requestToPay)
REF=$(uuidgen)
curl -X POST $BASE/collection/v1_0/requesttopay \
  -H "X-Reference-Id: $REF" \
  -H "X-Target-Environment: sandbox" \
  -H "Ocp-Apim-Subscription-Key: $SUB_KEY" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "5000",
    "currency": "EUR",
    "externalId": "CMD-2026-001234",
    "payer": { "partyIdType": "MSISDN", "partyId": "46733123450" },
    "payerMessage": "Paiement commande BoutiqueXYZ",
    "payeeNote": "Merci pour votre achat"
  }'

# Le statut se récupère ensuite avec :
curl -X GET $BASE/collection/v1_0/requesttopay/$REF \
  -H "Ocp-Apim-Subscription-Key: $SUB_KEY" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Target-Environment: sandbox"

En production, la devise dépend du pays (XOF pour la Côte d’Ivoire, GHS pour le Ghana, UGX pour l’Ouganda, etc.) et le X-Target-Environment bascule à la valeur du pays (par exemple mtnivorycoast). MTN expose deux modes : requestToPay (push : l’utilisateur reçoit un prompt USSD/app à valider) et un mode hosted équivalent à Wave/Orange.

Tous les opérateurs sérieux fournissent un environnement sandbox avec données fictives. Commencez là pour développer l’intégration sans risquer de vraies transactions :

# Exemple Wave (très simplifié)
curl -X POST https://api.wave.com/v1/checkout/sessions \
  -H "Authorization: Bearer wave_sandbox_test_key_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "currency": "XOF",
    "error_url": "https://exemple.sn/error",
    "success_url": "https://exemple.sn/success",
    "client_reference": "CMD-2026-001234"
  }'

# Réponse type :
# {
#   "id": "cs_abc123",
#   "wave_launch_url": "https://pay.wave.com/c/cs_abc123",
#   ...
# }

Voir notre tutoriel Wave Node.js pour le détail complet de l’intégration Wave avec gestion des webhooks et signatures.

Étape 2.5 — Intégration sur un site WordPress + WooCommerce

WooCommerce reste l’écrasante majorité du e-commerce francophone en Afrique de l’Ouest. Pour brancher un provider Mobile Money sur WooCommerce, on écrit un mini-plugin qui enregistre une payment gateway personnalisée. Le plugin se compose typiquement de cinq éléments : (1) une classe qui hérite de WC_Payment_Gateway, (2) le formulaire d’admin pour saisir les clés API, (3) la méthode process_payment qui crée la session côté provider et redirige le client, (4) un endpoint REST personnalisé pour recevoir le webhook, (5) la mise à jour du statut de la commande à la réception du webhook. Exemple complet avec Wave ci-dessous, transposable à Orange Money et MTN MoMo en changeant l’adapter.

Structure du plugin (3 fichiers)

wp-content/plugins/wc-gateway-wave/
├── wc-gateway-wave.php          # Bootstrap + hooks WP
├── includes/
│   ├── class-wc-gateway-wave.php  # Gateway WooCommerce
│   └── class-wave-webhook.php     # Endpoint REST + handler

1. Bootstrap (wc-gateway-wave.php)

<?php
/**
 * Plugin Name: WC Gateway Wave (Sénégal/CI)
 * Description: Passerelle de paiement Wave pour WooCommerce.
 * Version: 1.0.0
 * Requires Plugins: woocommerce
 */

if (!defined('ABSPATH')) exit;

add_action('plugins_loaded', function () {
    if (!class_exists('WC_Payment_Gateway')) return;
    require_once __DIR__ . '/includes/class-wc-gateway-wave.php';
    require_once __DIR__ . '/includes/class-wave-webhook.php';

    add_filter('woocommerce_payment_gateways', function ($methods) {
        $methods[] = 'WC_Gateway_Wave';
        return $methods;
    });

    // Enregistre la route REST /wp-json/wave/v1/webhook
    add_action('rest_api_init', ['WC_Wave_Webhook', 'register_routes']);
});

2. La gateway (class-wc-gateway-wave.php)

<?php
if (!defined('ABSPATH')) exit;

class WC_Gateway_Wave extends WC_Payment_Gateway {
    /** @var WC_Logger */ private $logger;

    public function __construct() {
        $this->id                 = 'wave';
        $this->icon               = plugins_url('assets/wave-logo.png', __FILE__);
        $this->method_title       = 'Wave';
        $this->method_description = 'Paiement Wave Sénégal / Côte d\'Ivoire / Mali / Burkina.';
        $this->has_fields         = false;
        $this->supports           = ['products', 'refunds'];

        $this->init_form_fields();
        $this->init_settings();

        $this->title          = $this->get_option('title', 'Wave');
        $this->description    = $this->get_option('description', 'Payer avec Wave');
        $this->api_key        = $this->get_option('api_key');
        $this->webhook_secret = $this->get_option('webhook_secret');

        add_action('woocommerce_update_options_payment_gateways_' . $this->id,
                   [$this, 'process_admin_options']);
    }

    public function init_form_fields() {
        $this->form_fields = [
            'enabled' => [
                'title'   => 'Activer',
                'type'    => 'checkbox',
                'default' => 'yes',
            ],
            'title' => [
                'title'   => 'Titre affiché au checkout',
                'type'    => 'text',
                'default' => 'Wave',
            ],
            'description' => [
                'title'   => 'Description',
                'type'    => 'textarea',
                'default' => 'Vous serez redirigé vers Wave pour valider le paiement.',
            ],
            'api_key' => [
                'title'       => 'Clé API Wave',
                'type'        => 'password',
                'description' => 'wave_sn_test_... ou wave_sn_prod_...',
            ],
            'webhook_secret' => [
                'title'       => 'Secret webhook',
                'type'        => 'password',
                'description' => 'Pour vérifier la signature HMAC.',
            ],
        ];
    }

    public function process_payment($order_id) {
        $order = wc_get_order($order_id);
        $total = $order->get_total();
        $body  = wp_json_encode([
            'amount'            => (string) intval($total),
            'currency'          => 'XOF',
            'success_url'       => $this->get_return_url($order),
            'error_url'         => wc_get_checkout_url(),
            'client_reference'  => (string) $order_id,
        ]);

        $resp = wp_remote_post('https://api.wave.com/v1/checkout/sessions', [
            'headers' => [
                'Authorization' => 'Bearer ' . $this->api_key,
                'Content-Type'  => 'application/json',
            ],
            'body'    => $body,
            'timeout' => 15,
        ]);

        if (is_wp_error($resp)) {
            wc_add_notice('Erreur connexion Wave : ' . $resp->get_error_message(), 'error');
            return;
        }
        $code = wp_remote_retrieve_response_code($resp);
        $data = json_decode(wp_remote_retrieve_body($resp), true);
        if ($code >= 400 || empty($data['wave_launch_url'])) {
            $order->add_order_note('Wave init KO (' . $code . ') : ' . wp_remote_retrieve_body($resp));
            wc_add_notice('Le service Wave est temporairement indisponible. Réessayez.', 'error');
            return;
        }

        $order->update_status('pending', 'En attente de paiement Wave');
        $order->update_meta_data('_wave_session_id', $data['id']);
        $order->save();

        return [
            'result'   => 'success',
            'redirect' => $data['wave_launch_url'],
        ];
    }
}

3. Endpoint webhook (class-wave-webhook.php)

<?php
if (!defined('ABSPATH')) exit;

class WC_Wave_Webhook {
    public static function register_routes() {
        register_rest_route('wave/v1', '/webhook', [
            'methods'             => 'POST',
            'callback'            => [__CLASS__, 'handle'],
            'permission_callback' => '__return_true', // signature HMAC vérifiée dans handle
        ]);
    }

    public static function handle(WP_REST_Request $req) {
        $raw    = $req->get_body();
        $header = $req->get_header('wave_signature') ?: '';
        $parts  = [];
        foreach (explode(',', $header) as $kv) {
            $a = explode('=', $kv, 2);
            if (count($a) === 2) $parts[$a[0]] = $a[1];
        }
        $ts = $parts['t']  ?? '';
        $v1 = $parts['v1'] ?? '';
        if (!$ts || !$v1 || abs(time() - intval($ts)) > 300) {
            return new WP_REST_Response(['error' => 'bad_signature'], 401);
        }

        $gw       = new WC_Gateway_Wave();
        $secret   = $gw->get_option('webhook_secret');
        $expected = hash_hmac('sha256', $ts . $raw, $secret);
        if (!hash_equals($expected, $v1)) {
            return new WP_REST_Response(['error' => 'mismatch'], 401);
        }

        $event = json_decode($raw, true);
        $type  = $event['type']  ?? '';
        $data  = $event['data']  ?? [];
        $clientRef = $data['client_reference'] ?? null;
        if (!$clientRef) return new WP_REST_Response(['ok' => true], 200);

        // Idempotence : option site pour ne traiter chaque event qu'une fois
        $eventId   = $event['id'] ?? '';
        $seen_opt  = 'wave_seen_events';
        $seen      = get_option($seen_opt, []);
        if (in_array($eventId, $seen, true)) {
            return new WP_REST_Response(['ok' => true, 'dup' => true], 200);
        }
        // Trim à 5000 derniers IDs pour ne pas exploser wp_options
        $seen[] = $eventId;
        if (count($seen) > 5000) array_shift($seen);
        update_option($seen_opt, $seen, false);

        $order = wc_get_order(intval($clientRef));
        if (!$order) return new WP_REST_Response(['ok' => true], 200);

        if ($type === 'checkout.session.completed' || $data['status'] === 'succeeded') {
            $order->payment_complete($data['id'] ?? '');
            $order->add_order_note('Paiement Wave confirmé : ' . ($data['id'] ?? ''));
        } elseif ($type === 'checkout.session.payment_failed' || $data['status'] === 'failed') {
            $order->update_status('failed', 'Paiement Wave échoué');
        }
        return new WP_REST_Response(['ok' => true], 200);
    }
}

Côté Wave, le webhook URL à configurer dans le portail business.wave.com est https://votresite.sn/wp-json/wave/v1/webhook. WordPress expose automatiquement cette route après activation du plugin. Pour tester en local : exposer votre WordPress local avec ngrok http 80 et utiliser l’URL temporaire dans la config Wave sandbox.

Adapter pour Orange Money et MTN MoMo

La structure reste identique — vous dupliquez le plugin en remplaçant trois éléments. Premièrement, l’appel API d’initiation : Orange impose un POST /oauth/v3/token préalable pour obtenir l’access_token (à cacher en transient WordPress pendant 3 500 secondes pour éviter de re-tokeniser à chaque commande), puis le POST /orange-money-webpay/dev/v1/webpayment. Pour MTN MoMo, on enchaîne la création d’API user (une seule fois, en activation du plugin), puis le POST /collection/v1_0/requesttopay. Deuxièmement, la vérification du webhook : Orange utilise le notif_token stocké en meta de commande (pas de HMAC), MTN MoMo impose un re-fetch du statut via GET /collection/v1_0/requesttopay/{refId} pour confirmer. Troisièmement, la conversion de devise et l’identifiant payeur (numéro de téléphone E.164 pour MTN, identifiant Orange Money pour Orange).

Si vous voulez les trois providers actifs simultanément sur la même boutique, WooCommerce les liste comme trois moyens de paiement distincts au checkout, et le client choisit le sien. Aucun conflit de hooks tant que chaque plugin enregistre sa propre route REST (/wave/v1, /orange/v1, /momo/v1) et son propre $id de gateway.

Hardening production-ready (corrections à appliquer avant déploiement)

Le code ci-dessus est volontairement compact pour la lisibilité. Avant de le déployer sur une boutique en production, six points critiques doivent être renforcés. Chaque point ci-dessous identifie le problème, son impact, et le correctif à appliquer.

1. Implémenter process_refund() obligatoire. La gateway déclare 'supports' => ['products', 'refunds'] mais ne fournit pas la méthode — un admin qui clique « Rembourser » dans wp-admin déclenche une erreur fatale. Wave expose POST /v1/checkout/sessions/{id}/refund qu’il faut câbler :

public function process_refund($order_id, $amount = null, $reason = '') {
    $order      = wc_get_order($order_id);
    $session_id = $order->get_meta('_wave_session_id');
    if (!$session_id) {
        return new WP_Error('no_session', 'Aucune session Wave liée à cette commande.');
    }
    $resp = wp_remote_post(
        "https://api.wave.com/v1/checkout/sessions/{$session_id}/refund",
        [
            'headers' => [
                'Authorization' => 'Bearer ' . $this->api_key,
                'Content-Type'  => 'application/json',
            ],
            'body'    => wp_json_encode(['amount' => (string) intval($amount)]),
            'timeout' => 15,
        ]
    );
    if (is_wp_error($resp)) {
        return new WP_Error('refund_failed', $resp->get_error_message());
    }
    if (wp_remote_retrieve_response_code($resp) >= 400) {
        return new WP_Error('refund_failed', wp_remote_retrieve_body($resp));
    }
    $order->add_order_note(sprintf('Remboursement Wave %s XOF appliqué — raison : %s', $amount, $reason));
    return true;
}

2. Race condition sur l’idempotence webhook. Le pattern get_option + update_option n’est pas atomique — deux webhooks arrivant en parallèle (Wave retente parfois après timeout) peuvent tous les deux passer le check de duplication et déclencher payment_complete() deux fois. Solution : utiliser wp_cache_add() avec une clé unique event_id, qui est atomique :

// Remplacer le bloc get_option/update_option par :
$lock_key = 'wave_evt_' . $eventId;
if (!wp_cache_add($lock_key, 1, 'wave_webhooks', 86400)) {
    return new WP_REST_Response(['ok' => true, 'dup' => true], 200);
}
// Persistance long terme dans une table custom ou en post_meta sur l'order
$order->update_meta_data('_wave_event_' . $eventId, time());

Si vous n’avez pas Redis/Memcached activé (cas mutualisé Hostinger), wp_cache_add dégrade en transient avec lock SQL INSERT IGNORE via une table custom — plus robuste que l’option array.

3. Check de longueur avant timingSafeEqual (handler Node.js). L’API Node.js crypto.timingSafeEqual throw si les deux buffers n’ont pas la même longueur. Un attaquant qui envoie un v1 de longueur arbitraire peut provoquer une 500 (déni de service léger). Encadrer :

const expBuf = Buffer.from(expected, 'hex');
const sigBuf = Buffer.from(sig, 'hex');
if (expBuf.length !== sigBuf.length || !crypto.timingSafeEqual(expBuf, sigBuf)) {
  return res.status(401).send('mismatch');
}

4. Currency hardcodée XOF. Une boutique configurée en EUR ou USD (cas fréquent pour les sites multi-pays) verra un mismatch silencieux Wave qui refuse le paiement. Le bon comportement : vérifier la devise du shop et refuser l’activation si elle ne correspond pas à Wave (XOF uniquement) :

public function is_available() {
    if (get_woocommerce_currency() !== 'XOF') return false;
    return parent::is_available();
}

5. Toggle sandbox/production explicite. Le préfixe de la clé (wave_sn_test_ vs wave_sn_prod_) permet de détecter l’environnement mais ce n’est pas robuste — un copier-coller maladroit met une clé prod sur un site staging. Ajouter un champ admin testmode et logger en évidence :

'testmode' => [
    'title'   => __('Mode test (sandbox)', 'wc-gateway-wave'),
    'type'    => 'checkbox',
    'default' => 'yes',
    'description' => __('Décocher seulement après validation en sandbox.', 'wc-gateway-wave'),
],
// puis dans __construct
$this->testmode = 'yes' === $this->get_option('testmode');
if ($this->testmode && strpos($this->api_key, '_prod_') !== false) {
    $this->logger->warning('Clé production utilisée en mode test', ['source' => 'wave']);
}

6. Logger WooCommerce systématique. Sans logger, un bug en prod est impossible à déboguer (les add_order_note ne sont visibles que par commande). Ajouter un WC_Logger au constructeur et logger les appels critiques :

// Dans __construct :
$this->logger = wc_get_logger();

// Dans process_payment, avant le wp_remote_post :
$this->logger->info(sprintf('Wave init payment order=%d amount=%s', $order_id, $total),
                    ['source' => 'wave']);

// Sur réponse erreur :
$this->logger->error('Wave init failed code=' . $code . ' body=' . substr($body, 0, 500),
                     ['source' => 'wave']);

Les logs sont consultables dans WooCommerce → État → Journaux, filtre source: wave. Pour un volume élevé, configurer une rotation quotidienne ou expédier vers un agrégateur (Papertrail, Better Stack, Loki).

Check-list finale avant mise en production

Avant la première transaction réelle, les huit points suivants doivent être validés sur l’environnement cible :

  1. Clé production stockée dans le secret manager (Vault, wp-config sécurisé chmod 600), jamais dans la base WordPress directement.
  2. Webhook URL HTTPS configurée dans le portail Wave/Orange/MTN et testée avec un appel sandbox réel (pas seulement curl local).
  3. IP whitelisting de votre serveur configuré côté provider (Wave production l’impose).
  4. Test de bout en bout en sandbox : commande → init → redirection → validation → webhook → payment_complete → email client → réception en table wp_postmeta.
  5. Test de timeout : bloquer artificiellement le webhook pendant 10 secondes et vérifier que Wave retente après timeout sans dupliquer la commande.
  6. Test de remboursement en sandbox sur une commande, vérifier que _wave_session_id est bien lu et que le add_order_note est ajouté.
  7. Monitoring : alerte Slack/email sur tout webhook qui passe par wc_get_logger()->error, ou sur tout 4xx/5xx du endpoint /wp-json/wave/v1/webhook via Better Stack ou équivalent.
  8. Réconciliation J+1 active depuis 48 heures avant la communication grand public du nouveau moyen de paiement, pour identifier les écarts éventuels sur du trafic test interne.

Cette check-list est dérivée du guide complet de mise en production Wave Business API — la version exhaustive y détaille chaque point avec ses pièges spécifiques.

Étape 3 — Webhooks et idempotence

Les webhooks sont la pièce centrale du flow : c’est le seul moyen fiable de savoir qu’un paiement a été effectivement validé. Trois règles non-négociables : (1) vérifier la signature HMAC, (2) traiter le webhook de manière idempotente, (3) répondre 200 en moins de 5 secondes.

Wave — vérification HMAC-SHA256 (Node.js Express)

const express = require('express');
const crypto = require('crypto');
const app = express();

// CRITIQUE : utiliser raw body pour préserver les bytes exacts du payload signé
app.post('/webhooks/wave',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.headers['wave-signature'] || '';
    const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
    const { t: ts, v1: sig } = parts;

    if (!ts || !sig) return res.status(401).send('missing signature');
    if (Math.abs(Date.now()/1000 - parseInt(ts)) > 300) return res.status(401).send('expired');

    const expected = crypto.createHmac('sha256', process.env.WAVE_WEBHOOK_SECRET)
      .update(ts + req.body.toString('utf8'))
      .digest('hex');
    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
      return res.status(401).send('mismatch');
    }

    // Idempotence : INSERT ... ON CONFLICT DO NOTHING sur event.id
    const event = JSON.parse(req.body.toString('utf8'));
    queueProcess(event);            // traitement async
    res.status(200).send('ok');     // ack immédiat
  });

Le format Wave : header Wave-Signature: t=<timestamp>,v1=<hmac>, signature = HMAC-SHA256 sur la concaténation directe du timestamp et du raw body (sans séparateur). Fenêtre anti-replay 5 minutes.

Orange Money — vérification du notif_token

Orange ne signe pas le webhook avec HMAC mais envoie un notif_token dans la requête de callback. Vous comparez ce token à celui reçu en réponse de l’API d’initiation (étape B) et stocké côté serveur lié à la commande.

from flask import Flask, request, abort
import os, json

app = Flask(__name__)

@app.post('/webhooks/orange')
def orange_webhook():
    payload = request.get_json()
    order_id = payload.get('order_id')
    notif_token = payload.get('notif_token')
    status = payload.get('status')   # SUCCESS / FAILED / EXPIRED / CANCELLED

    # Récupérer le notif_token stocké à l'initiation
    expected_token = db.fetch_notif_token(order_id)
    if not expected_token or notif_token != expected_token:
        abort(401)

    # Idempotence : ON CONFLICT DO NOTHING sur (order_id, status)
    db.upsert_payment_event(order_id, status, payload)
    if status == 'SUCCESS':
        fulfill_order(order_id)
    return ('', 200)

MTN MoMo — polling + callback URL signé X-Callback-URL

MTN MoMo n’utilise pas HMAC sur le webhook : vous fournissez votre providerCallbackHost à l’enregistrement et MTN POSTe le statut. La vérification se fait par : (1) re-fetch du statut via GET /collection/v1_0/requesttopay/{X-Reference-Id} pour confirmation, (2) match du externalId avec votre commande.

package main

import (
  "encoding/json"
  "net/http"
)

type MoMoEvent struct {
  Status     string `json:"status"`
  ExternalID string `json:"externalId"`
  FinancialTransactionID string `json:"financialTransactionId"`
}

func momoWebhook(w http.ResponseWriter, r *http.Request) {
  var ev MoMoEvent
  if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
    http.Error(w, "bad", http.StatusBadRequest); return
  }
  refID := r.Header.Get("X-Reference-Id")

  // CRITIQUE : re-fetch le statut depuis MTN pour confirmer (évite spoofing)
  realStatus, err := fetchMoMoStatus(refID)
  if err != nil || realStatus != ev.Status {
    http.Error(w, "unverified", http.StatusUnauthorized); return
  }
  // Idempotence sur (externalId, financialTransactionId)
  upsertPayment(ev.ExternalID, ev.Status, ev.FinancialTransactionID)
  w.WriteHeader(http.StatusOK)
}

Le pattern de double-check (callback reçu + re-fetch GET status) est la défense recommandée par MTN contre l’usurpation de callback, puisque la signature HMAC n’est pas implémentée côté Collections.

Les webhooks sont la pièce centrale. Règles d’or :

  • Vérifier la signature du webhook avec le secret partagé. Sans ça, n’importe qui peut envoyer un faux callback à votre URL.
  • Idempotence : votre handler doit pouvoir recevoir le même webhook 2 ou 3 fois sans dupliquer la transaction. Utilisez l’ID de transaction comme clef d’unicité en base.
  • Réponse 200 rapide : répondez 200 immédiatement et faites le traitement en background (queue), sinon l’opérateur considère le webhook échoué et le rejoue.
  • Logs exhaustifs : gardez tous les webhooks reçus en base pour debugging et conformité.
  • Replay protection : timestamp dans la signature, refuser si décalage > 5 minutes.

Étape 4 — Sécurité

  • Clefs API en variable d’environnement, jamais hardcodées
  • HTTPS partout, certificat valide
  • Rate limiting sur vos endpoints d’initiation pour éviter le spam ou les attaques
  • Audit log de chaque transaction (qui, montant, statut, timestamp, IP)
  • Validation montant côté backend : ne jamais faire confiance au montant envoyé par le frontend (l’utilisateur peut le modifier)
  • Anti-fraude basique : limites quotidiennes par utilisateur, détection patterns (X transactions identiques en 5 minutes)

Étape 5 — Multi-providers et fallback

Dès le second provider intégré, vous voulez une couche d’abstraction côté backend qui choisit le bon provider selon le contexte de l’utilisateur (préférence affichée, numéro téléphone, échec récent). Pattern simple : un router qui prend une commande + préférence et appelle l’adapter du provider correspondant.

// adapters/PaymentProvider.ts
export interface PaymentProvider {
  name: 'wave' | 'orange' | 'momo' | 'yas';
  init(order: Order): Promise<{ redirectUrl: string; providerRef: string }>;
  verifyWebhook(req: Request): Promise<{ status: string; orderId: string }>;
}

import { WaveAdapter } from './wave';
import { OrangeAdapter } from './orange';
import { MoMoAdapter } from './momo';
import { YasAdapter } from './yas';

const ADAPTERS: Record<string, PaymentProvider> = {
  wave:   new WaveAdapter(),
  orange: new OrangeAdapter(),
  momo:   new MoMoAdapter(),
  yas:    new YasAdapter(),
};

export async function initPayment(order: Order, preferred: string) {
  // 1. Essayer le provider préféré
  try {
    const r = await ADAPTERS[preferred].init(order);
    await logInit(order.id, preferred, r.providerRef);
    return r;
  } catch (e) {
    await logInitError(order.id, preferred, e);
    // 2. Fallback chain : ordre métier
    const fallbackChain = preferred === 'wave'
      ? ['orange', 'momo', 'yas']
      : ['wave', 'orange', 'momo', 'yas'].filter(p => p !== preferred);
    for (const p of fallbackChain) {
      try {
        const r = await ADAPTERS[p].init(order);
        await logInit(order.id, p, r.providerRef);
        return r;
      } catch (e2) {
        await logInitError(order.id, p, e2);
      }
    }
    throw new Error('all_providers_down');
  }
}

L’intérêt du fallback : si Wave ne répond pas pendant 30 secondes (timeout API ou maintenance), votre paiement bascule automatiquement sur Orange Money sans que l’utilisateur s’en rende compte. Le seul effet visible côté UX : le logo du provider de paiement change sur la page de redirection. Pour les marchés où Wave représente 70 % des transactions, cette résilience prévient les pertes massives lors des incidents.

Côté monitoring, tracer chaque tentative par provider avec son code de retour (succès, timeout, erreur API) permet de bâtir un dashboard Grafana qui affiche le taux de disponibilité par provider et le taux de fallback déclenché. Si Wave montre 5 % de fallback déclenchés sur une heure, vous avez probablement un incident côté Wave avant même que leur status page ne le signale.

Un utilisateur sénégalais peut avoir Wave OU Orange Money OU Mixx by Yas (ex-Free Money). Affichez les 3 boutons dans votre checkout, laissez l’utilisateur choisir. Architecture recommandée : un module d’abstraction PaymentProvider en backend qui expose une interface unique, avec une implémentation par opérateur :

interface PaymentProvider {
  initiate(amount: number, ref: string, customer: string): Promise<PaymentSession>;
  verify(transactionId: string): Promise<PaymentStatus>;
  handleWebhook(headers: Record<string, string>, body: string): Promise<PaymentEvent>;
}

class WaveProvider implements PaymentProvider { /* ... */ }
class OrangeMoneyProvider implements PaymentProvider { /* ... */ }
class FreeMoneyProvider implements PaymentProvider { /* ... */ }

// Routing
const provider = providers[chosenProviderName];
const session = await provider.initiate(5000, "CMD-001", "+221 77 XXX XX XX");

Cette abstraction simplifie aussi la migration vers un agrégateur, ou l’ajout d’un nouveau provider plus tard.

Étape 6 — Réconciliation

Un job cron quotidien (typiquement à 02h00 locale, avant l’arrivée du support) confronte vos transactions internes aux exports des opérateurs. Le scénario à anticiper : une transaction marquée payée en base via webhook, mais absente de l’agrégat journalier de l’opérateur (ou inversement). Sans contrôle quotidien automatisé, l’écart s’accumule en silence.

"""
Job de réconciliation multi-providers — exécution quotidienne 02h00.
Compare les transactions internes (DB) à l'export opérateur (API).
"""
import datetime as dt
from decimal import Decimal
from collections import defaultdict

def reconcile_yesterday():
    day = dt.date.today() - dt.timedelta(days=1)
    internal = db.fetch_payments_by_date(day)    # { provider: [PaymentRow] }
    external = {
        'wave':   wave_client.list_transactions(day),
        'orange': orange_client.list_transactions(day),
        'momo':   momo_client.list_transactions(day),
        'yas':    yas_client.list_transactions(day),
    }
    report = defaultdict(lambda: { 'match': 0, 'missing_external': [], 'missing_internal': [] })

    for provider, internal_rows in internal.items():
        ext_by_ref = { tx.client_reference: tx for tx in external[provider] }
        for row in internal_rows:
            ext = ext_by_ref.pop(row.client_reference, None)
            if not ext:
                report[provider]['missing_external'].append(row.client_reference)
            elif Decimal(ext.amount) != row.amount_xof:
                report[provider]['amount_mismatch'] = report[provider].get('amount_mismatch', [])
                report[provider]['amount_mismatch'].append((row.client_reference, row.amount_xof, ext.amount))
            else:
                report[provider]['match'] += 1
        # Transactions opérateur sans contrepartie interne
        report[provider]['missing_internal'] = list(ext_by_ref.keys())

    # 3 sorties scénarisées
    total_anomalies = sum(
        len(r['missing_external']) + len(r['missing_internal']) + len(r.get('amount_mismatch', []))
        for r in report.values()
    )
    if total_anomalies == 0:
        notify_slack('#payments', f"Reconciliation {day}: 100% match")
    elif total_anomalies < 10:
        notify_email('finance@exemple.sn', f"Reconciliation {day}: {total_anomalies} écarts", report)
    else:
        page_oncall(f"Reconciliation {day}: {total_anomalies} écarts — investigation requise", report)
    return report

Trois sorties scénarisées à coder explicitement : 100 % match (notification info), écart minoritaire (rapport email équipe finance), écart majoritaire (page-out astreinte + suspension nouvelles transactions le temps de comprendre). Le seuil exact (ici 10) dépend du volume journalier — pour 10 000 transactions/jour, ce seuil est plutôt à 50-100.

Tous les jours (ou heures pour les volumes élevés), vous devez réconcilier vos transactions internes avec ce que les opérateurs ont effectivement crédité. Sur les volumes, des écarts existent : transactions qui ont validé côté opérateur mais dont le webhook n’est pas arrivé, doublons, retours, frais. Notre guide réconciliation multi-providers détaille la méthodologie.

Conformité et fiscal

  • Sénégal : déclaration mensuelle TVA (18 % sur services numériques en B2B), IS sur bénéfices, redevance des opérateurs (intégrée à leurs frais)
  • Côte d’Ivoire : TVA 18 %, déclaration mensuelle
  • Mali, Burkina, Bénin, Togo : régimes proches CEDEAO/UEMOA, à valider avec votre comptable
  • Émission de reçus : obligatoire dans la plupart des pays, automatique idéalement (PDF + email/SMS)
  • Données personnelles : Sénégal CDP, Côte d’Ivoire ARTCI — déclaration d’un traitement de données en cas de stockage de numéros téléphone

Coûts moyens 2026

ProviderFrais marchandSetup feesNotes
Wave~1 % du montant0Gratuit pour utilisateur
Orange Money~2-3 % marchand + frais util.0-50 000 FCFAVariable selon contrat
Mixx by Yas (ex-Free Money)~1.5-2 %0En croissance
MTN MoMo~2 %0Côte d’Ivoire
PayDunya / CinetPay2.5-3.5 %0Tout-en-un

Adaptation Afrique de l’Ouest

Quelques points spécifiques à intégrer :

  • Numéros internationaux : toujours stocker en format E.164 avec préfixe pays (+221, +225, etc.)
  • Devise : UEMOA = XOF (FCFA), CEMAC = XAF, Ghana = GHS, Nigeria = NGN — adaptez selon le marché
  • Paiement en personne : option « paiement à la livraison » reste très utilisée, intégrez-la même si vous favorisez le digital
  • Lenteur réseau : prévoir un timeout long sur l’API (30-60 sec), retry intelligent
  • Multilingue : français, anglais, parfois arabe, plus langues locales (wolof, bambara) pour les SMS de confirmation

Erreurs fréquentes

ErreurCauseSolution
Webhook signature invalideMauvaise vérification HMACLire la doc, comparer en bytes (timing-safe)
Doublons de transactionPas d’idempotenceIndex unique sur transaction_id externe
Webhook reçu plusieurs foisRéponse non 200Répondre 200 immédiatement, traitement en queue
Montant frauduleuxConfiance au frontendCalculer le montant côté backend depuis l’ID commande
Crédit non reçuFrais opérateur non documentésVérifier la grille tarifaire complète
Déclaration TVA oubliéePas de processus comptableEngager un comptable agréé local

Sur le même thème

FAQ

Faut-il intégrer tous les providers ?

Pour un démarrage MVP, 1-2 providers majeurs (Wave + Orange Money au Sénégal) couvrent ~85-90 % du marché. Vous ajoutez les autres si la conversion baisse à cause d’utilisateurs qui n’ont qu’un seul provider.

Combien coûte vraiment l’intégration ?

Coût technique : 1-2 semaines de dev par provider en intégration directe, 2-3 jours via agrégateur. Coût opérateur : généralement 1-3 % par transaction, parfois frais de setup. Pour un volume mensuel de 5M FCFA, comptez 50 000-150 000 FCFA de frais opérateurs.

Qu’est-ce qu’un paiement halal en mobile money ?

Le paiement Mobile Money lui-même est neutre — c’est un moyen de transfert. La question de licéité concerne ce que vous vendez (produit halal) et la structure de votre prix (pas de riba dans les frais). Pour un marchand qui vend des produits ou services licites avec marge transparente, l’usage de Wave/Orange Money/Mixx by Yas (ex-Free Money) est conforme.

Peut-on faire du test avec un vrai compte ?

Oui, en sandbox vous testez avec des numéros et montants fictifs. Pour les premiers tests en production, utilisez votre propre numéro et de petits montants (1 000 FCFA). Documentez les résultats avant de scaler.

Partager