📍 Guide principal de la série : Mobile money en backend 2026 — Wave, Orange Money, PayDunya, CinetPay
Introduction
Encaisser via Orange Money depuis une application Next.js demande de croiser deux sujets exigeants : le flux de paiement par redirection avec OAuth2 côté Orange, et l’architecture App Router de Next.js 15 avec ses server actions et ses route handlers asynchrones. Ce tutoriel construit l’intégration complète en TypeScript : génération du token, initiation du paiement, page de retour client, callback signé, état persisté en base. La cible est un produit prêt pour la production qui sert un public Orange Money au Sénégal et qui doit tenir une charge raisonnable sans perdre de transactions.
Prérequis
- Node.js 20.x ou 22.x
- pnpm 9 (ou npm/yarn équivalent)
- Next.js 15 avec App Router activé
- TypeScript 5.4+
- Une base de données accessible (PostgreSQL, MySQL ou SQLite pour le dev) avec Prisma ou Drizzle
- Un compte développeur sur developer.orange-sonatel.com ou developer.orange.com pour le sandbox
- Un tunnel HTTPS public pour le dev (Cloudflare Tunnel ou ngrok) — Orange ne peut pas envoyer son callback sur localhost
- Niveau intermédiaire en Next.js et TypeScript
- Temps estimé : 3 heures pour le code, 1 jour de validation sandbox
Étape 1 — Créer le compte développeur Sonatel et récupérer les credentials
Le portail Sonatel à developer.orange-sonatel.com propose un environnement de test gratuit pour l’OM Merchant Payment. L’inscription crée un workspace dans lequel vous récupérez deux types de credentials : un client ID et un client secret pour l’authentification OAuth2, et un merchant key qui identifie votre marchand auprès de la passerelle.
Une fois inscrit, validez votre numéro de téléphone et votre adresse email comme demandé par le portail, puis créez une application dans la section dédiée. Le formulaire vous demandera typiquement le nom de l’application, le domaine de production attendu, l’URL de callback à laquelle Orange enverra ses notifications, et l’URL de retour client après paiement. Mettez ici l’URL de votre tunnel HTTPS pendant le développement — par exemple https://votre-projet.trycloudflare.com/api/orange-money/notify pour le callback et https://votre-projet.trycloudflare.com/payment/return pour le retour client.
Notez bien : les credentials sandbox délivrés à l’inscription ne fonctionnent que sur l’endpoint sandbox, qui est distinct de l’endpoint production. La bascule vers les credentials de production passe par un dossier KYC complet auprès de Sonatel — preuves d’activité, NINEA, statut juridique, RIB pour les versements depuis votre balance Orange Money. Comptez deux à quatre semaines selon la qualité du dossier.
Étape 2 — Initialiser le projet Next.js 15
Démarrez un projet propre avec App Router et TypeScript préconfigurés. La commande de scaffolding Next.js récente embarque toutes les options dont nous avons besoin.
pnpm create next-app@latest orange-money-checkout \
--typescript --app --src-dir --tailwind --eslint --no-import-alias
cd orange-money-checkout
pnpm add zod
pnpm add -D @types/node
La commande crée un répertoire src/app/ qui est notre point d’entrée App Router. Zod servira à valider les payloads entrants et sortants — c’est la bonne pratique en TypeScript dès que vous touchez du JSON externe. Une fois le projet créé, configurez votre fichier .env.local avec les credentials sandbox récupérés à l’étape 1.
ORANGE_API_BASE=https://api.sandbox.orange-sonatel.com
ORANGE_CLIENT_ID=votre_client_id
ORANGE_CLIENT_SECRET=votre_client_secret
ORANGE_MERCHANT_KEY=votre_merchant_key
ORANGE_NOTIF_TOKEN_SECRET=un_secret_que_vous_choisissez
APP_URL=https://votre-projet.trycloudflare.com
L’ORANGE_NOTIF_TOKEN_SECRET est un secret de votre choix qui servira à signer les URL de callback — Orange ne signe pas nativement ses notifications, c’est à vous d’embarquer un token dans l’URL et de le vérifier côté handler. Cette technique est fragile face à un secret leaké, mais c’est la pratique sur cette passerelle. Une bonne hygiène passe par la rotation trimestrielle du secret.
Étape 3 — Implémenter la récupération du token OAuth2
L’API Orange Money exige un access_token valide dans le header Authorization: Bearer de chaque appel. Le token est récupéré contre vos credentials client et vit typiquement une heure. Recoder la récupération à chaque appel serait inefficace et risquerait de saturer le rate limit du serveur d’authentification — on mémoïse donc le token avec son expiration.
Créez src/lib/orange/token.ts qui exporte une fonction getAccessToken() qui regarde son cache mémoire avant de réclamer un nouveau token. En production sur Vercel ou un autre runtime serverless, ce cache vit le temps de l’instance — ce qui suffit largement pour amortir le coût d’auth sur les transactions parallèles d’une même instance.
const tokenCache: { value: string | null; expiresAt: number } = {
value: null,
expiresAt: 0,
};
export async function getAccessToken(): Promise<string> {
if (tokenCache.value && Date.now() < tokenCache.expiresAt - 60_000) {
return tokenCache.value;
}
const auth = Buffer.from(
`${process.env.ORANGE_CLIENT_ID}:${process.env.ORANGE_CLIENT_SECRET}`
).toString("base64");
const res = await fetch(`${process.env.ORANGE_API_BASE}/oauth/v1/token`, {
method: "POST",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
cache: "no-store",
});
if (!res.ok) {
throw new Error(`OAuth2 token failed: ${res.status} ${await res.text()}`);
}
const data: { access_token: string; expires_in: number } = await res.json();
tokenCache.value = data.access_token;
tokenCache.expiresAt = Date.now() + data.expires_in * 1000;
return data.access_token;
}
Le décalage de 60 secondes (expiresAt - 60_000) anticipe la situation où un token est récupéré juste avant son expiration et serait utilisé après — on rafraîchit légèrement en avance pour éviter le 401 in extremis. Le cache: "no-store" désactive le cache HTTP de Next.js sur cette route — un token mis en cache serait une fuite de credentials en mémoire partagée.
Gardez à l’esprit que les noms d’endpoints et les contrats peuvent varier selon les versions exactes du portail Orange utilisé (Sonatel pour le Sénégal, Cameroun, etc.). Vérifiez toujours dans la documentation officielle que vous avez en main au moment de l’intégration.
Étape 4 — Créer une intention de paiement via server action
Le flux web payment commence par un appel POST à l’endpoint webpayment (le chemin exact dépend de la version Orange — typiquement /orange-money-webpay/dev/v1/webpayment en sandbox général ou un équivalent côté Sonatel). Le payload identifie votre marchand, le montant, la devise, l’identifiant de commande côté merchant, et les URLs de retour et de notification.
Côté Next.js 15, le bon outil est la server action — fonction asynchrone exécutée côté serveur sur soumission de formulaire, sans avoir à créer une route API explicite. On crée src/app/actions/initiate-payment.ts qui appelle l’API Orange et retourne l’URL de redirection.
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
import { getAccessToken } from "@/lib/orange/token";
import { db } from "@/lib/db";
import crypto from "node:crypto";
const InitInput = z.object({
amount: z.number().int().positive(),
orderId: z.string().min(1),
});
export async function initiatePayment(formData: FormData) {
const parsed = InitInput.parse({
amount: Number(formData.get("amount")),
orderId: String(formData.get("orderId")),
});
const token = await getAccessToken();
const notifToken = crypto.randomBytes(32).toString("hex");
const orderUlid = crypto.randomUUID();
await db.payment.create({
data: {
orderId: parsed.orderId,
provider: "orange-money",
amount: parsed.amount,
currency: "XOF",
status: "pending",
externalRef: orderUlid,
notifToken,
},
});
const res = await fetch(`${process.env.ORANGE_API_BASE}/omcoreapis/1.0.2/mp/init`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
merchant_key: process.env.ORANGE_MERCHANT_KEY,
currency: "XOF",
order_id: orderUlid,
amount: parsed.amount,
return_url: `${process.env.APP_URL}/payment/return?ref=${orderUlid}`,
cancel_url: `${process.env.APP_URL}/payment/cancel?ref=${orderUlid}`,
notif_url: `${process.env.APP_URL}/api/orange-money/notify?token=${notifToken}`,
lang: "fr",
reference: parsed.orderId,
}),
cache: "no-store",
});
if (!res.ok) {
throw new Error(`Init failed: ${res.status}`);
}
const data: { pay_token: string; payment_url: string; notif_token?: string } =
await res.json();
await db.payment.update({
where: { externalRef: orderUlid },
data: { payToken: data.pay_token },
});
redirect(data.payment_url);
}
Trois choix de design méritent d’être justifiés. Le notifToken est généré aléatoirement et embarqué dans l’URL de notification — quand Orange rappellera notre handler, il devra présenter ce token et nous le comparerons à celui stocké en base. C’est ce qui empêche un attaquant qui devinerait l’URL de notification de forger des callbacks sans connaître le token. La création de la ligne payment en base avant l’appel Orange garantit qu’on peut toujours réconcilier — même si l’appel timeoute après que Orange ait commencé à traiter, la trace existe. Le redirect() côté server action redirige le navigateur du client vers la page Orange Money sans aller-retour client supplémentaire.
Notez bien : le chemin exact de l’endpoint (/omcoreapis/1.0.2/mp/init) est donné à titre indicatif — selon votre portail (Orange global ou Sonatel) et la version active au moment de votre intégration, le chemin peut être différent. Consultez la documentation que vous avez reçue avec vos credentials pour le chemin précis.
Étape 5 — Implémenter la page de retour et le pull de réconciliation
Quand le client revient sur votre site après avoir confirmé son paiement par OTP côté Orange, il arrive sur la return_url que vous avez fournie. Cette page n’est pas censée recevoir la confirmation autoritative — c’est le rôle du callback — mais elle doit afficher quelque chose au client : « paiement réussi », « en cours de validation », « erreur ». Pour éviter que ce qui s’affiche ne soit incohérent avec la réalité, on appelle l’API Orange pour récupérer le statut courant du paiement avant de rendre la page.
Créez src/app/payment/return/page.tsx en server component. Le bénéfice : pas de JS client à charger, l’utilisateur voit la bonne réponse dès la première frame.
import { db } from "@/lib/db";
import { getAccessToken } from "@/lib/orange/token";
import { notFound } from "next/navigation";
export default async function ReturnPage({
searchParams,
}: {
searchParams: Promise<{ ref?: string }>;
}) {
const { ref } = await searchParams;
if (!ref) notFound();
const payment = await db.payment.findUnique({ where: { externalRef: ref } });
if (!payment) notFound();
if (payment.status === "pending" && payment.payToken) {
const token = await getAccessToken();
const res = await fetch(
`${process.env.ORANGE_API_BASE}/omcoreapis/1.0.2/mp/pay/${payment.payToken}`,
{
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}
);
if (res.ok) {
const data: { status?: string } = await res.json();
if (data.status === "SUCCESS") {
await db.payment.update({
where: { externalRef: ref },
data: { status: "succeeded", confirmedAt: new Date() },
});
payment.status = "succeeded";
} else if (data.status === "FAILED" || data.status === "EXPIRED") {
await db.payment.update({
where: { externalRef: ref },
data: { status: "failed" },
});
payment.status = "failed";
}
}
}
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-2xl font-bold">
{payment.status === "succeeded" && "Paiement reçu"}
{payment.status === "failed" && "Paiement échoué"}
{payment.status === "pending" && "Validation en cours"}
</h1>
<p className="mt-2 text-gray-600">Référence : {payment.orderId}</p>
</main>
);
}
La logique est simple : si le paiement est encore pending côté base mais qu’on a un payToken, on appelle Orange pour récupérer le statut. Si Orange dit SUCCESS, on transitionne en base et on affiche le succès. Si Orange dit FAILED ou EXPIRED, on transitionne en failed. Sinon on laisse pending et on affiche un message d’attente — le callback finira par arriver et l’utilisateur peut rafraîchir la page.
Le cache: "no-store" est crucial : sans lui, Next.js cacherait la réponse d’Orange et un utilisateur revenant sur la page verrait le statut figé au moment du premier appel.
Étape 6 — Implémenter le route handler de notification (callback)
Le callback Orange arrive en POST sur la notif_url que vous avez fournie. Le payload contient l’identifiant de transaction côté Orange, le statut final, et parfois des champs additionnels selon la version de l’API. Le route handler doit vérifier le notifToken embarqué dans l’URL, retrouver la ligne payment correspondante, transitionner son statut, déclencher les effets métier, et acquitter avec un statut HTTP 2xx.
Créez src/app/api/orange-money/notify/route.ts :
import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const NotifySchema = z.object({
status: z.string(),
txnid: z.string().optional(),
notif_token: z.string().optional(),
pay_token: z.string().optional(),
});
export async function POST(req: NextRequest) {
const url = new URL(req.url);
const expectedToken = url.searchParams.get("token");
if (!expectedToken) return NextResponse.json({ error: "no token" }, { status: 401 });
const raw = await req.text();
const parsed = NotifySchema.safeParse(JSON.parse(raw));
if (!parsed.success) return NextResponse.json({ error: "bad payload" }, { status: 400 });
const payment = await db.payment.findFirst({
where: { notifToken: expectedToken },
});
if (!payment) return NextResponse.json({ error: "not found" }, { status: 404 });
const event = await db.webhookEvent.create({
data: {
provider: "orange-money",
externalId: parsed.data.txnid ?? `${payment.id}-${Date.now()}`,
payload: parsed.data,
},
}).catch(() => null);
if (!event) {
return NextResponse.json({ received: true, dedup: true });
}
const newStatus =
parsed.data.status === "SUCCESS" ? "succeeded" :
parsed.data.status === "FAILED" ? "failed" :
parsed.data.status === "EXPIRED" ? "expired" : payment.status;
await db.payment.update({
where: { id: payment.id },
data: {
status: newStatus,
confirmedAt: ["succeeded", "failed", "expired"].includes(newStatus)
? new Date() : null,
},
});
return NextResponse.json({ received: true });
}
La déduplication par webhookEvent.externalId (avec index unique sur la colonne) garantit qu’un même callback reçu deux fois ne déclenche pas deux fois les effets métier. La comparaison expectedToken contre celui stocké en base bloque tout appel forgé qui ne connaîtrait pas le token aléatoire généré à la création de l’intention. La transition de statut est encadrée — seuls les statuts connus déclenchent une mise à jour, un statut inattendu laisse la base inchangée et journalise via le webhookEvent.
Étape 7 — Tester en sandbox avec le tunnel HTTPS
Le test de bout en bout exige un tunnel HTTPS. Sans cela, Orange ne peut pas atteindre votre notif_url et vous ne saurez jamais si le callback fonctionne. Le tunnel le plus simple à mettre en place en 2026 est Cloudflare Tunnel, gratuit, sans inscription, lancé en une commande.
pnpm dev &
cloudflared tunnel --url http://localhost:3000
La sortie de cloudflared affiche une URL en https://<aléa>.trycloudflare.com. Copiez cette URL dans votre .env.local sous APP_URL, puis renseignez la même URL côté portail Sonatel comme notif_url et return_url de votre application sandbox. Redémarrez next dev pour qu’il prenne en compte la nouvelle valeur de APP_URL.
Le test manuel suit trois étapes. Soumettez un paiement depuis votre formulaire en passant un montant en multiple de 5 et un orderId arbitraire. Vous êtes redirigé vers la page Orange Money sandbox qui simule l’OTP — saisissez le code de test fourni par le portail. Au retour, votre page de succès s’affiche, et après quelques secondes le callback est reçu et journalisé dans la table webhookEvent. Vérifiez en base que la ligne payment est bien passée à succeeded et que la ligne webhookEvent a été créée.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| 401 sur l’endpoint webpayment | Token OAuth2 expiré ou cache mémoire vide après redéploiement | Vérifier le cache TTL, ajouter une marge de 60 secondes avant expiration |
| Callback jamais reçu en local | Pas de tunnel HTTPS, ou URL pas mise à jour côté portail Sonatel | Cloudflare Tunnel + redéclarer la notif_url côté portail |
Statut pending figé après paiement réussi |
Page de retour ne fait pas le pull de réconciliation | Implémenter l’appel à mp/pay/:pay_token au rendu de la return page |
notif_token rejeté en 401 |
Token URL pas synchronisé avec celui en base (ex : régénération oubliée) | Stocker le notifToken dès la création de l’intention, ne jamais le re-générer |
| Doublon de paiement enregistré | Pas d’idempotence sur webhookEvent.externalId |
Index unique + create().catch() pour gérer le duplicat |
| Montant rejeté | Orange Money attend des montants entiers en XOF, pas de décimales | Forcer Number(amount).toFixed(0) ou Math.round(amount) côté client |
| Server action échoue silencieusement | Erreur côté Orange non remontée à l’utilisateur | try/catch autour du fetch, logger côté serveur, afficher un message générique |
Tutoriels frères
- Wave Payout API en Laravel : verser un paiement mobile pas-à-pas
- PayDunya en Django : encaisser via tous les wallets pas-à-pas
FAQ
Faut-il signer le callback côté Orange ?
Le portail Orange Money ne fournit pas nativement de signature HMAC sur ses callbacks. La pratique sur cette passerelle est d’embarquer un token aléatoire dans l’URL de notification et de comparer ce token à celui stocké en base. Pour renforcer, vous pouvez ajouter une vérification d’IP source — Orange publie ses ranges IP dans la documentation marchand.
Que faire si le client ferme le navigateur avant le retour ?
Le callback finit par arriver indépendamment du retour client. Votre handler de notification met à jour la base, puis un mail ou un SMS de confirmation au client matérialise la réussite côté utilisateur. Ce mécanisme garantit qu’un client mobile sur réseau instable ne « perd » pas son paiement.
Le sandbox Sonatel utilise-t-il un OTP réel ?
Non. Le sandbox utilise un OTP de test fourni dans la documentation du portail (typiquement 1234 ou similaire). En production, l’OTP est envoyé sur le téléphone du client par USSD.
Comment gérer les remboursements ?
Orange Money expose une API de remboursement séparée (typiquement mp/refund ou équivalent). Le contrat exact dépend de votre version de portail. Le remboursement nécessite que le paiement soit en SUCCESS et que les fonds soient encore disponibles côté Orange.
Peut-on utiliser le même code pour la Côte d’Ivoire ou le Cameroun ?
L’API Orange Money est multi-pays mais chaque pays a ses propres credentials et parfois ses propres particularités (devises secondaires, délais de validation, formats de numéro). Le code de cet article s’adapte facilement en changeant ORANGE_API_BASE et les credentials, mais validez toujours le contrat exact sur la documentation du pays cible.
Next.js 15 supporte-t-il les server actions en prod sur Vercel ?
Oui, les server actions sont supportées en production sur Vercel et sur tout runtime Node compatible. Pour les runtimes serverless edge, vérifiez que les SDK utilisés sont compatibles — le module crypto natif de Node n’est pas disponible en edge.
Pour aller plus loin
- 🔝 Retour à la ressource principale : Mobile money en backend 2026 — Wave, Orange Money, PayDunya, CinetPay
- Orange Developer Portal — Web Payment — référence officielle multi-pays
- Orange Sonatel Developer Portal — produits Sénégal
- Documentation Next.js 15 — Server Actions
- Tutoriel suivant suggéré : PayDunya en Django