Friday, July 25, 2025

A Complete Guide to PG Payment Integration with Flutter WebView (When an SDK is Unavailable)

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:

  1. [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.
  2. [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.
  3. [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.
  4. [Backend Server] Forward URL to App: Our backend server then forwards the payment page URL received from the PG to the Flutter app.
  5. [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.
  6. [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.
  7. [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:

  1. Set up a navigationDelegate to monitor all URL loading requests.
  2. If the requested URL is a non-standard scheme (not http/https), we prevent the WebView's default navigation behavior by returning NavigationDecision.prevent.
  3. We analyze the intercepted URL to determine if it's an Android intent or an iOS custom scheme.
  4. 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 in webview_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