Développement Web

PI-SPI BCEAO : intégrer les paiements instantanés UEMOA en Node.js pas-à-pas

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

📍 Article principal du cluster : Mobile money en backend 2026 — Wave, Orange Money, PayDunya, CinetPay
Cet article fait partie du cluster « Paiements Afrique ».

Introduction

Au Sénégal comme en Côte d’Ivoire, un client Orange Money ne pouvait pas payer directement un marchand chez Mixx by Yas sans passer par un retrait puis un dépôt : deux frais, deux étapes, une friction qui freinait l’adoption du paiement digital. Ce mur entre opérateurs n’existe plus depuis le 30 septembre 2025, date du lancement officiel de la PI-SPI — Plateforme Interopérable du Système de Paiement Instantané de la BCEAO.

Déployée dans les huit pays de l’UEMOA et obligatoire pour toutes les institutions financières à compter du 30 juin 2026, la PI-SPI est le hub centralisé qui relie banques, portefeuilles mobile money (Orange Money, Mixx by Yas, Moov Money) et microfinances sur un même rail de paiement en temps réel. Une seule intégration API suffit pour encaisser depuis n’importe quel wallet ou compte bancaire de la région.

Ce tutoriel vous guide pas-à-pas dans l’intégration de la PI-SPI en Node.js — de l’ouverture du sandbox à la réception d’un webhook de confirmation, en passant par l’authentification mTLS + OAuth2 et la construction d’un message ISO 20022.

Prérequis

  • Node.js 20 LTS minimum — nodejs.org
  • OpenSSL 3.x pour les certificats mTLS
  • Un compte sur le portail développeur : developer.pispi.bceao.int
  • Niveau intermédiaire en JavaScript backend, notions de base sur TLS/HTTPS
  • Temps estimé : 3 à 4 heures pour un premier end-to-end en sandbox

Étape 1 — Comprendre l’architecture hub de la PI-SPI

Avant d’écrire la première ligne de code, il est important de comprendre pourquoi la PI-SPI fonctionne différemment d’une intégration classique à l’API Orange Money ou Mixx by Yas.

Avant la PI-SPI, chaque opérateur exposait sa propre API avec son propre format. La PI-SPI introduit un modèle en hub centralisé : la BCEAO opère une plateforme unique qui sert d’intermédiaire universel. Votre application s’intègre une seule fois au hub et peut atteindre instantanément les 80 institutions connectées dans les huit pays de l’UEMOA.

Votre application
      │
      ▼
  Hub PI-SPI (BCEAO)
  ┌─────────────────────────────────────────┐
  │ Orange Money │ Mixx by Yas │ Moov Money │
  │ BOA │ Ecobank │ UBA │ Baobab │ Cofina   │
  └─────────────────────────────────────────┘

Les transactions supportées : compte à compte (bank-to-bank, wallet-to-wallet, bank-to-wallet), Request-to-Pay (RTP), QR Code interopérable et alias de compte (numéro de téléphone au lieu du RIB). Standards : ISO 20022 pour les messages, mTLS pour l’authentification réseau, OAuth2 pour les autorisations. Délai de traitement : moins de 10 secondes, disponible 24h/24, 7j/7.

Étape 2 — Créer votre compte sur le sandbox PI-SPI

Le sandbox public de la PI-SPI est accessible librement depuis le 14 novembre 2025 sur developer.pispi.bceao.int. Créez votre compte, validez votre e-mail et téléchargez depuis le tableau de bord :

  1. client_id et client_secret — credentials OAuth2
  2. client.crt et client.key — certificat mTLS et clé privée
  3. bceao-ca.crt — certificat racine BCEAO pour valider le serveur

Placez ces fichiers dans un répertoire certs/ et créez le fichier .env :

PISPI_CLIENT_ID=votre_client_id_sandbox
PISPI_CLIENT_SECRET=votre_client_secret_sandbox
PISPI_TOKEN_URL=https://auth.sandbox.pispi.bceao.int/oauth2/token
PISPI_API_BASE=https://api.sandbox.pispi.bceao.int/v1
CERT_PATH=./certs/client.crt
KEY_PATH=./certs/client.key
CA_PATH=./certs/bceao-ca.crt
npm install axios dotenv express uuid qrcode

Vérifiez l’environnement :

node -e "require('dotenv').config(); console.log(process.env.PISPI_CLIENT_ID)"

Si votre client_id s’affiche, l’environnement est prêt. L’erreur UNABLE_TO_VERIFY_LEAF_SIGNATURE indique que bceao-ca.crt ne correspond pas — retéléchargez-le depuis le portail.

