Le client ne revient pas sur mon site après le paiement
La redirection après un paiement ramène le client sur votre page, mais elle ne doit jamais être ce qui décide qu'une commande est payée. Voici pourquoi, et le flux qui tient dans tous les cas.
La règle
GET /v1/pay-status. Si votre code ne sait conclure qu'au retour du navigateur, vous perdrez des commandes déjà payées.Le returnUrl est une courtoisie envers le client : il le ramène sur une page qui lui dit que c'est fait. Ce n'est pas un canal de confirmation. Nous le respectons, nous y ajoutons ?reference=<votre referenceId>, et nous préservons vos propres paramètres — mais entre le téléphone du client, le réseau mobile et le passage chez Digicel, personne ne peut vous promettre que ce navigateur reviendra.
Pourquoi le client ne revient pas
Toutes ces situations sont ordinaires, et dans chacune le paiement a réussi :
Le client ferme l'onglet dès qu'il voit « paiement réussi » chez MonCash.
Sa data coupe juste après le paiement : MonCash a encaissé, la redirection ne part jamais.
Batterie vide, appel entrant, téléphone verrouillé pendant la redirection.
Il appuie sur « retour » au lieu de suivre la redirection.
Il paie depuis une WebView dans une app qui ne suit pas la redirection.
L'opérateur redirige ailleurs que sur votre URL (voir plus bas).
pay-status répond quand même completed. C'est pour ça que la confirmation doit vivre côté serveur.La bonne façon : le webhook décide, la page affiche
Votre page de retour ne fait que lire un état déjà écrit par le webhook. Si le client arrive avant le webhook, elle demande l'état à l'API au lieu de deviner.
// 1) Le webhook termine la commande. C'est la seule source de vérité.
app.post("/webhooks/moncashconnect", express.raw({ type: "*/*" }), (req, res) => {
if (!verifySignature(req.headers["x-mcc-signature"], req.body, process.env.MCC_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "payment.completed") {
// Idempotent : le webhook peut arriver deux fois, et peut arriver
// AVANT que le client ne revienne (ou alors qu'il ne reviendra jamais).
fulfillOrder(event.data.referenceId);
}
res.sendStatus(200); // répondez 200 vite, le travail lourd en arrière-plan
});
// 2) La page de retour n'affiche que ce qui est déjà vrai.
app.get("/merci", async (req, res) => {
const ref = req.query.reference; // ajouté par MonCashConnect
let order = await findOrder(ref);
if (order?.status !== "paid") {
// Le client est plus rapide que le webhook : demandez à l'API.
const r = await fetch(
`https://api.moncashconnect.com/v1/pay-status?referenceId=${encodeURIComponent(ref)}`,
{ headers: { Authorization: `Bearer ${process.env.MCC_SECRET_KEY}` } },
);
const status = await r.json();
if (status.status === "completed") order = await fulfillOrder(ref);
}
res.render("merci", { order });
});?reference= dans l'URL de votre page de retour. N'importe qui peut taper cette URL à la main. C'est la réponse de l'API ou le webhook signé qui fait foi.Portails Wi-Fi, bornes et écrans partagés
Si votre produit doit donner un accès au moment du paiement — un portail Wi-Fi MikroTik, une borne, un écran posé sur un comptoir — ne construisez rien sur le retour du navigateur. Ouvrez le paiement dans un nouvel onglet et laissez votre page d'origine, celle qui contrôle l'accès, interroger l'API toute seule :
// Sur la page du portail, qui reste ouverte et garde le contrôle de l'accès.
const { paymentUrl, reference } = await fetch("/api/start-payment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan: "1H", mac, ip }),
}).then((r) => r.json());
window.open(paymentUrl, "_blank"); // le client paie à côté
// La page du portail ne dépend plus du tout de la redirection.
const timer = setInterval(async () => {
// Votre serveur appelle pay-status avec votre clé — jamais le navigateur.
const { status } = await fetch(`/api/payment-status?reference=${reference}`).then((r) => r.json());
if (status === "completed") {
clearInterval(timer);
grantAccess(); // ouvrez l'accès ici
}
}, 3000);Si vos clients atterrissent sur un site qui n'est pas le vôtre
C'est arrivé, et ce n'est pas une erreur de votre côté. Des marchands ont vu leurs clients finir sur la page de confirmation d'une autre entreprise après un paiement réussi. La cause était chez l'opérateur MonCash : quand il n'arrive pas à utiliser l'URL de retour qu'on lui transmet, il retombe sur une URL configurée au niveau du compte, qui appartient à quelqu'un d'autre. Les returnUrl les plus touchés étaient ceux qui contenaient déjà un point d'interrogation et des caractères encodés (%3A, %2F) — typiques des portails MikroTik.
Ce qui a changé de notre côté : l'opérateur ne reçoit plus votre URL. Il reçoit une URL courte à nous, sans aucun paramètre et sans caractère encodé, et c'est nous qui reconstruisons ensuite la vôtre, telle que vous l'avez envoyée, avec vos paramètres et ?reference= ajouté. Vous n'avez rien à modifier dans votre intégration.
referenceId du paiement et l'adresse exacte où il a atterri. C'est ce qui permet de le prouver à l'opérateur. Ne changez pas votre returnUrl « au cas où » : si votre paiement se crée, votre URL est valide.Checklist
Mon webhook est en place, vérifie la signature, et c'est lui qui termine la commande.
Mon traitement de webhook est idempotent : deux fois le même événement ne livre pas deux fois.
Ma page de retour lit un état, elle ne le décide pas.
Si le client arrive avant le webhook, ma page appelle pay-status au lieu de deviner.
Je n'accorde jamais rien sur la seule présence de ?reference= dans l'URL.
Si mon produit donne un accès immédiat, ma page d'origine interroge pay-status et ne dépend pas de la redirection.
Mon appel à pay-status part de mon serveur, jamais du navigateur : ma clé sk_proj_ n'est pas dans le code client.