ReactReact 18+ · Vite / CRA · Node 18+ · @moncashconnect/sdk

Intégrer MonCash avec React

Un SPA React qui appelle un mini-backend Node pour créer un paiement MonCash, redirige l'utilisateur vers MonCashConnect, puis confirme la commande via webhook signé.

Sandbox

Commencez en Sandbox

Avant d'implémenter le code ci-dessous avec votre clé live (sk_proj_…), testez d'abord en mode Sandbox avec sk_test_proj_…. Le code est identique — vous ne changez que la valeur de la variable d'environnement. Lisez le Guide Sandbox avant de continuer.

Architecture en deux temps

React tourne dans le navigateur — il ne peut jamais détenir la clé secrète sk_proj_…. L'intégration recommandée est donc :

  1. Le composant React envoie { orderId, amount } à votre backend.
  2. Le backend appelle POST /pay-create avec la clé secrète et renvoie paymentUrl.
  3. React redirige le navigateur vers paymentUrl.
  4. Après paiement, MonCash renvoie l'utilisateur vers votre returnUrl.
  5. En parallèle, votre webhook reçoit payment.completed — c'est la source de vérité.
Ne stockez jamais sk_proj_… dans VITE_* ni REACT_APP_* : tout ce qui commence par ce préfixe est inlined dans le bundle public.

Prérequis

  • Node.js 18+ pour le backend
  • React 18+ (Vite ou Create React App)
  • Un projet MonCashConnect avec clé secrète — créer un projet

Backend Node + @moncashconnect/sdk

Dans un dossier /server séparé :

npm install express cors dotenv @moncashconnect/sdk

Fichier .env :

MCC_SECRET_KEY=sk_proj_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MCC_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_URL=https://votresite.com
FRONTEND_ORIGIN=https://votresite.com
PORT=3001

Fichier server.js :

import "dotenv/config";
import express from "express";
import cors from "cors";
import { MonCashClient, constructEvent, MonCashError } from "@moncashconnect/sdk";

const app    = express();
const client = new MonCashClient(process.env.MCC_SECRET_KEY);

// CORS limité à votre origine frontend
app.use(cors({ origin: process.env.FRONTEND_ORIGIN, credentials: false }));

// JSON sauf pour le webhook (qui a besoin du body brut)
app.use((req, res, next) => {
  if (req.path === "/webhooks/moncash") return next();
  express.json()(req, res, next);
});

// ── POST /api/pay ─────────────────────────────────────────────────────
// Appelé par React, renvoie { paymentUrl, reference }
app.post("/api/pay", async (req, res) => {
  const { orderId, amount, customerName, customerEmail } = req.body ?? {};

  if (!orderId || !amount) {
    return res.status(400).json({ error: "orderId et amount requis." });
  }

  try {
    const payment = await client.createPayment(Number(amount), String(orderId), {
      returnUrl:     `${process.env.APP_URL}/pay/return`,
      customerName,
      customerEmail,
    });

    res.json({
      paymentUrl: payment.paymentUrl,
      reference:  payment.reference,
    });
  } catch (err) {
    if (err instanceof MonCashError) {
      return res.status(err.statusCode).json({ error: err.message });
    }
    res.status(500).json({ error: "Erreur serveur." });
  }
});

// ── GET /api/pay-status?reference= ────────────────────────────────────
// Appelé par la page de retour pour afficher un statut
app.get("/api/pay-status", async (req, res) => {
  const ref = String(req.query.reference ?? "");
  if (!ref) return res.status(400).json({ error: "Référence manquante." });

  try {
    const tx = await client.getPaymentStatus(ref);
    res.json(tx);
  } catch (err) {
    if (err instanceof MonCashError) {
      return res.status(err.statusCode).json({ error: err.message });
    }
    res.status(500).json({ error: "Erreur serveur." });
  }
});

// ── Webhook (voir section dédiée) ─────────────────────────────────────
app.post(
  "/webhooks/moncash",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = constructEvent(
        req.body,
        req.headers["x-mcc-signature"] ?? "",
        req.headers["x-mcc-timestamp"] ?? "",
        process.env.MCC_WEBHOOK_SECRET,
      );

      if (event.event === "payment.completed") {
        // markOrderPaid(event.reference);
      } else if (event.event === "payment.failed") {
        // markOrderFailed(event.reference);
      }
      res.sendStatus(200);
    } catch (err) {
      if (err instanceof MonCashError) {
        return res.status(err.statusCode).send(err.message);
      }
      res.sendStatus(500);
    }
  },
);

app.listen(process.env.PORT ?? 3001);

Composant React (PayButton)

Dans votre projet React, ajoutez l'URL du backend dans .env :

VITE_API_URL=https://api.votresite.com

Composant src/components/PayButton.tsx :

import { useState } from "react";

type Props = {
  orderId: string;
  amount: number;          // en HTG
  customerName?: string;
  customerEmail?: string;
};

export function PayButton({ orderId, amount, customerName, customerEmail }: Props) {
  const [loading, setLoading] = useState(false);
  const [error, setError]     = useState<string | null>(null);

  async function startPayment() {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(`${import.meta.env.VITE_API_URL}/api/pay`, {
        method:  "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ orderId, amount, customerName, customerEmail }),
      });

      if (!res.ok) {
        const j = await res.json().catch(() => ({}));
        throw new Error(j.error ?? `HTTP ${res.status}`);
      }

      const { paymentUrl } = await res.json();
      // Redirection vers MonCash — l'utilisateur quitte la page
      window.location.assign(paymentUrl);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Erreur inconnue");
      setLoading(false);
    }
  }

  return (
    <div>
      <button
        type="button"
        onClick={startPayment}
        disabled={loading}
        className="rounded-md bg-red-600 px-4 py-2 font-medium text-white disabled:opacity-60"
      >
        {loading ? "Redirection…" : `Payer ${amount} HTG avec MonCash`}
      </button>
      {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
    </div>
  );
}
Utilisez window.location.assign(paymentUrl) et pas window.open(...) : les bloqueurs de pop-ups empêchent souvent le second mode sur mobile.

Page de retour /pay/return

MonCash redirige le client vers {returnUrl}?ref={reference}. Affichez un statut côté client en interrogeant votre backend :

import { useEffect, useState } from "react";

type Tx = { status: "pending" | "completed" | "failed"; amount: number; reference: string };

export function PaymentReturn() {
  const [tx, setTx]   = useState<Tx | null>(null);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    const ref = new URLSearchParams(window.location.search).get("ref");
    if (!ref) { setErr("Référence manquante"); return; }

    fetch(`${import.meta.env.VITE_API_URL}/api/pay-status?reference=${ref}`)
      .then((r) => r.json())
      .then(setTx)
      .catch((e) => setErr(String(e)));
  }, []);

  if (err)  return <p>Erreur : {err}</p>;
  if (!tx)  return <p>Vérification en cours…</p>;

  if (tx.status === "completed") return <p>Merci ! Paiement de {tx.amount} HTG reçu.</p>;
  if (tx.status === "failed")   return <p>Le paiement a échoué.</p>;
  return <p>Paiement en attente de confirmation MonCash…</p>;
}
La page de retour est purement UX. Ne créditez jamais une commande uniquement parce qu'elle s'affiche en completed ici — attendez l'événement webhook payment.completed.

Webhook — source de vérité

Le webhook est implémenté côté backend (cf. server.js plus haut). Configurez l'URL publique de votre backend dans le tableau de bord MonCashConnect :

https://api.votresite.com/webhooks/moncash

Pour tester en local, exposez votre backend avec un tunnel :

ngrok http 3001

Voir le guide de test webhook pour la procédure complète avec rejeu d'événements.

Liste de contrôle avant production

MCC_SECRET_KEY n'est JAMAIS exposé côté React (pas dans VITE_* ni REACT_APP_*)

Le backend valide orderId / amount avant d'appeler /pay-create

CORS du backend est restreint à votre origine de production

La page de retour affiche un état 'pending' tant que le webhook n'a pas confirmé

Le handler webhook est idempotent (ne crédite pas deux fois la même commande)

L'URL https://api.votresite.com/webhooks/moncash est enregistrée dans le dashboard MCC

Les clés sk_test_proj_… ont été remplacées par sk_proj_… en production

FAQ

Pourquoi ne pas appeler /pay-create directement depuis React ?+
Parce que cela exposerait votre sk_proj_… dans le bundle JavaScript public. Toute personne ouvrant DevTools pourrait alors créer des paiements à votre place. La clé secrète ne doit JAMAIS quitter votre serveur.
Puis-je utiliser Create React App au lieu de Vite ?+
Oui — le code du composant est identique. Seul le préfixe d'environnement change : VITE_API_URL pour Vite, REACT_APP_API_URL pour CRA. Les nouvelles applications devraient préférer Vite (CRA n'est plus maintenu).
Comment afficher un état de chargement pendant la redirection ?+
Mettez un useState isLoading à true avant le fetch, et à false dans le finally. La redirection est synchrone (window.location.assign) — l'utilisateur quitte la page presque immédiatement.
Faut-il un compte marchand Digicel séparé ?+
Non. MonCashConnect agit comme passerelle sur l'infrastructure Bazik — un seul compte MCC suffit pour encaisser. Aucun contrat direct avec Digicel n'est requis pour commencer.
Que faire si le même webhook arrive en double ?+
C'est attendu : MonCashConnect retente toute requête qui ne renvoie pas 200 dans la fenêtre de timeout. Votre handler doit être idempotent — vérifiez en base si la commande est déjà en status 'paid' avant de la créditer.
Mon SPA est servi depuis un CDN, où dois-je héberger le backend ?+
N'importe où qui exécute du Node : Render, Railway, Fly.io, AWS Lambda derrière API Gateway, Vercel Functions… L'important est que l'origine soit autorisée par CORS et que MCC_SECRET_KEY soit injecté en variable d'environnement (pas dans le code).