Business Digital

Intégration API Wave Node.js 2026 : tutoriel complet (checkout, webhooks signés)

13 min de lecture

Wave est devenu le leader incontesté du paiement mobile au Sénégal et en pleine croissance en Côte d’Ivoire et au Mali. Pour tout marchand digital ouest-africain, intégrer Wave en 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer) est une priorité : c’est l’app de paiement préférée des consommateurs urbains, avec des transferts gratuits ou à coût très réduit. L’API marchand de Wave (Checkout API) est moderne, RESTful, bien documentée — voici le tutoriel complet d’intégration en Node.js (compatible Bun) avec gestion des webhooks signés et idempotence.

Ce tutoriel s’inscrit dans notre série Mobile Money. Pour le panorama global, voir notre guide pratique API Mobile Money 2026.

Prérequis

  • Compte marchand Wave Business actif (KYC validé) ou environnement sandbox pour développer
  • Clef API marchand (depuis le dashboard Wave Business)
  • Node.js 20+ ou Bun
  • Une base de données PostgreSQL ou SQLite pour stocker les transactions
  • Un domaine HTTPS public pour recevoir les webhooks (en local : tunnel ngrok ou Cloudflare Tunnel)
  • Niveau attendu : intermédiaire
  • Temps : 4-6 heures pour l’intégration complète

Étape 1 — Setup projet

mkdir wave-integration && cd wave-integration
bun init -y
bun add hono drizzle-orm postgres zod

# Variables d'environnement
cat > .env << 'EOF'
WAVE_API_KEY=wave_test_key_xxx
WAVE_WEBHOOK_SECRET=whsec_xxx
WAVE_API_BASE=https://api.wave.com/v1
DATABASE_URL=postgres://localhost/wavedemo
EOF

Étape 2 — Schéma de base

// src/db/schema.ts
import { pgTable, varchar, integer, timestamp, jsonb } from "drizzle-orm/pg-core";

export const orders = pgTable("orders", {
  id: varchar("id", { length: 50 }).primaryKey(),
  amount: integer("amount").notNull(),         // en FCFA
  currency: varchar("currency", { length: 3 }).default("XOF").notNull(),
  status: varchar("status", { length: 20 }).default("pending").notNull(),
  customerPhone: varchar("customer_phone", { length: 20 }),
  customerEmail: varchar("customer_email", { length: 255 }),
  waveSessionId: varchar("wave_session_id", { length: 100 }),
  waveTransactionId: varchar("wave_transaction_id", { length: 100 }),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

export const webhookEvents = pgTable("webhook_events", {
  id: varchar("id", { length: 100 }).primaryKey(),
  type: varchar("type", { length: 50 }).notNull(),
  payload: jsonb("payload").notNull(),
  receivedAt: timestamp("received_at").defaultNow().notNull(),
});

Étape 3 — Initier un paiement Wave

// src/wave.ts
const WAVE_API_BASE = process.env.WAVE_API_BASE!;
const WAVE_API_KEY = process.env.WAVE_API_KEY!;

export interface CheckoutSession {
  id: string;
  wave_launch_url: string;
  client_reference: string;
  amount: number;
  currency: string;
  payment_status: string;
}

export async function createCheckoutSession(params: {
  amount: number;
  clientReference: string;
  successUrl: string;
  errorUrl: string;
}): Promise<CheckoutSession> {
  const res = await fetch(`${WAVE_API_BASE}/checkout/sessions`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${WAVE_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": params.clientReference,
    },
    body: JSON.stringify({
      amount: String(params.amount),
      currency: "XOF",
      success_url: params.successUrl,
      error_url: params.errorUrl,
      client_reference: params.clientReference,
    }),
  });

  if (!res.ok) {
    const err = await res.text();
    throw new Error(`Wave API error: ${res.status} ${err}`);
  }

  return res.json();
}

L’Idempotency-Key est crucial : si l’utilisateur clique deux fois sur « Payer », Wave retourne la même session au lieu de créer un doublon.

Étape 4 — Endpoint d’initiation

// src/server.ts
import { Hono } from "hono";
import { db } from "./db/client";
import { orders } from "./db/schema";
import { createCheckoutSession } from "./wave";
import { nanoid } from "nanoid";

const app = new Hono();

app.post("/api/checkout/wave", async (c) => {
  const { amount, customerPhone, customerEmail } = await c.req.json();

  // Validations métier côté backend
  if (typeof amount !== "number" || amount < 100 || amount > 1000000) {
    return c.json({ error: "Montant invalide" }, 400);
  }

  // Créer la commande en base
  const orderId = `CMD-${nanoid(10)}`;
  await db.insert(orders).values({
    id: orderId,
    amount,
    customerPhone,
    customerEmail,
    status: "pending",
  });

  // Créer la session Wave
  const session = await createCheckoutSession({
    amount,
    clientReference: orderId,
    successUrl: `https://exemple.sn/checkout/success?order=${orderId}`,
    errorUrl: `https://exemple.sn/checkout/error?order=${orderId}`,
  });

  // Sauvegarder l'ID Wave
  await db.update(orders)
    .set({ waveSessionId: session.id })
    .where(eq(orders.id, orderId));

  // Retourner l'URL au front
  return c.json({
    orderId,
    paymentUrl: session.wave_launch_url,
  });
});

export default app;

Le frontend redirige l’utilisateur vers paymentUrl, qui ouvre la page Wave de validation. Sur mobile avec l’app Wave installée, c’est instantané.

Étape 5 — Webhook signé

Wave envoie un webhook POST à votre URL configurée à chaque changement de statut (succès, échec, annulation). Vérification de signature obligatoire :

// src/webhook.ts
import crypto from "crypto";

const WEBHOOK_SECRET = process.env.WAVE_WEBHOOK_SECRET!;

export function verifyWaveSignature(
  rawBody: string,
  signatureHeader: string,
): boolean {
  // Wave envoie : "t=<timestamp>,v1=<hex_hmac>"
  const parts = signatureHeader.split(",");
  const tsPart = parts.find((p) => p.startsWith("t="));
  const sigPart = parts.find((p) => p.startsWith("v1="));
  if (!tsPart || !sigPart) return false;

  const timestamp = tsPart.split("=")[1];
  const signature = sigPart.split("=")[1];

  // Anti-replay : refuser si décalage > 5 min
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) return false;

  // Calculer HMAC SHA256
  const payload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(payload)
    .digest("hex");

  // Comparaison timing-safe
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signature, "hex"),
  );
}

