When adding payment functionality to a mobile app, the most common approach is to use the native SDK provided by a Payment Gateway (PG). However, you may face a challenging situation where a dedicated Flutter SDK is not available due to project requirements or a specific PG's policy. This article shares a detailed account of our experience successfully implementing payment integration with a domestic PG using a WebView in the absence of a Flutter SDK, including the technical hurdles we overcame.
1. Designing a WebView-Based Architecture for PG Integration Without an SDK
The absence of a Flutter SDK means we cannot natively control the payment process provided by the PG. The alternative is to display the PG's 'web payment page' within the app, and the most reliable technology for this is the WebView.
For robust payment processing, we designed the following data flow and architecture:
- [Flutter App] Payment Request: When a user taps the 'Pay' button, the app sends product information (name, price) and order details to our backend server.
- [Backend Server] Prepare Payment Request to PG: Based on the information from the app, our server generates a unique order ID (
orderId
) and calls the PG's "prepare payment" API with this information. - [PG Server] Respond with Payment Page URL: The PG server validates the request, generates a unique web payment page URL for this specific transaction, and returns it to our backend server.
- [Backend Server] Forward URL to App: Our backend server then forwards the payment page URL received from the PG to the Flutter app.
- [Flutter App] Load Payment Page in WebView: The app loads the received URL into a WebView, presenting it to the user. From this point, the user proceeds with the payment process (entering card details, authentication, etc.) within the PG's web environment.
- [PG Server → Backend Server] Payment Result Notification (Webhook): Once the user completes the payment (whether it's a success, failure, or cancellation), the PG server sends the result asynchronously to a pre-configured URL on our backend server (a Callback or Webhook URL). This server-to-server communication is the single source of truth for the payment result.
- [PG Web → Flutter App] Redirect After Payment Completion: After the process in the web payment page is finished, the PG redirects the WebView to a 'result page' URL we specified (e.g.,
https://my-service.com/payment/result?status=success
). The app detects this navigation, closes the WebView, and shows an appropriate result screen to the user.
In this architecture, the server is responsible for secure communication with the PG, validating payment data against tampering, and managing the final state. The app is responsible for the user interface and acting as a bridge to the PG's payment page via the WebView. While it might seem straightforward, the real challenges arose from the peculiarities of the local payment environment.
2. The Biggest Hurdle: WebView and External App (App-to-App) Integration
In many regions, especially in Korea, PG web payment pages don't just ask for card numbers. For enhanced security and convenience, they heavily rely on an 'App-to-App' flow, which involves invoking various external applications.
- Credit Card Apps: Directly launching specific card company apps like Shinhan pLay, KB Pay, or the Hyundai Card App for authentication and payment.
- Simple Payment Apps: Launching apps like KakaoPay, Naver Pay, or Toss Pay.
- Security/Authentication Apps: Launching separate authentication apps like ISP/Paybooc or Mobile Ansim-Click.
These external apps are not launched via standard http://
or https://
links. Instead, they use special address formats called Custom URL Schemes or Android Intents. For example:
ispmobile://
: A scheme to launch the ISP/Paybooc app.kftc-bankpay://
: A scheme for bank transfer-related apps.intent://...#Intent;scheme=kb-acp;...;end
: An Android Intent address to launch the KB Pay app.
The problem is that Flutter's official WebView plugin, webview_flutter
, does not know how to handle these non-standard URLs by default. The WebView either displays a 'Page not found' error or does nothing at all. Solving this was the biggest hurdle in our project.
3. The Solution: Intercepting URLs with `navigationDelegate`
The key to solving this problem lies in the navigationDelegate
provided by webview_flutter
. This powerful feature allows developers to intercept every page navigation request within the WebView and execute custom logic.
Our strategy was clear:
- Set up a
navigationDelegate
to monitor all URL loading requests. - If the requested URL is a non-standard scheme (not
http/https
), we prevent the WebView's default navigation behavior by returningNavigationDecision.prevent
. - We analyze the intercepted URL to determine if it's an Android
intent
or an iOS custom scheme. - We then call the appropriate native code or a helper package (like
url_launcher
) to launch the external app.
Here is the basic skeleton code for our Flutter WebView
widget.
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'dart:io' show Platform;
class PaymentWebViewScreen extends StatefulWidget {
final String paymentUrl;
const PaymentWebViewScreen({Key? key, required this.paymentUrl}) : super(key: key);
@override
State<PaymentWebViewScreen> createState() => _PaymentWebViewScreenState();
}
class _PaymentWebViewScreenState extends State<PaymentWebViewScreen> {
late final WebViewController _controller;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Payment')),
body: WebView(
initialUrl: widget.paymentUrl,
javascriptMode: JavascriptMode.unrestricted,
onWebViewCreated: (WebViewController webViewController) {
_controller = webViewController;
},
navigationDelegate: (NavigationRequest request) {
// 1. Detect the final payment result URL (success/cancel/fail)
if (request.url.startsWith('https://my-service.com/payment/result')) {
// TODO: Process the payment result and close the WebView screen
Navigator.of(context).pop('Payment attempt finished');
return NavigationDecision.prevent; // Prevent the WebView from navigating to this URL
}
// 2. Handle external app launch URLs (non-http)
if (!request.url.startsWith('http://') && !request.url.startsWith('https://')) {
if (Platform.isAndroid) {
_handleAndroidIntent(request.url);
} else if (Platform.isIOS) {
_handleIosUrl(request.url);
}
return NavigationDecision.prevent; // Crucial to prevent default WebView behavior
}
// 3. Allow all other http/https URLs to load normally
return NavigationDecision.navigate;
},
),
);
}
// Logic for handling Android Intents (detailed below)
void _handleAndroidIntent(String url) {
// ...
}
// Logic for handling iOS Custom Schemes (detailed below)
void _handleIosUrl(String url) {
// ...
}
}
3.1. Android: Battling `intent://` with a MethodChannel
On Android, the intent://
scheme is the most challenging. This URL contains complex information, including the target app's package name and a fallback URL (usually a Google Play Store link) for when the app isn't installed. Parsing and executing this from Dart code alone is nearly impossible; it absolutely requires help from native Android code. To achieve this, we use Flutter's MethodChannel.
Flutter (Dart) Side Code
First, add the url_launcher
package. While it can't handle intent://
directly, it's useful for launching simpler schemes like market://
or fallback URLs.
flutter pub add url_launcher
Now, let's flesh out the _handleAndroidIntent
function. We'll pass URLs starting with intent://
to the native side and try to handle other schemes with url_launcher
.
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
// ... inside the _PaymentWebViewScreenState class ...
// Define the channel to communicate with native Android code
static const MethodChannel _channel = MethodChannel('com.mycompany.myapp/payment');
Future<void> _launchAndroidIntent(String url) async {
if (url.startsWith('intent://')) {
try {
// Pass the intent URL to native code and request execution
await _channel.invokeMethod('launchIntent', {'url': url});
} on PlatformException catch (e) {
// Failed to launch intent (e.g., unhandled intent)
debugPrint("Failed to launch intent: '${e.message}'.");
}
} else {
// For other schemes (e.g., market://, ispmobile://)
// try to launch with url_launcher
_launchUrl(url);
}
}
// A common function using url_launcher
Future<void> _launchUrl(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
// Could not launch the URL (app not installed, etc.)
debugPrint('Could not launch $url');
}
}
// The final function to be called from the navigationDelegate
void _handleAndroidIntent(String url) {
_launchAndroidIntent(url);
}
Native Android (Kotlin) Side Code
Now, in your android/app/src/main/kotlin/.../MainActivity.kt
file, add the code to receive the method channel call and handle the intent
.
package com.mycompany.myapp
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Intent
import android.net.Uri
import java.net.URISyntaxException
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.mycompany.myapp/payment"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
call, result ->
if (call.method == "launchIntent") {
val url = call.argument<String>("url")
if (url != null) {
launchIntent(url)
result.success(null) // Notify Flutter that the call was handled
} else {
result.error("INVALID_URL", "URL is null.", null)
}
} else {
result.notImplemented()
}
}
}
private fun launchIntent(url: String) {
val intent: Intent?
try {
// 1. Parse the intent URL string into an Android Intent object
intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
// 2. Check if an app exists to handle this Intent
val packageManager = context.packageManager
val existPackage = intent.`package`?.let {
packageManager.getLaunchIntentForPackage(it)
}
if (existPackage != null) {
// 3. If the app is installed, launch it
startActivity(intent)
} else {
// 4. If the app is not installed, navigate to the fallback URL (usually a market URL)
val fallbackUrl = intent.getStringExtra("browser_fallback_url")
if (fallbackUrl != null) {
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse(fallbackUrl))
startActivity(marketIntent)
}
}
} catch (e: URISyntaxException) {
// Handle malformed URI
e.printStackTrace()
} catch (e: Exception) {
// Handle other exceptions
e.printStackTrace()
}
}
}
This native code receives the intent://
string from Flutter, parses it into a standard Android Intent
object, checks if the target app is installed, and either launches it or redirects to the Play Store via the browser_fallback_url
. This fully resolves the app-to-app integration issue on Android.
3.2. iOS: The Importance of Custom URL Schemes and `Info.plist`
The situation on iOS is a bit simpler than on Android. Instead of complex intents, iOS primarily uses simple custom schemes like ispmobile://
or kakaopay://
. In most cases, the url_launcher
package is sufficient to handle them.
However, there is a critically important prerequisite. Since iOS 9, due to privacy policy enhancements, you must whitelist the URL schemes of the apps you intend to call in your Info.plist
file. If a scheme is not on this list, canLaunchUrl
will always return false
, and you won't be able to launch the app.
Configuring `ios/Runner/Info.plist`
Open your ios/Runner/Info.plist
file and add the LSApplicationQueriesSchemes
key with an array containing all the schemes required for your integration. You must consult your PG's developer guide to get a complete list.
LSApplicationQueriesSchemes
kakaotalk
kakaopay
ispmobile
kftc-bankpay
shinhan-sr-ansimclick
hdcardappcardansimclick
kb-acp
lotteappcard
nhallonepayansimclick
mpocket.online.ansimclick
Flutter (Dart) Side Code
Now, you can implement the _handleIosUrl
function using url_launcher
. No MethodChannel is needed; Dart code is sufficient.
// ... inside the _PaymentWebViewScreenState class ...
void _handleIosUrl(String url) {
_launchUrl(url); // Reuse the common _launchUrl function created earlier
}
// The _launchUrl function is already defined above.
// For iOS, canLaunchUrl will return true if the scheme is registered in Info.plist.
Future<void> _launchUrl(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
// LaunchMode.externalApplication is needed to open the app directly without going through Safari
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
// App is not installed
// You can redirect to the App Store link provided by the PG,
// or show a dialog to the user.
// e.g., if (url.startsWith('ispmobile')) { launchUrl(Uri.parse('APP_STORE_ISP_LINK')); }
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('App Not Installed'),
content: const Text('To proceed with the payment, an external app needs to be installed. Please install it from the App Store.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('OK')),
],
),
);
}
}
With this, you can successfully launch external apps on iOS. Remember, the Info.plist
configuration is the most critical part of this process.
4. Returning to the App and the Crucial 'Server-Side Verification'
After the payment is completed in the external app and the user returns to our app's WebView, the PG will redirect to the agreed-upon result page (e.g., https://my-service.com/payment/result?status=success&orderId=...
). We must detect this URL in our navigationDelegate
to finalize the payment process.
// ... inside the navigationDelegate ...
if (request.url.startsWith('https://my-service.com/payment/result')) {
// Parse the query parameters from the URL to check the result
final Uri uri = Uri.parse(request.url);
final String status = uri.queryParameters['status'] ?? 'fail';
final String orderId = uri.queryParameters['orderId'] ?? '';
// 🚨 SECURITY WARNING: Do NOT assume the payment was successful here!
// This information from the client (app) can be easily manipulated.
// You MUST re-verify the final payment status with your own server.
// Example of calling a server API for final verification
// Future<void> verifyPayment() async {
// try {
// bool isSuccess = await MyApiService.verifyPayment(orderId);
// if (isSuccess) {
// // Final success handling, navigate to success page
// } else {
// // Final failure handling, navigate to failure page
// }
// } catch (e) {
// // Handle communication errors
// }
// }
// Close the WebView and return the status
Navigator.of(context).pop(status);
return NavigationDecision.prevent; // Prevent the WebView from loading this page
}
The most important point is that you must never finalize a payment as successful based solely on the redirect URL's parameters (status=success
). This is a severe security vulnerability, as a malicious user could craft this URL to access paid content without actually paying. The app must make a final verification request to your backend server, asking, "Was the payment for this orderId
really successful?" The server then confirms the true status using the webhook data it received from the PG (in step 6 of our architecture) and responds to the app. Only after this server-side cross-verification is a secure payment system complete.
5. Conclusion: Key Takeaways and Lessons Learned
Integrating a PG without a Flutter SDK was certainly a challenging process. The intricacies of Android's intent
and iOS's Info.plist
configuration were particularly tricky parts that required several rounds of trial and error. However, by effectively using webview_flutter
's navigationDelegate
and platform-specific native integration (MethodChannel), we were able to solve all the issues.
Here are the key lessons learned from this experience:
- Good Architecture is Half the Battle: Clearly defining the entire flow—from payment request to WebView loading, result handling, and final verification—is crucial. Clearly separate the roles of the server and the client.
- URL Interception is the Key: The
navigationDelegate
inwebview_flutter
is the centerpiece of WebView-based payment integration. It gives you the control to handle external app launches and process results. - Respect Platform-Specifics: While Flutter is a cross-platform framework, features like external app integration require you to understand and adhere to each platform's unique mechanisms (Android Intents, iOS Custom Schemes).
- Prioritize Security: Never trust payment success information received from the client. Always perform a final, server-side verification using data from the PG's webhook to prevent fraud.
We hope this guide proves helpful to other developers looking to implement payment features in Flutter.
0 개의 댓글:
Post a Comment