Étape 3 — Authentification PI-SPI BCEAO : OAuth2 et mTLS en Node.js

La PI-SPI combine OAuth2 (autorisations applicatives) et mTLS (authentification mutuelle réseau). Chaque connexion HTTPS vers le hub implique que votre application présente son certificat client ET valide le certificat du serveur BCEAO — les deux parties se valident avant tout échange de données.

Créez src/auth.js :

require('dotenv').config();
const axios = require('axios');
const https = require('https');
const fs = require('fs');

const mtlsAgent = new https.Agent({
  cert: fs.readFileSync(process.env.CERT_PATH),
  key:  fs.readFileSync(process.env.KEY_PATH),
  ca:   fs.readFileSync(process.env.CA_PATH),
  rejectUnauthorized: true,
});

let cachedToken = null;
let tokenExpiry  = 0;

async function getAccessToken() {
  if (cachedToken && Date.now() < tokenExpiry - 30000) return cachedToken;

  const params = new URLSearchParams({
    grant_type:    'client_credentials',
    client_id:     process.env.PISPI_CLIENT_ID,
    client_secret: process.env.PISPI_CLIENT_SECRET,
    scope:         'pispi:payment pispi:alias pispi:rtp',
  });

  const response = await axios.post(process.env.PISPI_TOKEN_URL, params, {
    httpsAgent: mtlsAgent,
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  });

  cachedToken  = response.data.access_token;
  tokenExpiry  = Date.now() + response.data.expires_in * 1000;
  return cachedToken;
}

module.exports = { getAccessToken, mtlsAgent };

Ce code met en cache le token et le renouvelle automatiquement 30 secondes avant expiration, évitant un appel d’authentification à chaque transaction. Test rapide :

node -e "const {getAccessToken}=require('./src/auth'); getAccessToken().then(t=>console.log('OK:',t.slice(0,30)+'...')).catch(console.error)"

Étape 4 — Initier un Request-to-Pay (RTP)

Le Request-to-Pay est le flux principal pour un marchand : vous envoyez une demande de paiement à un numéro de téléphone, le propriétaire du wallet reçoit une notification push et valide depuis son application. Créez src/payment.js :

require('dotenv').config();
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
const { getAccessToken, mtlsAgent } = require('./auth');

async function initiateRTP({ amount, currency, debtorPhone, description, webhookUrl }) {
  const token     = await getAccessToken();
  const messageId = uuidv4().replace(/-/g, '').slice(0, 35);

  const payload = {
    msgId:    messageId,
    creDtTm:  new Date().toISOString(),
    initgPty: { nm: 'Mon Commerce', id: process.env.PISPI_CLIENT_ID },
    cdtrInf:  { alias: { tp: 'PHONE', id: debtorPhone } }, // format E.164 : +221771234567
    txInf: {
      amt:    { instdAmt: amount, ccy: currency || 'XOF' },
      purp:   description,
      cbkUrl: webhookUrl,
    },
  };

  const response = await axios.post(
    process.env.PISPI_API_BASE + '/rtp/initiate',
    payload,
    {
      httpsAgent: mtlsAgent,
      headers: {
        Authorization:    'Bearer ' + token,
        'Content-Type':   'application/json',
        'X-Request-ID':   messageId,
      },
    }
  );
  return response.data; // { rtpId, status: 'PENDING' }
}

module.exports = { initiateRTP };

La réponse en sandbox est { rtpId: "...", status: "PENDING" }. Le statut final (ACCEPTED ou REJECTED) arrive ensuite via webhook. Le payload s’inspire de l’ISO 20022 : msgId (identifiant unique max 35 caractères), creDtTm (horodatage ISO), cdtrInf.alias (alias destinataire), txInf.amt (montant en XOF).

Étape 5 — Gérer les alias de compte

Plutôt que demander un RIB ou un numéro de compte — inconnu de la plupart des utilisateurs — vous utilisez le numéro de téléphone comme identifiant universel. Le hub PI-SPI résout cet alias vers le compte ou wallet sous-jacent, quel que soit l’opérateur, et renvoie le nom de l’institution pour confirmation visuelle.

async function resolveAlias(phone) {
  const token = await getAccessToken();
  const response = await axios.get(
    process.env.PISPI_API_BASE + '/alias/resolve',
    {
      params: { type: 'PHONE', value: phone },
      httpsAgent: mtlsAgent,
      headers: { Authorization: 'Bearer ' + token },
    }
  );
  return response.data;
  // Ex: { accountId: "SN-ORANGE-...", institutionName: "Orange Finances Mobiles", accountType: "EME" }
}

Utilisez cette fonction avant de déclencher un RTP pour afficher à l’utilisateur : « Payer depuis votre compte Orange Finances Mobiles ? » — ce qui réduit les annulations et les erreurs de destinataire.

Étape 6 — Générer un QR Code interopérable

Pour les points de vente physiques, le QR code interopérable est la fonctionnalité la plus opérationnelle. Un seul QR affiché à la caisse est scannable depuis n’importe quelle app connectée à la PI-SPI (Orange Money, Mixx by Yas, Moov Money). Fini les multiples QR codes collés au mur.

const QRCode = require('qrcode');

async function generatePaymentQR({ amount, description }) {
  const token = await getAccessToken();
  const response = await axios.post(
    process.env.PISPI_API_BASE + '/qr/generate',
    { amount, currency: 'XOF', description, merchantId: process.env.PISPI_CLIENT_ID, expiresIn: 300 },
    { httpsAgent: mtlsAgent, headers: { Authorization: 'Bearer ' + token } }
  );
  const { qrPayload } = response.data; // chaîne encodée EMVCO
  const qrImageDataUrl = await QRCode.toDataURL(qrPayload); // base64 PNG
  return { qrPayload, qrImageDataUrl };
}

Affichez l’image côté marchand avec une balise <img> dont le src est le qrImageDataUrl retourné. Le QR expire après expiresIn secondes — prévoyez un bouton de rafraîchissement si votre caisse tourne longtemps sans transaction.

Étape 7 — Recevoir et valider les webhooks de confirmation

Les paiements PI-SPI sont asynchrones : vous initiez la transaction, puis le hub vous notifie du résultat via HTTP POST sur l’URL fournie dans la requête. Pour le développement local, utilisez npx ngrok http 3000 pour exposer votre serveur. Créez src/webhook.js :

require('dotenv').config();
const express = require('express');
const crypto  = require('crypto');

const app = express();

// IMPORTANT : conserver le raw body intact pour la vérification HMAC.
// JSON.stringify(req.body) ne reproduit pas forcément l'octet-pour-octet
// envoyé par le hub (ordre des clés, espaces) → la signature échouerait.
app.use('/webhook/pispi', express.raw({ type: 'application/json' }));

