ITSkillsCenter
Développement Web

Hono + Wave et Orange Money : webhooks signés et idempotence 2026

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

📍 Article principal : Hono framework TypeScript en production 2026

Introduction

Une plateforme e-commerce de Dakar utilisée pour la livraison de courses traite environ 1 200 paiements Wave et Orange Money par jour. Pendant trois semaines en novembre 2025, l’équipe a observé un phénomène curieux : 4 % des commandes apparaissaient en double dans le tableau de bord, alors que les clients n’avaient cliqué qu’une fois. Le diagnostic a révélé que les webhooks Wave étaient parfois rejoués deux fois (comportement normal du provider en cas de timeout côté serveur), et que le code n’avait aucune protection contre les rejeux. Ce tutoriel construit l’intégration que cette plateforme aurait dû avoir dès le départ : webhooks signés, idempotence stricte, retry exponentiel, et audit trail conforme aux exigences UEMOA. À la fin, vous avez un endpoint Hono qui ne perd aucun paiement, n’en compte aucun en double, et trace tout en cas de litige avec un client ou avec le PSP.

Prérequis

  • Compte marchand Wave Direct (validation par Wave Senegal SA)
  • Compte marchand Orange Money (Sonatel, Orange CI, ou agrégateur PayDunya/CinetPay)
  • API Hono fonctionnelle avec base PostgreSQL
  • Hébergement HTTPS public (Workers ou VPS Hetzner)
  • Niveau : intermédiaire avancé — Temps : 3 heures

Étape 1 — Architecture des paiements

Trois entités à modéliser. Commandes : ce que le client achète. Tentatives de paiement : un ou plusieurs essais par commande, chacun avec un identifiant unique côté nous (que nous envoyons au PSP) et un identifiant côté PSP. Webhooks reçus : log immuable de chaque notification entrante, qu’elle soit traitée avec succès, rejetée, ou en erreur. La séparation entre tentatives et webhooks reçus est cruciale : un même webhook peut arriver plusieurs fois, mais on ne crée jamais de tentative en double.

// db/schema.ts (extrait)
export const tentativesPaiement = pgTable('tentatives_paiement', {
  id: text('id').primaryKey(),                    // ULID que nous générons
  commandeId: text('commande_id').notNull(),
  pspNom: text('psp_nom').notNull(),               // 'wave' | 'orange_money' | 'free_money'
  pspReference: text('psp_reference'),             // ID retourné par le PSP
  montantXof: integer('montant_xof').notNull(),
  statut: text('statut').notNull(),                // 'initiee' | 'reussie' | 'echouee' | 'expiree'
  initieeLe: timestamp('initiee_le').defaultNow().notNull(),
  termineeLe: timestamp('terminee_le')
});

export const webhooksRecus = pgTable('webhooks_recus', {
  id: text('id').primaryKey(),
  pspNom: text('psp_nom').notNull(),
  signatureValide: boolean('signature_valide').notNull(),
  payloadBrut: text('payload_brut').notNull(),
  recuLe: timestamp('recu_le').defaultNow().notNull(),
  traiteLe: timestamp('traite_le'),
  resultat: text('resultat')                        // 'applique' | 'duplique' | 'erreur'
});

Étape 2 — Initier un paiement Wave

import { ulid } from 'ulid';

app.post('/api/paiements/wave', requireAuth, async (c) => {
  const userId = c.get('userId');
  const { commandeId, montantXof } = await c.req.json();

  const tentativeId = ulid();
  await db.insert(tentativesPaiement).values({
    id: tentativeId, commandeId, pspNom: 'wave', montantXof, statut: 'initiee'
  });

  const r = await fetch('https://api.wave.com/v1/checkout/sessions', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + c.env.WAVE_API_KEY,
      'Content-Type': 'application/json',
      'Idempotency-Key': tentativeId
    },
    body: JSON.stringify({
      amount: String(montantXof),
      currency: 'XOF',
      success_url: 'https://example.sn/paiement/succes?t=' + tentativeId,
      error_url: 'https://example.sn/paiement/erreur?t=' + tentativeId,
      client_reference: tentativeId
    })
  });

  if (!r.ok) {
    await db.update(tentativesPaiement).set({ statut: 'echouee' }).where(eq(tentativesPaiement.id, tentativeId));
    return c.json({ erreur: 'Initiation échouée' }, 502);
  }

  const session = await r.json();
  await db.update(tentativesPaiement).set({ pspReference: session.id }).where(eq(tentativesPaiement.id, tentativeId));
  return c.json({ url: session.wave_launch_url, tentativeId });
});

