ITSkillsCenter
Business Digital

Wave Business Checkout API : encaissement web pas-à-pas

14 min de lecture

📍 Lecture connexe : Stripe, Paystack, Flutterwave et Wave en 2026 : intégrer un processeur de paiement — pour la vue d’ensemble du paysage paiement.

Wave Mobile Money est devenu en quelques années l’application financière la plus utilisée de la zone UEMOA. Son API Business expose un endpoint Checkout très direct : on crée une session, on redirige le client vers la page hébergée Wave, on reçoit un webhook quand le paiement est confirmé. La spécificité technique est la signature obligatoire des requêtes sortantes (en plus des webhooks entrants), qui place Wave dans la catégorie des PSP les plus exigeants en discipline d’opération. Ce tutoriel implémente une intégration complète en Node.js, avec la génération correcte des signatures HMAC et le déroulé d’un encaissement de bout en bout.

Prérequis

  • Node.js 22 LTS, vérifier avec node --version
  • Compte Wave Business actif, avec accès à business.wave.com
  • Une clé API Wave avec request signing activé (la clé et le signing secret sont affichés une seule fois à la création)
  • Une URL HTTPS publique pour les webhooks
  • Niveau attendu : intermédiaire
  • Temps estimé : environ 70 minutes

Étape 1 — Comprendre la signature des requêtes

Wave est l’un des rares PSP grand public à exiger une signature des requêtes sortantes du commerçant en plus des webhooks. La logique est simple : sans signature, un attaquant qui compromet une clé API peut faire des appels arbitraires. Avec signature, il lui faut aussi la signing secret, et celle-ci n’a jamais transité dans aucune requête HTTP — elle a été affichée une fois à la création et stockée localement.

Le format du header Wave-Signature est t=TIMESTAMP,v1=SIGNATURE, où TIMESTAMP est l’epoch en secondes et SIGNATURE est le HMAC-SHA256 hexadécimal du payload obtenu en concaténant directement timestamp et body. Pour une requête POST avec body, le payload signé est littéralement TIMESTAMP suivi du BODY_BRUT sans séparateur. Pour une requête GET sans body, c’est juste TIMESTAMP. Le serveur Wave rejette les signatures dont le timestamp est trop éloigné de l’instant courant (généralement quelques minutes), ce qui empêche les attaques par rejeu.

La documentation officielle Wave Business détaille ce mécanisme avec des exemples dans plusieurs langages. La complexité paraît élevée mais une fois encapsulée dans un client HTTP préconfiguré, on l’oublie complètement.

Étape 2 — Initialiser le projet

mkdir wave-checkout && cd wave-checkout
npm init -y
npm install express axios dotenv
npm install -D typescript tsx @types/node @types/express
npx tsc --init

On configure le fichier .env avec les credentials Wave. La clé API et le signing secret sont obtenus dans Settings → API Keys du tableau de bord business.wave.com. Si le signing n’a pas été activé à la création de la clé, il faut révoquer cette clé et en créer une nouvelle.

# .env
WAVE_API_KEY=wave_sn_test_...
WAVE_SIGNING_SECRET=...
WAVE_BASE=https://api.wave.com
WAVE_WEBHOOK_SECRET=...
APP_URL=http://localhost:3000

Le WAVE_WEBHOOK_SECRET est distinct du signing secret des requêtes sortantes. Wave génère un secret webhook spécifique à chaque endpoint configuré dans le tableau de bord. Cette séparation des secrets est conforme au principe de moindre privilège : compromettre le webhook secret ne donne pas la capacité d’émettre des requêtes au nom du commerçant.

Étape 3 — Construire un client HTTP signé

Plutôt que d’écrire la signature à chaque appel, on encapsule la logique dans un wrapper axios qui ajoute automatiquement le header Wave-Signature.

// src/wave.ts
import axios, { AxiosRequestConfig } from 'axios'
import crypto from 'node:crypto'
import 'dotenv/config'

const SECRET = process.env.WAVE_SIGNING_SECRET!

function sign(timestamp: number, body: string) {
  // Wave: payload = timestamp + body (concaténation directe, pas de séparateur)
  const payload = body ? `${timestamp}${body}` : `${timestamp}`
  return crypto.createHmac('sha256', SECRET).update(payload).digest('hex')
}

