Vue 3Vue 3 · Vite · Node 18+ · Express · @moncashconnect/sdk

Intégrer MonCash avec Vue 3

Application Vue 3 (Vite) côté client + Express côté serveur, avec un composable usePayment() réutilisable et un webhook signé pour confirmer les commandes.

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 client / serveur

Vue tourne dans le navigateur — la clé sk_proj_… doit rester sur le serveur. Le flux est en 5 étapes :

  1. Le composant Vue appelle POST /api/pay sur votre backend.
  2. Le backend Express appelle /pay-create avec le SDK.
  3. Le backend renvoie { paymentUrl, reference }.
  4. Vue redirige le navigateur vers paymentUrl.
  5. Le webhook backend reçoit payment.completed et marque la commande payée.
Toute variable préfixée VITE_ est inlined dans le bundle Vue. N'y mettez jamais une clé secrète.

Prérequis

  • Node.js 18+ pour le backend
  • Vue 3.4+ (Composition API)
  • Vite 5+ comme bundler frontend
  • Un projet MonCashConnect — créer un projet

Backend Express + @moncashconnect/sdk

Installation dans votre dossier serveur :

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);

app.use(cors({ origin: process.env.FRONTEND_ORIGIN }));

// JSON pour /api/* mais pas pour le webhook
app.use((req, res, next) => {
  if (req.path.startsWith("/webhooks/")) return next();
  express.json()(req, res, next);
});

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." });
  }
});

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 {
    res.json(await client.getPaymentStatus(ref));
  } catch (err) {
    if (err instanceof MonCashError) {
      return res.status(err.statusCode).json({ error: err.message });
    }
    res.status(500).json({ error: "Erreur serveur." });
  }
});

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") {
        // marquer la commande comme payée
      } else if (event.event === "payment.failed") {
        // marquer comme échouée
      }
      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);

Composable usePayment()

Dans .env côté frontend :

VITE_API_URL=https://api.votresite.com

Fichier src/composables/usePayment.ts :

import { ref } from "vue";

type StartArgs = {
  orderId: string;
  amount: number;
  customerName?: string;
  customerEmail?: string;
};

export function usePayment() {
  const loading = ref(false);
  const error   = ref<string | null>(null);

  async function startPayment(args: StartArgs) {
    loading.value = true;
    error.value   = null;
    try {
      const res = await fetch(`${import.meta.env.VITE_API_URL}/api/pay`, {
        method:  "POST",
        headers: { "Content-Type": "application/json" },
        body:    JSON.stringify(args),
      });

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

      const { paymentUrl } = await res.json();
      window.location.assign(paymentUrl);
    } catch (e) {
      error.value   = e instanceof Error ? e.message : "Erreur inconnue";
      loading.value = false;
    }
  }

  return { loading, error, startPayment };
}

Composant <script setup>

Fichier src/components/PayButton.vue :

<script setup lang="ts">
import { usePayment } from "@/composables/usePayment";

const props = defineProps<{
  orderId: string;
  amount:  number;
  customerName?:  string;
  customerEmail?: string;
}>();

const { loading, error, startPayment } = usePayment();

function onClick() {
  startPayment({
    orderId:       props.orderId,
    amount:        props.amount,
    customerName:  props.customerName,
    customerEmail: props.customerEmail,
  });
}
</script>

<template>
  <div>
    <button
      type="button"
      :disabled="loading"
      class="rounded-md bg-red-600 px-4 py-2 font-medium text-white disabled:opacity-60"
      @click="onClick"
    >
      <span v-if="loading">Redirection…</span>
      <span v-else>Payer {{ amount }} HTG avec MonCash</span>
    </button>
    <p v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</p>
  </div>
</template>
Préférez window.location.assign(paymentUrl) à window.open : sur mobile, le pop-up est presque toujours bloqué et le client ne voit jamais la page de paiement.

Page de retour PaymentReturn.vue

<script setup lang="ts">
import { onMounted, ref } from "vue";

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

const tx    = ref<Tx | null>(null);
const error = ref<string | null>(null);

onMounted(async () => {
  const ref_ = new URLSearchParams(window.location.search).get("ref");
  if (!ref_) {
    error.value = "Référence manquante.";
    return;
  }
  try {
    const r = await fetch(
      `${import.meta.env.VITE_API_URL}/api/pay-status?reference=${ref_}`,
    );
    tx.value = await r.json();
  } catch (e) {
    error.value = e instanceof Error ? e.message : "Erreur";
  }
});
</script>

<template>
  <div>
    <p v-if="error">Erreur : {{ error }}</p>
    <p v-else-if="!tx">Vérification du paiement…</p>
    <p v-else-if="tx.status === 'completed'">
      Merci ! Paiement de {{ tx.amount }} HTG reçu.
    </p>
    <p v-else-if="tx.status === 'failed'">Le paiement a échoué.</p>
    <p v-else>Paiement en attente de confirmation MonCash…</p>
  </div>
</template>
L'affichage de la page de retour n'est qu'une indication d'UX. Ne déclenchez la livraison ou l'accès au produit qu'au reçu de l'événement webhook payment.completed.

Liste de contrôle avant production

MCC_SECRET_KEY est dans .env du backend uniquement (pas dans VITE_*)

Le backend valide orderId et amount (montant > 0, type number)

CORS du backend autorise uniquement votre origine frontend de production

La page PaymentReturn.vue gère explicitement le statut 'pending'

Le handler webhook est idempotent (check en base avant de créditer)

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

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

FAQ

Pourquoi un backend ? Vue ne peut pas appeler MonCashConnect directement ?+
L'API /pay-create exige un Authorization: Bearer sk_proj_…. Mettre cette clé dans Vue l'exposerait à tout visiteur du site. Le pattern correct est : Vue appelle votre backend, qui appelle MonCashConnect.
Faut-il Vuex / Pinia pour gérer l'état du paiement ?+
Non, un composable usePayment() suffit pour un parcours de checkout. Pinia devient utile si vous voulez partager le statut du dernier paiement entre plusieurs vues (ex : header de notification).
Comment intégrer dans un projet Nuxt 3 ?+
Le composable Vue reste identique. Le backend Express devient une route serveur Nuxt dans server/api/pay.post.ts. Les variables d'environnement passent par runtimeConfig (server-only) — jamais via NUXT_PUBLIC_*.
Faut-il un compte marchand Digicel séparé ?+
Non. MonCashConnect agit comme passerelle sur l'infrastructure Bazik. Une seule inscription sur MonCashConnect.com suffit pour encaisser via MonCash.
Mon montant est en gourdes — faut-il convertir ?+
Envoyez le montant tel quel en HTG (entier ou float). Pas de conversion en cents : MonCash travaille en gourdes natives. Ex : 250 = 250.00 HTG.
Que faire si le webhook arrive en double ?+
Votre handler doit être idempotent. Vérifiez en base si la commande est déjà en status 'paid' avant tout crédit. MonCashConnect retente tant que vous ne renvoyez pas 200.