Trois précautions visibles. Premièrement, Idempotency-Key côté Wave — si la requête est rejouée (timeout, retry réseau), Wave ne crée pas deux sessions de paiement. Deuxièmement, client_reference = notre ID interne — le webhook nous reviendra avec cet ID et nous saurons exactement à quoi le rattacher. Troisièmement, on enregistre la tentative en base AVANT d’appeler Wave — ainsi même si l’appel échoue, la trace existe pour audit.

Étape 3 — Endpoint webhook Wave avec signature

import { createHmac, timingSafeEqual } from 'node:crypto';

app.post('/api/webhooks/wave', async (c) => {
  const signatureRecue = c.req.header('Wave-Signature') || '';
  const corpsBrut = await c.req.text();

  // Vérification HMAC SHA-256
  const calculee = createHmac('sha256', c.env.WAVE_WEBHOOK_SECRET).update(corpsBrut).digest('hex');
  const valide = signatureRecue.length === calculee.length
    && timingSafeEqual(Buffer.from(signatureRecue), Buffer.from(calculee));

  // Tracer le webhook quoi qu'il arrive
  const webhookId = ulid();
  await db.insert(webhooksRecus).values({
    id: webhookId, pspNom: 'wave', signatureValide: valide,
    payloadBrut: corpsBrut
  });

  if (!valide) return c.json({ erreur: 'Signature invalide' }, 401);

  const evt = JSON.parse(corpsBrut);
  const tentativeId = evt.data?.client_reference;
  if (!tentativeId) {
    await db.update(webhooksRecus).set({ traiteLe: new Date(), resultat: 'erreur' }).where(eq(webhooksRecus.id, webhookId));
    return c.json({ erreur: 'client_reference manquant' }, 400);
  }

  // Idempotence : récupérer la tentative et vérifier le statut actuel
  const t = (await db.select().from(tentativesPaiement).where(eq(tentativesPaiement.id, tentativeId)).limit(1))[0];
  if (!t) {
    await db.update(webhooksRecus).set({ traiteLe: new Date(), resultat: 'erreur' }).where(eq(webhooksRecus.id, webhookId));
    return c.json({ erreur: 'Tentative inconnue' }, 404);
  }

  if (t.statut === 'reussie' || t.statut === 'echouee') {
    // Webhook déjà traité, on accuse réception sans rien faire
    await db.update(webhooksRecus).set({ traiteLe: new Date(), resultat: 'duplique' }).where(eq(webhooksRecus.id, webhookId));
    return c.json({ ok: true, deja_traite: true });
  }

  if (evt.type === 'checkout.session.completed' && evt.data.payment_status === 'succeeded') {
    await db.transaction(async (tx) => {
      await tx.update(tentativesPaiement).set({ statut: 'reussie', termineeLe: new Date() }).where(eq(tentativesPaiement.id, tentativeId));
      await tx.update(commandes).set({ statut: 'payee' }).where(eq(commandes.id, t.commandeId));
    });
    await db.update(webhooksRecus).set({ traiteLe: new Date(), resultat: 'applique' }).where(eq(webhooksRecus.id, webhookId));
  } else {
    await db.update(tentativesPaiement).set({ statut: 'echouee' }).where(eq(tentativesPaiement.id, tentativeId));
    await db.update(webhooksRecus).set({ traiteLe: new Date(), resultat: 'applique' }).where(eq(webhooksRecus.id, webhookId));
  }

  return c.json({ ok: true });
});

La signature HMAC SHA-256 est vérifiée avec timingSafeEqual qui résiste au timing attack. La séquence « log d’abord, vérifier ensuite, traiter en transaction » garantit qu’aucun webhook légitime ne se perd même si une étape échoue. La transaction sur la mise à jour tentative + commande est cruciale : on ne peut pas avoir une tentative réussie sans commande payée, ni l’inverse.

