Intégrer MonCash dans une application Flutter
Architecture mobile multiplateforme : url_launcher ouvre MonCash dans le navigateur système, app_links ramène l'utilisateur dans l'app via mcc://payment-return, et un webhook signé HMAC-SHA256 confirme la transaction côté serveur.
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 mobile sécurisée
Comme pour toute app mobile, la clé secrète reste exclusivement sur votre backend. Un fichier APK ou IPA peut être décompressé et son contenu Dart inspecté. L'app Flutter communique uniquement avec votre serveur, qui détient sk_proj_….
Flutter ──▶ Backend ──▶ MonCashConnect
(sk_proj_…) │
│
Flutter ◀── paymentUrl ──────┘
│
│ url_launcher (externalApplication)
▼
Navigateur système + app MonCash
│
│ redirect mcc://payment-return?ref=…
▼
Flutter (app_links écoute) ──▶ Backend ──▶ statut
Pendant ce temps en parallèle :
MonCashConnect ──▶ Webhook signé HMAC ──▶ Backend (source de vérité)Prérequis
- Flutter 3.16+ (Dart 3.2+)
- Un backend déployé (Node, Python, Go, Dart Shelf…)
- Un projet MonCashConnect actif — créer un projet
- Émulateur Android ou simulateur iOS pour les tests Sandbox
Déclarer le schéma deep link mcc://
Ajoutez les dépendances :
flutter pub add url_launcher app_linksAndroid — android/app/src/main/AndroidManifest.xml :
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:exported="true">
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mcc" android:host="payment-return" />
</intent-filter>
<!-- Conserver l'intent-filter Flutter existant en parallèle -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>iOS — ios/Runner/Info.plist :
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.maboutique.flutter</string>
<key>CFBundleURLSchemes</key>
<array>
<string>mcc</string>
</array>
</dict>
</array>launchMode="singleTop" (ou "singleTask") évite qu'une nouvelle Activity Flutter soit créée au retour du deep link — sinon l'état de la commande est perdu.Backend — endpoint create-payment
Exemple avec Node + SDK officiel. Tout backend qui peut signer du HMAC-SHA256 fonctionne (Go, Python, Dart Shelf, Rust…).
// backend/server.js — Node + Express
import express from "express";
import { MonCashConnect } from "@moncashconnect/sdk";
const app = express();
app.use(express.json());
const mcc = new MonCashConnect({ secretKey: process.env.MCC_SECRET_KEY });
app.post("/api/payments", async (req, res) => {
const { amount, orderId } = req.body;
const payment = await mcc.payments.create({
amount,
referenceId: orderId,
returnUrl: "mcc://payment-return",
});
res.json({
paymentUrl: payment.paymentUrl,
reference: payment.reference,
});
});
app.get("/api/payments/:reference", async (req, res) => {
const tx = await mcc.payments.retrieve(req.params.reference);
res.json(tx);
});
app.listen(3000);Code Flutter — initier le paiement
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
url_launcher: ^6.2.0
app_links: ^6.0.0
http: ^1.2.0
shared_preferences: ^2.2.0// lib/screens/checkout_screen.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:convert';
const apiBase = 'https://api.maboutique.com';
class CheckoutScreen extends StatefulWidget {
const CheckoutScreen({super.key});
@override
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
bool _loading = false;
Future<void> _pay() async {
setState(() => _loading = true);
try {
final orderId = 'ORD-${DateTime.now().millisecondsSinceEpoch}';
final res = await http.post(
Uri.parse('$apiBase/api/payments'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'amount': 500, 'orderId': orderId}),
);
final data = jsonDecode(res.body) as Map<String, dynamic>;
// Persister la référence pour la retrouver au retour
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pendingRef', data['reference'] as String);
// Ouvrir MonCash dans le navigateur système
final uri = Uri.parse(data['paymentUrl'] as String);
final ok = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible d\'ouvrir MonCash')),
);
}
} catch (e) {
debugPrint('Pay error: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Paiement')),
body: Center(
child: _loading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: _pay,
child: const Text('Payer 500 HTG avec MonCash'),
),
),
);
}
}LaunchMode.externalApplication est obligatoire. AvecLaunchMode.inAppWebView ouinAppBrowserView, MonCash peut bloquer le rendu pour raisons de sécurité.Gérer le retour mcc://payment-return
Initialisez le listener app_links au démarrage. Il gère à la fois les ouvertures runtime et les cold-starts par deep link :
// lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:app_links/app_links.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
final navigatorKey = GlobalKey<NavigatorState>();
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _appLinks = AppLinks();
StreamSubscription<Uri>? _sub;
@override
void initState() {
super.initState();
_initDeepLinks();
}
Future<void> _initDeepLinks() async {
// Cas 1 : cold start — l'app a été lancée PAR le deep link
final initial = await _appLinks.getInitialAppLink();
if (initial != null) _handle(initial);
// Cas 2 : runtime — l'app était ouverte ou en arrière-plan
_sub = _appLinks.uriLinkStream.listen(_handle);
}
Future<void> _handle(Uri uri) async {
if (uri.host != 'payment-return') return;
final prefs = await SharedPreferences.getInstance();
final ref = uri.queryParameters['ref'] ?? prefs.getString('pendingRef');
if (ref == null) return;
// Demander le statut au backend — pas confiance aux paramètres URL
final res = await http.get(
Uri.parse('https://api.maboutique.com/api/payments/$ref'),
);
final tx = jsonDecode(res.body) as Map<String, dynamic>;
if (tx['status'] == 'completed') {
navigatorKey.currentState?.pushReplacementNamed('/success');
} else if (tx['status'] == 'pending') {
// Webhook pas encore arrivé — afficher écran "en cours" + poll
navigatorKey.currentState?.pushReplacementNamed('/pending');
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
home: const CheckoutScreen(),
);
}
}go_router, le pattern reste valable. Plutôt que pushReplacementNamed, appelez router.go('/success') avec le router exposé en singleton.Webhook backend (Node) — vérification HMAC
// backend/server.js (suite)
import crypto from "crypto";
// IMPORTANT : raw body, sinon le hash ne correspond pas
app.post("/webhooks/moncash",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-MCC-Signature") || "";
const timestamp = req.header("X-MCC-Timestamp") || "";
const rawBody = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", process.env.MCC_WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex"),
);
if (!valid) return res.status(401).send("invalid signature");
const event = JSON.parse(rawBody);
if (event.event === "payment.completed") {
// Marquer la commande payée + push FCM/APNs au device
} else if (event.event === "payment.failed") {
// Marquer la commande échouée + alerter l'utilisateur
}
res.status(200).send("OK");
},
);payment.completed — l'utilisateur voit la confirmation même si l'app était fermée.Liste de contrôle avant publication
Le schéma mcc:// est déclaré dans AndroidManifest.xml ET Info.plist
launchMode=singleTop (ou singleTask) sur l'Activity Android principale
Aucune clé sk_proj_… n'est référencée dans le code Dart
url_launcher est appelé avec LaunchMode.externalApplication, pas inAppWebView
AppLinks().getInitialAppLink() est appelé pour les cold-starts
AppLinks().uriLinkStream est écouté pour les ouvertures runtime
Le statut est lu via le backend — pas déduit des query params de l'URL retour
Le backend vérifie le HMAC en temps constant (timingSafeEqual)
Le webhook est idempotent (même reference reçue 2× ne livre qu'une fois)
Firebase Cloud Messaging est configuré pour notifier le résultat
SharedPreferences sauvegarde la référence pendante (au cas où le deep link arrive sans ?ref)
FAQ
url_launcher ou flutter_inappwebview ?+
uni_links ou app_links ?+
Faut-il une clé API différente pour Android vs iOS ?+
Comment gérer le cas où l'utilisateur ferme l'app pendant le paiement ?+
Le test fonctionne sur émulateur Android ?+
Mon app Flutter web est concernée ?+
Lectures recommandées :