Étape 6 — Handler webhook

app.post("/webhooks/wave", async (c) => {
  const rawBody = await c.req.text();
  const signature = c.req.header("Wave-Signature") ?? "";

  // 1. Vérifier la signature
  if (!verifyWaveSignature(rawBody, signature)) {
    console.warn("Webhook signature invalide");
    return c.text("Invalid signature", 401);
  }

  const event = JSON.parse(rawBody);

  // 2. Idempotence : refuser les doublons
  const exists = await db.select().from(webhookEvents)
    .where(eq(webhookEvents.id, event.id))
    .limit(1);
  if (exists.length > 0) {
    return c.text("Already processed", 200);
  }

  // 3. Stocker l'événement
  await db.insert(webhookEvents).values({
    id: event.id,
    type: event.type,
    payload: event,
  });

  // 4. Répondre 200 RAPIDEMENT (Wave timeout 5s)
  c.executionCtx?.waitUntil?.(processWebhook(event));
  return c.text("OK", 200);
});

async function processWebhook(event: any) {
  const orderId = event.data.client_reference;

  if (event.type === "checkout.session.completed") {
    await db.update(orders)
      .set({
        status: "paid",
        waveTransactionId: event.data.transaction_id,
        updatedAt: new Date(),
      })
      .where(eq(orders.id, orderId));

    // Déclencher la livraison, envoyer le reçu, etc.
    await sendReceiptEmail(orderId);
  } else if (event.type === "checkout.session.payment_failed") {
    await db.update(orders)
      .set({ status: "failed", updatedAt: new Date() })
      .where(eq(orders.id, orderId));
  }
}

Étape 7 — Vérification proactive

En complément du webhook (qui peut arriver en retard ou pas du tout), implémentez une vérification proactive : un cron toutes les 10 minutes qui interroge l’API Wave pour les commandes pending de plus de 15 minutes :

