Architecture mobile
App mobile
|
| POST /api/paiement/initier
↓
Votre backend → LigdiCash
|
| { pay_url, transaction_id }
↓
App mobile
|
| Ouvre pay_url dans une WebView native
↓
Page de paiement LigdiCash
|
| Redirige vers return_url ou cancel_url
↓
WebView détecte l'URL → ferme la WebView
|
| GET /api/paiement/:id/statut
↓
Votre backend (interroge LigdiCash si needed)
|
| { statut: "completed" | "pending" | "notcompleted" }
↓
App mobile — affiche le résultat
Ne jamais ouvrir l’URL LigdiCash dans le navigateur système. Cela fait quitter l’application et rend le retour impossible à détecter proprement. Utilisez une WebView native ou, en fallback acceptable,
SFSafariViewController / Chrome Custom Tabs avec deep link.React Native
Installation
npm install react-native-webview
# iOS uniquement
npx pod-install
Composant WebView de paiement
React Native
import { useState } from "react";
import { ActivityIndicator, StyleSheet, View, Alert } from "react-native";
import { WebView } from "react-native-webview";
/**
* @param {string} paymentUrl - URL retournée par votre backend (response_text LigdiCash)
* @param {Function} onSuccess - Appelé quand return_url est détectée
* @param {Function} onCancel - Appelé quand cancel_url est détectée
*/
export function PaiementWebView({ paymentUrl, onSuccess, onCancel }) {
const [loading, setLoading] = useState(true);
const RETURN_URL = "https://monapp.com/paiement/succes";
const CANCEL_URL = "https://monapp.com/paiement/annule";
const handleNavigationChange = (navState) => {
const { url } = navState;
if (url.startsWith(RETURN_URL)) onSuccess();
else if (url.startsWith(CANCEL_URL)) onCancel();
};
return (
<View style={styles.container}>
<WebView
source={{ uri: paymentUrl }}
onNavigationStateChange={handleNavigationChange}
onLoadStart={() => setLoading(true)}
onLoadEnd={() => setLoading(false)}
onError={() => Alert.alert("Erreur", "Impossible de charger la page de paiement.")}
javaScriptEnabled
/>
{loading && (
<ActivityIndicator style={styles.loader} size="large" color="#FF6B00" />
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
loader: { position: "absolute", alignSelf: "center", top: "50%" },
});
Intégration dans un écran de paiement
React Native
import { useState } from "react";
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from "react-native";
import { PaiementWebView } from "./PaiementWebView";
export function EcranPaiement({ route, navigation }) {
const { commandeId, montant, description } = route.params;
const [payUrl, setPayUrl] = useState(null);
const [transactionId, setTransactionId] = useState(null);
const [statut, setStatut] = useState("idle"); // idle | loading | webview | verifying | done
const initierPaiement = async () => {
setStatut("loading");
const res = await fetch("https://api.monapp.com/api/paiement/initier", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ commandeId, montant, description }),
}).then((r) => r.json());
if (!res.pay_url) {
setStatut("idle");
Alert.alert("Erreur", "Impossible d'initier le paiement. Réessayez.");
return;
}
setTransactionId(res.transaction_id);
setPayUrl(res.pay_url);
setStatut("webview");
};
const verifierPaiement = async () => {
setStatut("verifying");
const res = await fetch(
`https://api.monapp.com/api/paiement/${transactionId}/statut`
).then((r) => r.json());
setStatut("done");
if (res.statut === "completed") {
navigation.replace("CommandeConfirmee", { commandeId });
} else if (res.statut === "notcompleted") {
navigation.replace("PaiementEchoue", { commandeId });
} else {
// pending : le callback n'est pas encore arrivé
navigation.replace("PaiementEnAttente", { transactionId });
}
};
if (statut === "webview") {
return (
<PaiementWebView
paymentUrl={payUrl}
onSuccess={verifierPaiement}
onCancel={() => setStatut("idle")}
/>
);
}
if (statut === "verifying") {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#FF6B00" />
<Text style={styles.text}>Vérification du paiement…</Text>
</View>
);
}
return (
<View style={styles.center}>
<Text style={styles.montant}>{montant.toLocaleString()} XOF</Text>
<Text style={styles.desc}>{description}</Text>
<TouchableOpacity
style={styles.bouton}
onPress={initierPaiement}
disabled={statut === "loading"}
>
{statut === "loading" ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.boutonTexte}>Payer maintenant</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
montant: { fontSize: 32, fontWeight: "bold", marginBottom: 8 },
desc: { fontSize: 16, color: "#666", marginBottom: 32 },
bouton: {
backgroundColor: "#FF6B00",
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 8,
minWidth: 200,
alignItems: "center",
},
boutonTexte: { color: "#fff", fontSize: 18, fontWeight: "600" },
text: { marginTop: 16, fontSize: 16, color: "#666" },
});
Flutter
Dépendance
pubspec.yaml
dependencies:
webview_flutter: ^4.0.0
http: ^1.0.0
Widget WebView de paiement
Flutter
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class PaiementWebView extends StatefulWidget {
final String paymentUrl;
final VoidCallback onSuccess;
final VoidCallback onCancel;
const PaiementWebView({
required this.paymentUrl,
required this.onSuccess,
required this.onCancel,
super.key,
});
@override
State<PaiementWebView> createState() => _PaiementWebViewState();
}
class _PaiementWebViewState extends State<PaiementWebView> {
late final WebViewController _controller;
bool _loading = true;
static const _returnUrl = "https://monapp.com/paiement/succes";
static const _cancelUrl = "https://monapp.com/paiement/annule";
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onPageStarted: (_) => setState(() => _loading = true),
onPageFinished: (_) => setState(() => _loading = false),
onNavigationRequest: (request) {
if (request.url.startsWith(_returnUrl)) {
widget.onSuccess();
return NavigationDecision.prevent;
}
if (request.url.startsWith(_cancelUrl)) {
widget.onCancel();
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
))
..loadRequest(Uri.parse(widget.paymentUrl));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Paiement"),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onCancel,
),
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_loading) const Center(child: CircularProgressIndicator()),
],
),
);
}
}
Écran de paiement complet
Flutter
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'paiement_webview.dart';
class EcranPaiement extends StatefulWidget {
final String commandeId;
final int montant;
final String description;
const EcranPaiement({
required this.commandeId,
required this.montant,
required this.description,
super.key,
});
@override
State<EcranPaiement> createState() => _EcranPaiementState();
}
class _EcranPaiementState extends State<EcranPaiement> {
String _statut = "idle";
String? _payUrl;
String? _transactionId;
Future<void> _initierPaiement() async {
setState(() => _statut = "loading");
final res = await http.post(
Uri.parse("https://api.monapp.com/api/paiement/initier"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"commandeId": widget.commandeId,
"montant": widget.montant,
"description": widget.description,
}),
);
final data = jsonDecode(res.body) as Map<String, dynamic>;
if (data["pay_url"] == null) {
setState(() => _statut = "idle");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Impossible d'initier le paiement.")),
);
}
return;
}
setState(() {
_payUrl = data["pay_url"] as String;
_transactionId = data["transaction_id"] as String;
_statut = "webview";
});
}
Future<void> _verifierPaiement() async {
setState(() => _statut = "verifying");
final res = await http.get(
Uri.parse("https://api.monapp.com/api/paiement/$_transactionId/statut"),
);
final data = jsonDecode(res.body) as Map<String, dynamic>;
final statut = data["statut"] as String?;
if (!mounted) return;
if (statut == "completed") {
Navigator.of(context).pushReplacementNamed(
"/commande-confirmee",
arguments: widget.commandeId,
);
} else if (statut == "notcompleted") {
Navigator.of(context).pushReplacementNamed("/paiement-echoue");
} else {
// pending — le callback n'est pas encore arrivé
Navigator.of(context).pushReplacementNamed(
"/paiement-en-attente",
arguments: _transactionId,
);
}
}
@override
Widget build(BuildContext context) {
if (_statut == "webview") {
return PaiementWebView(
paymentUrl: _payUrl!,
onSuccess: _verifierPaiement,
onCancel: () => setState(() => _statut = "idle"),
);
}
if (_statut == "verifying") {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Vérification du paiement…"),
],
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text("Paiement")),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${widget.montant.toString()} XOF",
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(widget.description, style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _statut == "loading" ? null : _initierPaiement,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF6B00),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _statut == "loading"
? const CircularProgressIndicator(color: Colors.white)
: const Text(
"Payer maintenant",
style: TextStyle(fontSize: 18, color: Colors.white),
),
),
),
],
),
),
),
);
}
}
Gérer l’état “pending”
Quand le callback n’est pas encore arrivé au moment où l’utilisateur revient dans l’application, la transaction estpending. Deux approches :
- Polling côté app
- Notification push
Affichez un écran « Vérification en cours » et interrogez votre backend toutes les 3 secondes pendant 30 secondes maximum.
Flutter — polling
Future<String> attendreStatut(String transactionId) async {
for (var i = 0; i < 10; i++) {
await Future.delayed(const Duration(seconds: 3));
final res = await http.get(
Uri.parse("https://api.monapp.com/api/paiement/$transactionId/statut"),
);
final statut = jsonDecode(res.body)["statut"] as String;
if (statut != "pending") return statut;
}
return "pending"; // toujours pending après 30s
}
Votre backend reçoit le callback LigdiCash et envoie une notification push à l’application via FCM ou APNs. L’application rafraîchit l’écran à la réception.C’est l’approche la plus propre : elle évite le polling et garantit que l’utilisateur voit le résultat dès que LigdiCash confirme, même s’il a mis l’application en arrière-plan.
Points de vigilance
La détection de
return_url ou cancel_url dans la WebView est un signal d’interface, pas une preuve de paiement. Confirmez toujours le statut via votre backend avant d’afficher une confirmation à l’utilisateur.Sur iOS,
WKWebView (utilisé par react-native-webview et webview_flutter) bloque les requêtes vers des URLs HTTP si App Transport Security est activé. Utilisez des URLs HTTPS pour votre return_url et cancel_url.Passez le
transaction_id dans les paramètres de query de return_url et cancel_url (?txn=txn_abc123) pour l’extraire facilement dans la WebView sans avoir à le stocker en état global.Pages associées
- Intégration mobile native (payin redirect) — WebView détaillée pour iOS (Swift) et Android (Kotlin)
- Architecture recommandée — structure backend proxy
- Sécurisation du callback — re-vérification côté serveur
- Guide e-commerce — le même flux côté web