Étape 4 — Orange Money : flux similaire avec différences

Orange Money expose une API REST avec des subtilités spécifiques. Le flux global ressemble à Wave : on initie, on redirige le client, on reçoit un webhook (ou on poll l’endpoint de statut). Différences principales : la signature webhook utilise une clé partagée différente (variable séparée), le format JSON est moins riche que Wave (moins de métadonnées), et le statut peut être pending longtemps avant de basculer en success ou failed selon la connectivité du client.

Le piège classique : ne pas attendre indéfiniment un statut pending. Configurer un cron toutes les 5 minutes qui interroge le statut de toutes les tentatives en cours depuis plus de 10 minutes via l’endpoint de statut Orange Money. Si le PSP confirme un paiement réussi alors que le webhook ne nous est jamais arrivé, on traite normalement et on évite de marquer comme échouée une tentative en réalité validée.

Étape 5 — Réconciliation périodique

Pour les paiements à enjeu, ne jamais se contenter du webhook. Une réconciliation horaire qui compare le journal des tentatives de la dernière heure avec le rapport API du PSP détecte les divergences. Sur Wave, l’endpoint GET /v1/checkout/sessions/{id} donne le statut faisant autorité. Si une tentative est marquée initiee en base alors que le PSP la dit succeeded, c’est qu’on a perdu le webhook : on rejoue le traitement comme si on l’avait reçu. Inversement, une tentative marquée reussie que le PSP dit failed indique une fraude potentielle ou un bug — alerte immédiate aux admins.

Erreurs fréquentes

Erreur Cause Solution
Commandes en double Webhook rejoué sans idempotence Vérifier le statut courant avant action
Signature invalide en prod uniquement Whitespace inséré par le proxy Lire le body avec c.req.text() sans parsing
Webhook timeout PSP Traitement synchrone trop long Répondre 200 vite, déléguer le travail à une queue
Statut pending infini Pas de polling de réconciliation Cron 5 min sur tentatives anciennes
Variable WEBHOOK_SECRET exposée Logguée par erreur Auditer logs, rotation immédiate du secret
Rejet webhook valide Mauvais encodage du body UTF-8 strict, pas de transformation avant HMAC

Adaptation au contexte ouest-africain

Trois aspects spécifiques. Premièrement, les pannes Sonatel ou Orange CI peuvent provoquer des retards de webhooks de plusieurs heures — la réconciliation périodique est non négociable, jamais « on fait sans, ça ira ». Deuxièmement, les clients en zones rurales (Touba, Korhogo, Bobo-Dioulasso) peuvent avoir une connexion qui se rétablit après que le PSP a déjà notifié notre serveur — d’où la page de confirmation côté UI qui interroge le statut au lieu de croire le redirect du PSP. Troisièmement, pour la conformité avec la loi sénégalaise sur les paiements électroniques et les directives BCEAO, on conserve les payloads bruts des webhooks 7 ans (obligation comptable), idéalement archivés sur Hetzner Storage Box ou Backblaze B2 chiffrés.

Côté agrégateurs (PayDunya, CinetPay, NaftaGo), le pattern reste identique mais les noms de champs varient. PayDunya envoie token à la place de client_reference, CinetPay utilise cpm_trans_id. On encapsule chaque PSP dans son propre adaptateur Hono qui normalise vers notre format interne : on isole les différences en un seul endroit, le code métier reste agnostique.

Tutoriels frères

Pour aller plus loin

FAQ

Pourquoi ne pas se contenter du redirect URL pour confirmer le paiement ?
Le redirect peut être manqué (le client ferme l’onglet, sa 4G coupe). Le webhook est la source de vérité côté serveur. Le redirect sert juste à afficher la page de confirmation côté UI.

Faut-il chiffrer les payloads bruts en base ?
Les payloads webhook ne contiennent généralement pas de données sensibles client (numéro de carte, CVV) car les PSP ne nous transmettent que des références. Le chiffrement at-rest de la base PostgreSQL via le disque chiffré Hetzner suffit.

Comment tester en local sans déployer ?
Cloudflare Tunnel ou ngrok exposent le serveur local sur Internet avec un domaine public, et on configure ce domaine comme URL de webhook côté PSP en mode sandbox.

