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.
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 :
- Le composant Vue appelle
POST /api/paysur votre backend. - Le backend Express appelle
/pay-createavec le SDK. - Le backend renvoie
{ paymentUrl, reference }. - Vue redirige le navigateur vers
paymentUrl. - Le webhook backend reçoit
payment.completedet marque la commande payé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/sdkFichier .env :
MCC_SECRET_KEY=sk_proj_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MCC_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_URL=https://votresite.com
FRONTEND_ORIGIN=https://votresite.com
PORT=3001Fichier 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.comFichier 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>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>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 ?+
Faut-il Vuex / Pinia pour gérer l'état du paiement ?+
Comment intégrer dans un projet Nuxt 3 ?+
Faut-il un compte marchand Digicel séparé ?+
Mon montant est en gourdes — faut-il convertir ?+
Que faire si le webhook arrive en double ?+
Lectures recommandées :