Implementing PG Payments in Flutter via WebView (When No SDK Exists)

When adding payment functionality to a mobile app, the standard operating procedure is to pull the native SDK provided by the Payment Gateway (PG). It’s clean, it’s documented, and it works. But reality often deviates from the ideal path. Recently, we faced a project requirement involving a specific domestic PG that simply did not offer a Flutter SDK. We were left with their raw web integration guide and a deadline.

This article documents how we bypassed the lack of an SDK by architecting a robust payment flow using WebView. We will cover the specific technical hurdles, primarily handling deep links and intent schemes, to ensure a seamless transaction experience.

The "Intent" Trap: Why Standard WebViews Fail

The core challenge isn't rendering the payment page; it's what happens during the transaction. Modern PGs (especially in Asia) rely heavily on App-to-App authentication. They attempt to launch banking apps or digital wallets (like Venmo, KakaoPay, or Line Pay) to verify the user.

In a standard desktop browser, this is handled via redirects. In a mobile webview_flutter instance, however, the WebView controller often chokes on unknown URL schemes. If the PG redirects to intent://... or a custom scheme like kakaotalk://, the WebView doesn't know how to handle it, resulting in a ERR_UNKNOWN_URL_SCHEME or simply a blank screen.

Critical Android Issue: Android WebViews do not automatically handle intent:// schemes. You must manually parse the intent string to extract the package ID and the browser_fallback_url. Failure to do this means the user is stuck if they don't have the banking app installed.

Furthermore, managing the session state (cookies) between the Flutter app and the PG's redirection logic is vital to ensure the final "Success" or "Failure" callback is captured correctly.

The Solution: Custom Navigation Delegate

To fix this, we need to intercept every navigation request within the WebView. We use the NavigationDelegate to inspect the URL before the WebView attempts to load it. If the URL is a standard http/s link, we allow it. If it is an external app scheme, we delegate the action to the OS using url_launcher.

The following code demonstrates a production-ready implementation that handles Android Intents and general app schemes.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart';

class PaymentWebView extends StatefulWidget {
  final String paymentUrl;
  
  const PaymentWebView({Key? key, required this.paymentUrl}) : super(key: key);

  @override
  State<PaymentWebView> createState() => _PaymentWebViewState();
}

class _PaymentWebViewState extends State<PaymentWebView> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: (NavigationRequest request) async {
            final url = request.url;
            
            // 1. Handle HTTP/S requests normally (allow navigation)
            if (url.startsWith('http://') || url.startsWith('https://')) {
              // Check for specific Success/Fail callback URLs from your PG
              if (url.contains('payment/success')) {
                 // Handle success logic
                 return NavigationDecision.prevent;
              }
              return NavigationDecision.navigate;
            }

            // 2. Handle Android Intent Schemes (The tricky part)
            if (Platform.isAndroid && url.startsWith('intent://')) {
              try {
                // Attempt to launch the app via intent
                await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
                return NavigationDecision.prevent;
              } catch (e) {
                // Fallback: Parsing the intent to find browser_fallback_url
                // In a real scenario, you'd parse the string manually or use a regex
                // to extract 'browser_fallback_url' parameter.
                final fallbackUrl = _extractFallbackUrl(url);
                if (fallbackUrl != null) {
                  await launchUrl(Uri.parse(fallbackUrl));
                }
                return NavigationDecision.prevent;
              }
            }

            // 3. Handle other Custom Schemes (e.g., kakaotalk://, ispmobile://)
            // Just try to launch them externally
            if (await canLaunchUrl(Uri.parse(url))) {
               await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
               return NavigationDecision.prevent;
            }

            return NavigationDecision.prevent;
          },
        ),
      )
      ..loadRequest(Uri.parse(widget.paymentUrl));
  }

  String? _extractFallbackUrl(String intentUrl) {
    // Simple parser logic for demo purposes
    // Real implementation requires robust parsing of S.browser_fallback_url
    if (intentUrl.contains('browser_fallback_url=')) {
      return Uri.decodeFull(intentUrl.split('browser_fallback_url=')[1].split(';')[0]);
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: WebViewWidget(controller: _controller),
    );
  }
}
Note on iOS: iOS is generally stricter about LSApplicationQueriesSchemes in your Info.plist. You must allow list the specific schemes (like `kftc-bankpay`, `ispmobile`) that the PG uses, otherwise canLaunchUrl will return false even if the app is installed.
Scheme Type Example Action Required
Standard Web https://pg.com/pay NavigationDecision.navigate (Allow load in WebView)
Deep Link kakaotalk://... launchUrl (Open external app) + NavigationDecision.prevent
Android Intent intent://... Parse Intent structure → launchUrl or fallback

Conclusion

Implementing a PG integration in Flutter without an official SDK forces you to understand the underlying mechanics of mobile web navigation. While it feels like a hack, wrapping the payment flow in a WebView and manually handling the OS-level intents is a battle-tested strategy that works for almost any payment provider.

The key takeaway is to aggressively filter the URLs in the NavigationDelegate. Do not trust the WebView to handle custom schemes automatically. By explicitly handing off authentication requests to the OS via url_launcher, you bridge the gap between the web payment interface and the native app ecosystem.

Post a Comment