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 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 complet 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
| Erreur | Cause | Solution |
|---|---|---|
| 401 Unauthorized | Mauvaise API key ou env | Vérifier WAVE_API_KEY, distinguer test vs prod |
| Webhook signature invalide | Body parsé avant vérif | Lire le rawBody texte, pas await req.json() |
| Webhook reçu plusieurs fois | Réponse non 200 | Toujours répondre 200 puis traiter en async |
| Doublons en base | Idempotence webhook absente | Index unique sur webhook event.id |
| Montant en string vs number | API Wave attend string | String(amount) lors du POST |
| Ngrok URL change | Plan gratuit ngrok | Utiliser Cloudflare Tunnel (gratuit, URL stable) |