export async function pollPendingOrders() {
  const pending = await db.select().from(orders)
    .where(and(
      eq(orders.status, "pending"),
      lt(orders.createdAt, new Date(Date.now() - 15 * 60 * 1000)),
    ));

  for (const order of pending) {
    if (!order.waveSessionId) continue;

    const res = await fetch(
      `${WAVE_API_BASE}/checkout/sessions/${order.waveSessionId}`,
      { headers: { "Authorization": `Bearer ${WAVE_API_KEY}` } },
    );
    const session = await res.json();

    if (session.payment_status === "succeeded") {
      await db.update(orders)
        .set({ status: "paid", waveTransactionId: session.transaction_id })
        .where(eq(orders.id, order.id));
    } else if (session.payment_status === "failed") {
      await db.update(orders).set({ status: "failed" }).where(eq(orders.id, order.id));
    }
  }
}

Étape 8 — Tester en local avec ngrok

# Démarrer le serveur local
bun run --hot src/index.ts

# Dans un autre terminal, exposer publiquement
ngrok http 3000

# Configurer l'URL ngrok comme webhook URL dans le dashboard Wave Business
# https://abcd1234.ngrok-free.app/webhooks/wave

Initiez ensuite un paiement test depuis votre app, validez sur l’app Wave de test, et observez les logs côté serveur. En sandbox Wave, les paiements ne déplacent pas vraiment d’argent.

Frontend : intégration UX

  • Bouton « Payer avec Wave » avec logo officiel (téléchargeable sur business.wave.com)
  • Affichage clair du montant en FCFA avant redirection
  • État « en attente » après redirection, avec polling pour refléter le succès dès retour du webhook
  • Page de succès avec récapitulatif et numéro de commande
  • Page d’erreur avec option de réessayer ou contacter support

Sécurité critique

  • Ne jamais valider la commande côté frontend uniquement, toujours attendre le webhook serveur
  • Calculer le montant côté backend à partir de l’ID commande/panier, pas accepter le montant envoyé par le frontend
  • HTTPS obligatoire sur l’URL webhook (Wave refuse les URLs non HTTPS en production)
  • Rate limiting sur l’endpoint d’initiation (10 transactions max par utilisateur par heure)
  • Logs persistants de tous les webhooks reçus pour audit

Erreurs fréquentes

ErreurCauseSolution
401 UnauthorizedMauvaise API key ou envVérifier WAVE_API_KEY, distinguer test vs prod
Webhook signature invalideBody parsé avant vérifLire le rawBody texte, pas await req.json()
Webhook reçu plusieurs foisRéponse non 200Toujours répondre 200 puis traiter en async
Doublons en baseIdempotence webhook absenteIndex unique sur webhook event.id
Montant en string vs numberAPI Wave attend stringString(amount) lors du POST
Ngrok URL changePlan gratuit ngrokUtiliser Cloudflare Tunnel (gratuit, URL stable)

Pour étoffer le tableau

Pourquoi Wave est devenu critique pour le e-commerce ouest-africain

Wave (wave.com) est le mobile money leader au Senegal, en Cote d Ivoire, au Mali, en Gambie, en Sierra Leone, et en expansion continue. La plateforme se distingue par des frais bien inferieurs (1 pour cent par transaction contre 5-10 pour cent pour la concurrence historique) et une API moderne pour l integration. Pour un commerce en ligne dans la zone CEDEAO, ne pas accepter Wave en 2026 revient a renoncer a 40-60 pour cent du marche selon les segments.

L API Wave Business expose deux endpoints principaux : Checkout (pour les paiements ponctuels via QR code ou lien) et Payouts (pour les paiements sortants : remboursements, paies, virements). Les webhooks signes permettent d etre notifie en temps reel des changements de statut sans avoir a poller l API.

Architecture d integration recommandee

Le flux de checkout standard comporte quatre etapes. (1) Le client choisit Wave comme moyen de paiement sur la landing ou le panier. (2) Le backend cree une session de checkout via l API Wave en passant le montant, la devise (XOF, GMD, SLE), la reference de commande, et l URL de retour. (3) Wave renvoie une URL de paiement vers laquelle on redirige le client (ou un QR code a scanner). (4) Une fois le paiement traite, Wave envoie un webhook signe au backend, qui verifie la signature, met a jour la commande, et declenche la fulfillment.

Le pattern critique est la verification de signature des webhooks. Sans cette verification, un attaquant pourrait envoyer de faux webhooks de paiement reussi a votre serveur et obtenir vos produits gratuitement. La signature utilise HMAC-SHA256 avec un secret partage entre Wave et votre application. Le code Node.js :

import crypto from  crypto ;function verifierSignatureWave(payloadBrut, signatureHeader, secret) {    const signatureAttendue = crypto        .createHmac( sha256 , secret)        .update(payloadBrut)        .digest( hex );    return crypto.timingSafeEqual(        Buffer.from(signatureAttendue),        Buffer.from(signatureHeader)    );}

L utilisation de timingSafeEqual au lieu de l operateur d egalite === protege contre les attaques par mesure de temps (timing attacks) — sans cette protection, un attaquant peut deduire la signature attendue caractere par caractere.

Idempotence et gestion des doubles paiements

Un piege classique des integrations de paiement : un meme webhook peut etre envoye plusieurs fois en cas de retry du fournisseur. Sans precaution, on peut creer plusieurs commandes ou debiter plusieurs fois le stock. La parade est d implementer l idempotence en base : chaque webhook recu est identifie par un transaction_id Wave, et l on stocke ce transaction_id dans une colonne unique de la base. La premiere reception est traitee, les suivantes detectent le doublon et repondent simplement OK 200 sans rejouer la logique.

Dans Postgres, une contrainte UNIQUE sur la colonne transaction_id avec un ON CONFLICT DO NOTHING dans l INSERT suffit. Dans MongoDB, un index unique sur le meme champ avec un upsert. Cette pratique est fondamentale — elle evite 99 pour cent des bugs de paiement double.

Gestion des erreurs et timeout

L API Wave, comme tout service externe, peut etre indisponible ou ralentie. Trois patterns d ingenieurie sont obligatoires.

Timeout strict. Toute requete a l API Wave doit avoir un timeout (typiquement 10 secondes pour Checkout, 30 secondes pour Payout). Sans timeout, une panne reseau peut bloquer indefiniment un thread de votre serveur.

Retry avec backoff exponentiel. En cas d erreur 5xx, retenter avec un delai croissant (1s, 2s, 4s, 8s). Limiter a 3-5 retries pour eviter d aggraver l indisponibilite. Pour les Payouts, ne JAMAIS retenter sans verifier le statut au prealable (sinon risque de paiement double).

Reconciliation periodique. Un cron quotidien interroge l API Wave pour la liste des transactions du jour et confirme l etat de chacune en base. Si un webhook a ete perdu (cas rare mais possible), la reconciliation rattrape la difference.

Securite : ce qui ne se discute pas

Les cles API Wave ne doivent jamais figurer dans le code source ni dans les commits Git. Stocker dans un secret manager (AWS Secrets Manager, HashiCorp Vault, ou variables d environnement chiffrees Hostinger). Faire tourner les cles tous les 90 jours.

Le endpoint webhook doit etre uniquement accessible en HTTPS, et idealement filtre par IP source (Wave publie ses plages d IP de webhooks). En cas de doute sur l authenticite d un webhook, faire une requete API en sens inverse pour confirmer le statut de la transaction avant traitement.

Les logs de transactions contiennent typiquement des numeros de telephone et des montants. Ils sont des donnees personnelles au sens du RGPD et des lois locales sur la protection des donnees (Senegal, Cote d Ivoire). Limiter la duree de retention (6 a 36 mois selon obligations) et chiffrer au repos.

FAQ

Wave fonctionne-t-il dans tous les pays africains ?
Non. Couvre actuellement Senegal, Cote d Ivoire, Mali, Gambie, Sierra Leone, Burkina Faso, Mauritanie, Togo, Benin (variable selon les services). Verifier la disponibilite dans le pays cible avant integration. Pour les autres marches, agreger via PayDunya ou FedaPay qui couvrent plus de pays.

Comment tester sans depenser d argent reel ?
Wave fournit un environnement sandbox avec des numeros de test qui simulent les paiements sans transfert reel. Documentation complete sur dashboard.wave.com. Toujours valider integralement en sandbox avant de basculer en production.

Quels sont les frais Wave Business ?
1 pour cent par transaction Checkout, plafond a 500 XOF par transaction. Les Payouts ont un cout fixe (250 XOF par transaction sortante en 2025-2026). Tarifs publics sur wave.com/business/pricing. Variables selon les volumes negocies.

Que faire en cas de paiement reussi mais webhook non recu ?
Le script de reconciliation quotidienne detecte ce cas et synchronise l etat. En attendant, donner au client un moyen de confirmer son paiement manuellement (numero de transaction Wave a saisir dans un formulaire support). C est la pratique standard du e-commerce mobile money.

References

Partager