const wave = axios.create({ baseURL: process.env.WAVE_BASE })

wave.interceptors.request.use((config: AxiosRequestConfig) => {
  const ts = Math.floor(Date.now() / 1000)
  const body = config.data ? JSON.stringify(config.data) : ''
  const signature = sign(ts, body)
  config.headers = config.headers || {}
  config.headers['Authorization'] = `Bearer ${process.env.WAVE_API_KEY}`
  config.headers['Wave-Signature'] = `t=${ts},v1=${signature}`
  config.headers['Content-Type'] = 'application/json'
  return config
})

export { wave }

L’intercepteur axios calcule la signature juste avant l’envoi. Trois subtilités méritent attention. Le body est sérialisé manuellement avec JSON.stringify car axios pourrait modifier le formatage avant l’envoi, ce qui invaliderait la signature. La fonction sign traite le cas du GET sans body en concaténant uniquement le timestamp. Et l’horodatage est en secondes (epoch), pas en millisecondes — Wave rejette les signatures dont la précision dépasse la seconde.

Étape 4 — Créer une session de checkout

L’endpoint POST /v1/checkout/sessions crée la session de paiement et retourne une URL hébergée. La requête prend un montant, une devise (XOF, XAF, GHS selon le pays), une URL de retour et une URL d’annulation.

// src/server.ts
import express from 'express'
import { wave } from './wave'

const app = express()
app.use(express.json())

app.post('/api/checkout', async (req, res) => {
  const { amount, currency = 'XOF', clientReference } = req.body

  const r = await wave.post('/v1/checkout/sessions', {
    amount: String(amount),
    currency,
    error_url: `${process.env.APP_URL}/checkout/error`,
    success_url: `${process.env.APP_URL}/checkout/success`,
    client_reference: clientReference, // notre identifiant interne
  })

  // r.data contient { id, wave_launch_url, ... }
  res.json({
    sessionId: r.data.id,
    paymentUrl: r.data.wave_launch_url,
  })
})

Trois points sur la requête. Le champ amount est une chaîne de caractères, pas un nombre — Wave a fait ce choix pour éviter les pertes de précision en virgule flottante sur les très gros montants. Le client_reference est l’identifiant unique côté commerçant (un UUID préfixé) qui voyagera dans le webhook ; on l’utilise pour relier le paiement à la commande interne. Et l’URL de paiement (wave_launch_url) est temporaire — elle expire après quelques minutes si non consommée, ce qui empêche les liens stockés ou partagés indûment.

Étape 5 — Vérifier le webhook entrant

Quand le paiement est confirmé, Wave envoie un webhook checkout.session.completed. La signature de l’événement est calculée avec le webhook secret (différent du signing secret) et placée dans le header Wave-Signature avec le même format t=TIMESTAMP,v1=SIGNATURE.

app.post(
  '/webhooks/wave',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['wave-signature'] as string
    if (!sig) return res.status(400).send('missing sig')
    const [tPart, v1Part] = sig.split(',')
    const ts = parseInt(tPart.replace('t=', ''), 10)
    const got = v1Part.replace('v1=', '')

    // Wave applique une fenêtre asymétrique : 5 min dans le passé, 30 s dans le futur
    const now = Math.floor(Date.now() / 1000)
    if (ts < now - 300 || ts > now + 30) {
      return res.status(400).send('stale timestamp')
    }

    const expected = crypto
      .createHmac('sha256', process.env.WAVE_WEBHOOK_SECRET!)
      .update(`${ts}${req.body.toString()}`)
      .digest('hex')

    if (!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
      return res.status(401).send('bad sig')
    }

    const event = JSON.parse(req.body.toString())
    if (event.type === 'checkout.session.completed') {
      const session = event.data
      // Marquer la commande comme payée, idempotent par session.id
    }
    res.sendStatus(200)
  },
)

Le contrôle de fenêtre temporelle est ce qui fait la différence entre une signature simple et une protection contre le rejeu. Un attaquant qui capture un webhook valide ne peut pas le rejouer 10 minutes plus tard car le timestamp serait considéré comme périmé. La fenêtre de 300 secondes (5 minutes) est un compromis classique : assez tolérante pour absorber les dérives d’horloge, assez stricte pour empêcher les attaques.

L’utilisation de crypto.timingSafeEqual au lieu d’une comparaison === protège contre les attaques par mesure du temps de comparaison. Pour un endpoint paiement, ce niveau de paranoïa est justifié.

Étape 6 — Récupérer le détail d’une session

Pour les besoins de réconciliation, on doit pouvoir interroger Wave sur l’état d’une session via GET /v1/checkout/sessions/{id}. Le client signé qu’on a construit à l’étape 3 marche directement — l’intercepteur ajoute la signature appropriée pour les requêtes GET sans body.

app.get('/api/checkout/:id/status', async (req, res) => {
  const r = await wave.get(`/v1/checkout/sessions/${req.params.id}`)
  res.json({
    id: r.data.id,
    // payment_status : 'processing' | 'cancelled' | 'succeeded'
    status: r.data.payment_status,
    amount: r.data.amount,
    completedAt: r.data.when_completed,
  })
})

Cet endpoint est utile pour le job de réconciliation nocturne : on parcourt les sessions encore en processing de plus d’une heure, on les interroge, et on met à jour leur statut. C’est une assurance contre les webhooks perdus en cas d’indisponibilité serveur prolongée.

Étape 7 — Tester le scénario complet

Wave fournit un environnement sandbox accessible via https://api.wave.com avec des clés wave_sn_test_. Les paiements sandbox sont effectués avec des numéros de test fournis par le support après ouverture du compte. La page hébergée affiche les méthodes de paiement (Wave, Orange Money via partenariat, et selon le pays).

Le scénario type : POST /api/checkout avec montant 100 et email. Récupérer la paymentUrl, l’ouvrir dans le navigateur, suivre le flux de paiement test. Une fois le paiement confirmé sur la page Wave, le webhook arrive et marque la session complétée. La requête GET /api/checkout/:id/status retourne completed. Tout cela doit s’enchaîner en moins de 10 secondes en environnement de test.

Étape 8 — Préparer la production

Le passage en mode live demande quelques précautions spécifiques à Wave. Régénérer une clé API en mode production avec request signing activé. Stocker le signing secret dans un coffre dédié (jamais dans une variable d’environnement loggable). Configurer une URL de webhook dédiée sur business.wave.com et copier le webhook secret généré. Vérifier que l’URL de webhook est accessible en HTTPS avec un certificat valide ; Wave refuse les certificats auto-signés en mode live.

Côté observabilité, on instrumente la latence des appels Wave (cible 95e percentile sous 600 ms en zone UEMOA), le taux de succès des sessions (cible > 92 %, plus bas que les cartes classiques car le mobile money a plus de frictions UX), et le délai webhook-traitement. Une alerte sur volume horaire effondré (par exemple, moins de la moitié de la moyenne 7 jours pour le même créneau) attrape les pannes silencieuses.

Étape 9 — Gérer les payouts (versements)

L’API Wave Business expose aussi un endpoint POST /v1/payout pour verser des fonds depuis le solde commerçant vers un numéro Wave ou Orange Money. Cas d’usage typiques : remboursement après annulation de commande, paiement de partenaires, distribution de gains à des utilisateurs d’une plateforme. La signature des requêtes fonctionne identiquement à l’API Checkout, et un webhook payout.completed notifie le résultat.

Pour un déroulé complet de la Payout API en Laravel avec gestion des erreurs spécifiques aux payouts (numéro inexistant, solde insuffisant, plafond journalier dépassé), un tutoriel dédié existe sur le blog : Wave Payout API en Laravel.

Étape 10 — Rotation des clés et gestion des secrets

La compromission d’une clé API Wave ou de son signing secret ouvre la porte à des opérations frauduleuses. Une politique de rotation systématique limite la fenêtre d’exposition. La cadence recommandée est de 6 à 12 mois pour les clés non incidentes, immédiate en cas de soupçon de fuite.

La rotation se fait en deux temps. Premier temps, on génère une nouvelle clé API depuis le tableau de bord business.wave.com avec un nom incluant la date (prod-2026-05-clean). On configure la nouvelle clé dans le coffre de secrets sans encore l’utiliser. Deuxième temps, on bascule l’application via une variable d’environnement WAVE_API_KEY_VERSION=v2 et on observe le bon fonctionnement pendant 24 à 48 heures. Une fois la confiance établie, on révoque l’ancienne clé.

Cette procédure tient en quelques heures de travail mais évite l’erreur classique du « j’ai changé la clé en production le vendredi soir et tout est tombé ». La discipline d’avoir toujours deux clés actives en parallèle pendant la fenêtre de transition est ce qui fait la différence entre un déploiement maîtrisé et un incident.

Étape 11 — Réconciliation comptable

Comme pour tous les PSP, la réconciliation entre les paiements Wave et la comptabilité interne est un processus à automatiser. L’endpoint GET /v1/checkout/sessions avec filtre temporel retourne les sessions de la fenêtre demandée. Un job nocturne parcourt cette liste et la croise avec la table de transactions interne ; les écarts sont logués et notifiés.

Wave applique des frais marchands variables selon le pays et le volume. Pour un calcul comptable précis, on récupère le montant net effectivement crédité au compte Wave (amount_received dans la session) et on enregistre la différence avec le montant brut (amount) comme charges bancaires. Cette ventilation alimente le compte de charges bancaires en comptabilité.

Les payouts émis depuis le solde Wave (remboursements ou versements partenaires) sortent du compte Wave et apparaissent comme sortie de trésorerie. La comptabilisation correcte distingue ces sorties des frais bancaires et des annulations de vente, ce qui demande de tagger chaque transaction Wave côté commerçant avec son type (incoming_payment, refund, partner_payout).

Erreurs fréquentes

Erreur Cause Solution
401 systématique sur tous les appels Signature mal calculée (séparateur point manquant) Format obligatoire : TIMESTAMP.BODY ou TIMESTAMP seul pour GET
« Stale timestamp » Horloge serveur déphasée de plus de 5 min Activer NTP sur le serveur, viser une dérive sous 1 seconde
Webhook signature OK mais session inconnue côté commerçant client_reference non stocké à la création Toujours sauvegarder la session ID renvoyée AVANT de retourner au client
Body modifié par middleware express.json a parsé avant la vérif HMAC Utiliser express.raw sur la route webhook
Session expirée à l’ouverture L’URL de paiement a une durée de vie limitée Créer la session juste avant la redirection, pas en amont

Étape 12 — Limites par défaut et plafonds

Wave applique plusieurs plafonds qui méritent d’être anticipés. Un plafond par transaction limite le montant d’une session checkout unique ; il dépend du pays et du type de compte commerçant, généralement entre 500 000 et 5 000 000 de XOF. Un plafond journalier cumulé encadre le volume d’encaissement par compte. Un plafond mensuel sur la quantité de payouts évite les abus.

L’application doit anticiper ces limites côté code. Avant de créer une session pour un montant élevé, on vérifie via l’API GET /v1/checkout/sessions filtrée sur la journée le cumul déjà encaissé. Si le seuil sera dépassé, on propose au client une autre méthode (Flutterwave, Stripe) plutôt que d’attendre un échec côté Wave qui sera moins explicite. Pour les payouts, la même logique s’applique : un payout proche du plafond mensuel doit être différé ou subdivisé.

Pour les commerçants à fort volume, Wave permet de demander un relèvement de plafond auprès du support après vérification renforcée du compte. Cette procédure prend généralement 2 à 5 jours ouvrés et exige des justificatifs commerciaux (extraits de comptes, factures clients, attestation comptable). Mieux vaut entamer la procédure avant d’atteindre les plafonds plutôt qu’au milieu d’un pic d’activité.

Étape 13 — Suivi des frais et marges

Le suivi des frais Wave réels permet de comparer avec les autres PSP et d’arbitrer la stratégie de routage. Chaque session terminée renvoie le détail des frais facturés ; on les enregistre en base avec colonne dédiée pour pouvoir agréger par jour, par mois, par segment de clientèle. Cette donnée alimente directement le dashboard de marge brute par PSP.

Ressources

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é