Quelle queue utiliser pour le traitement asynchrone ?
Sur Cloudflare Workers : Queues. Sur Node Hetzner : BullMQ + Redis pour la robustesse, ou un cron simple si le volume est faible. Pour un MVP, traitement synchrone suffit jusqu’à ~100 webhooks/minute.

Architecture multi-PSP avec routing intelligent

Pour une plateforme qui sert plusieurs pays UEMOA, chaque pays a son PSP dominant : Wave et Orange Money au Sénégal, MTN MoMo et Orange Money en Côte d’Ivoire, Orange Money et Moov Money au Mali et au Burkina Faso. Implémenter chaque PSP individuellement multiplie la complexité. La bonne architecture : un dispatcher Hono qui sélectionne dynamiquement le PSP selon le pays du client et le moyen choisi, encapsule chaque adapter derrière une interface commune, et applique la même logique d’idempotence et de réconciliation à tous.

L’interface commune définit trois méthodes : initier(montant, ref), verifier(ref), traiterWebhook(payload, signature). Chaque adapter implémente cette interface en encapsulant les spécificités du PSP. Le code métier (commandes, factures, statuts) reste agnostique. Cette discipline architecturale paye dès qu’on ajoute un cinquième PSP : intégration en deux jours au lieu de deux semaines, sans toucher au code existant.

Audit financier et réconciliation comptable

Pour les SaaS qui traitent du paiement, la conformité comptable est aussi importante que la conformité technique. Trois rapports automatisés à produire chaque mois : (1) journal des paiements avec montant, PSP, date, statut, exportable en CSV pour le comptable ; (2) rapprochement des paiements reçus en base avec les relevés PSP (Wave fournit un rapport mensuel téléchargeable) ; (3) liste des litiges et remboursements ouverts/fermés. Pour une fintech qui doit déposer des rapports trimestriels à la BCEAO, ces exports sont la base du dossier.

L’audit trail des webhooks (table webhooks_recus conservée 7 ans) sert également de preuve en cas de litige client. Si un client conteste avoir effectué un paiement, on retrouve le payload signé Wave qui prouve que le PSP a bien notifié notre système d’une transaction validée. C’est juridiquement opposable selon la loi sénégalaise sur les transactions électroniques.

Sécurité avancée des webhooks

Au-delà de la signature HMAC, deux protections additionnelles. Vérification de l’IP source : Wave et Orange Money publient leurs ranges d’IP. Refuser les webhooks venant d’autres IPs élimine les tentatives d’usurpation où un attaquant fabrique un payload avec la bonne signature après avoir obtenu le secret par fuite. Replay protection par timestamp : si le payload contient un timestamp, refuser ceux datant de plus de 5 minutes (au-delà, c’est suspect). Cette double défense rend l’usurpation pratiquement impossible même en cas de compromission partielle des secrets.

Expérience utilisateur côté client

Le pattern technique parfait n’a aucune valeur si l’expérience perçue côté client est mauvaise. Quatre points clés à soigner. Premièrement, la page de redirection vers Wave doit charger en moins de 500 ms, sinon le client doute et abandonne. Deuxièmement, après retour sur notre site, afficher un état « Paiement en cours de validation, ne fermez pas cette page » pendant qu’on attend le webhook (timeout maximum 30 secondes), puis basculer automatiquement vers la confirmation ou l’erreur. Troisièmement, en cas de timeout sans confirmation, ne pas marquer immédiatement l’échec : afficher « Vérification en cours, vous recevrez un SMS de confirmation » et lancer la réconciliation côté backend. Quatrièmement, envoyer systématiquement un SMS et un email de confirmation après webhook réussi — le double canal sécurise le client qui ne reçoit qu’un seul des deux.

Ces détails comptent dans la conversion. Une étude interne sur une plateforme de Dakar a montré que l’amélioration de l’UX post-paiement (passage du redirect simple à l’état d’attente intelligent) a fait passer le taux de complétion de 78 % à 91 %, soit 13 points de gain mécanique sans changer le tunnel d’achat lui-même. Pour les marges fines du commerce ouest-africain, ces 13 points représentent souvent la différence entre rentabilité et perte.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité