DépannagereturnUrl · webhook · pay-status

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

Une commande ne se termine jamais parce que le navigateur du client est revenu sur votre page. Elle se termine parce que votre serveur a reçu le webhook signé, ou parce qu'il a appelé 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).

Dans tous ces cas, le webhook est quand même parti vers votre serveur, et 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 });
});
Ne terminez jamais une commande sur la seule présence de ?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);
Avantage secondaire : si le client revient, il revient sur une page déjà au courant. Et si la redirection se perd, l'accès s'ouvre quand même, parce que votre page n'a jamais dépendu d'elle.

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.

Si vous voyez encore un client arriver ailleurs que chez vous, envoyez-nous le 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.

FAQ

Le returnUrl sert à quoi, alors ?+
À l'expérience du client, pas à la comptabilité. Il ramène la personne sur une page qui dit « merci, c'est payé ». Ce qui décide qu'une commande est payée, c'est le webhook signé ou GET /v1/pay-status, jamais l'arrivée du navigateur sur votre page.
Mon returnUrl contient déjà des paramètres, est-ce un problème ?+
Non. Nous ajoutons reference=<votre referenceId> sans toucher à vos paramètres existants, et nous ne passons plus votre URL à l'opérateur : il reçoit une URL courte à nous, puis nous reconstruisons la vôtre telle quelle. Les caractères encodés (%3A, %2F) sont préservés.
Est-ce que je reçois le transactionId MonCash sur mon returnUrl ?+
Quand l'opérateur l'ajoute, oui : nous transmettons les paramètres qu'il ajoute, à condition qu'ils n'écrasent aucun des vôtres. Ne construisez rien de critique dessus, il n'est pas garanti. Le webhook, lui, contient toujours les identifiants.
Un client dit avoir payé mais ma page n'a jamais été appelée. Il a payé ?+
Peut-être, et c'est vérifiable sans lui : appelez GET /v1/pay-status?referenceId=... avec votre clé. C'est la réponse de l'API qui tranche, pas le fait que votre page de retour ait été chargée ou non.
Pourquoi ne pas simplement garder la redirection comme confirmation ?+
Parce qu'elle dépend d'un navigateur sur un téléphone, sur un réseau mobile haïtien, après un passage chez Digicel. Batterie vide, onglet fermé, data coupée, « retour » pressé trop tôt : le paiement a réussi et votre page n'a jamais été appelée. Avec le webhook, la commande se termine quand même.