app.post('/webhook/pispi', (req, res) => {
  // 1. Vérifier la signature HMAC pour rejeter les requêtes frauduleuses
  const signature  = req.headers['x-pispi-signature'] || '';
  const rawBody    = req.body; // Buffer brut grâce à express.raw()
  const expectedSig = 'sha256=' + crypto
    .createHmac('sha256', process.env.PISPI_WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  // Comparaison timing-safe pour éviter les attaques par mesure de temps
  const a = Buffer.from(signature);
  const b = Buffer.from(expectedSig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    console.warn('Signature invalide — requête rejetée');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // 2. Parser le JSON après vérification de la signature
  const event = JSON.parse(rawBody.toString('utf8'));
  const { rtpId, status, amount, currency } = event;

  if (status === 'ACCEPTED') {
    console.log('Paiement accepté — ' + rtpId + ' : ' + amount + ' ' + currency);
    // Déclencher la livraison, mettre à jour la commande en base, etc.
  } else if (status === 'REJECTED') {
    console.warn('Paiement rejeté — ' + rtpId);
    // Notifier le client de l'échec
  }

  // 3. Répondre 200 immédiatement (le hub relance si pas de 200 dans 10 s)
  res.status(200).json({ received: true });
});

app.listen(3000, () => console.log('Webhook PI-SPI en écoute sur le port 3000'));

La vérification HMAC est indispensable : sans elle, n’importe qui peut envoyer de fausses confirmations et déclencher des livraisons non payées. La clé PISPI_WEBHOOK_SECRET est disponible dans votre tableau de bord sandbox.

Étape 8 — Tests en sandbox et checklist de mise en production

Le portail developer.pispi.bceao.int fournit des numéros de test simulant différents scénarios : paiement accepté, rejet pour solde insuffisant, timeout de l’opérateur. Lancez votre serveur webhook et exposez-le avec ngrok :

node src/webhook.js &
npx ngrok http 3000
# Copiez l'URL ngrok dans vos appels initiateRTP

Checklist avant la mise en production :

Élément Vérification
Institution connectée à la PI-SPI Vérifier la liste sur pispi.bceao.int
Certificats mTLS production Obtenus auprès de la BCEAO (pas ceux du sandbox)
Secrets en variables d’environnement Jamais hardcodés dans le code
Vérification signature webhook Testée sur ACCEPTED et REJECTED
Idempotence sur les webhooks Déduplication par rtpId en base de données
Timeout et retry Circuit breaker si le hub ne répond pas dans les 10 s

Erreurs fréquentes

Erreur Cause Solution
UNABLE_TO_VERIFY_LEAF_SIGNATURE Cert CA sandbox ne correspond pas Retélécharger bceao-ca.crt depuis le portail
401 Unauthorized Token expiré ou scope manquant Vérifier expires_in et ajouter pispi:rtp dans le scope
422 Alias not found Numéro non enregistré sur la PI-SPI L’institution du destinataire n’est pas encore connectée
Webhook jamais reçu URL non joignable depuis internet Utiliser ngrok en dev, IP fixe en prod
Double débit Pas d’idempotence sur retry Vérifier la duplication par rtpId avant de traiter

Adaptation au contexte ouest-africain

La PI-SPI est la pièce manquante pour les marchands et fintechs qui opèrent dans plusieurs pays de l’UEMOA. Un entrepreneur dakarois peut aujourd’hui encaisser depuis un compte Orange Money Sénégal, un wallet Mixx by Yas ou un compte Ecobank Abidjan — une seule intégration, zéro frais de change (tout l’espace UEMOA partage le franc CFA XOF).

Points importants pour éviter les confusions :

  • Wave est absent de la PI-SPI au 2 avril 2026, malgré sa position de leader au Sénégal (6-7 millions d’utilisateurs actifs). La deadline réglementaire du 30 juin 2026 devrait forcer la connexion de Wave. En attendant, les transactions vers les utilisateurs Wave ne passent pas par la PI-SPI.
  • MTN est présent sur la PI-SPI en Côte d’Ivoire mais n’opère pas au Sénégal.
  • Mixx by Yas (ex-Free Money, ex-Free Sénégal) est bien connecté sous le nom « Mobile Cash SA » au Sénégal.
  • Pour le développement freelance, le sandbox est gratuit. La production nécessite une institution partenaire : Baobab, Cofina, Ecobank, BOA, UBA sont parmi les acteurs connectés qui proposent un onboarding marchand.

Tutoriels frères

Pour aller plus loin

FAQ

La PI-SPI est-elle déjà active ou encore en phase pilote ?

La PI-SPI a été officiellement lancée le 30 septembre 2025 à Dakar. La phase pilote avait démarré le 22 juillet 2024 avec 25 institutions. À partir du 30 juin 2026, la connexion devient obligatoire pour toutes les institutions financières de l’UEMOA.

Puis-je intégrer la PI-SPI sans être une banque ou un établissement de paiement ?

Oui pour le sandbox : tout développeur peut s’inscrire sur developer.pispi.bceao.int et tester gratuitement. Pour la production, votre application doit passer par une institution connectée (banque, EME, SFD) qui expose une API s’appuyant sur la PI-SPI.

Quels opérateurs mobile money sont connectés à la PI-SPI au Sénégal ?

Au 2 avril 2026 : Orange Finances Mobiles, Mixx by Yas (ex-Free Money), Ecobank, BOA, UBA, Baobab, Cofina. Wave est absent de la PI-SPI malgré sa position de leader (6-7 millions d’utilisateurs actifs).

La PI-SPI fonctionne-t-elle pour les paiements transfrontaliers entre pays UEMOA ?

Oui. Un virement instantané d’un compte sénégalais vers un compte ivoirien fonctionne si les deux institutions sont connectées, sans frais de change (tous les pays UEMOA partagent le franc CFA XOF).

Quel est le montant maximum par transaction sur la PI-SPI ?

Les limites sont définies par chaque institution participante dans le cadre de la réglementation BCEAO. En sandbox, des plafonds de test de 1 000 000 XOF sont typiquement configurés. Vérifiez les limites de votre institution partenaire en production.

ISO 20022, c’est quoi concrètement pour un développeur ?

ISO 20022 est un standard international de messagerie financière qui définit un vocabulaire XML commun. La PI-SPI l’implémente : vos payloads JSON s’appuient sur les champs définis (msgId, creDtTm, cdtrInf, txInf.amt). Les messages clés sont pain.013 (Request-to-Pay) et pacs.008 (virement).

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité