FlutterFlutter 3.16+ · Dart 3.2+ · url_launcher · app_links

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.

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

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(),
    );
  }
}
Si vous utilisez 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");
  },
);
Pour Android et iOS, configurez Firebase Cloud Messaging et envoyez un push au moment du webhook 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 ?+
url_launcher avec LaunchMode.externalApplication. La page MonCash est servie par l'app Digicel et bloque certains contextes WebView. Le navigateur système est le seul chemin garanti.
uni_links ou app_links ?+
app_links est le successeur recommandé en 2025 — il supporte universal links iOS, app links Android et schémas custom de la même API. Si vous démarrez aujourd'hui, choisissez app_links.
Faut-il une clé API différente pour Android vs iOS ?+
Non. La clé MonCashConnect est côté backend uniquement, donc Android et iOS partagent exactement le même flux serveur.
Comment gérer le cas où l'utilisateur ferme l'app pendant le paiement ?+
Le webhook payment.completed arrive sur votre backend indépendamment de l'app. Au prochain démarrage, l'écran de commande lit le statut via votre backend et affiche l'état correct.
Le test fonctionne sur émulateur Android ?+
Oui. L'émulateur Android ouvre paymentUrl dans Chrome, accepte le paiement Sandbox et redirige vers mcc://payment-return — pourvu que l'intent-filter soit déclaré.
Mon app Flutter web est concernée ?+
Pour Flutter web, traitez-le comme un site classique : pas de deep link, returnUrl est une URL https://, et url_launcher fait un window.location.assign.