Skip to content
flutter 2026-02-19

WebView App: Payment Flows, State Sync, and Platform Hacks (Part 2)

Second project, same problems. Payment flows that leave the app, API interception for state sync, and 40 TWINT URL schemes.


WebView App: Payment Flows, State Sync, and Platform Hacks (Part 2)

WebView App: Payment Flows, State Sync, and Platform Hacks (Part 2)

TL;DR: Part 1 showed how complex it gets when you embed websites in apps — using a jewelry e-commerce project as the example. In Part 2, a second project (Swiss electronics retailer, custom backend, different payment providers) shows that the same structural problems keep coming back. Plus additional layers: payment flows that cross app boundaries, native state synchronization through API interception, and platform permissions that work completely differently on iOS and Android.

What Is WebView?

WebView is a system component that lets mobile apps display web content directly inside the application — basically a browser without an address bar. A WebView app wraps existing websites in a native shell instead of rebuilding them natively. This seemingly simple approach creates significant complexity in practice.

Different Project, Same Problems

Cross-platform app development — including WebView-based hybrid apps — is growing at 17.3% annually through 2034, according to Precedence Research. More hybrid apps means more teams running into the same WebView problems.

After Part 1, you might argue: “That was a Shopify project with Shopify quirks. Custom backends would be easier.”

Reality says otherwise. The second project: a Flutter app for a Swiss electronics retailer. No Shopify — instead a proprietary backend with REST APIs, custom authentication, and payment integration via Datatrans. Same structural problems.

The architecture mirrors the hybrid approach: native screens for auth, home, search, and wishlists. WebViews for product pages, cart, checkout, and account settings. Same plugin system. Same JavaScript bridge (window.mobileApp). Same shouldOverrideUrlLoading logic with platform-specific workarounds.

But this project has something the Shopify version didn’t: a payment flow that leaves the WebView. That makes a real difference.

In numbers: 648 lines for the WebView container, 223 for the payment browser, 436 for the checkout page, 10 plugins. For “just embed the website.”

Payment Flows: When WebView Isn’t Enough

In the Shopify project, the entire checkout ran inside the WebView. Not here. Payment flows are their own layer of complexity.

The Problem: Three Worlds Instead of Two

The checkout flow:

  1. User sees the cart (WebView #1)
  2. Navigates to checkout (WebView #1 switches to the checkout URL)
  3. Selects payment method and clicks “Pay”
  4. Payment provider (Datatrans) opens its own interface
  5. Depending on the payment method, an external app launches (TWINT, Google Pay)
  6. After payment, the user returns to the app — to the confirmation page

That’s not two worlds (native + web), but three: native + WebView + external payment apps. Every transition is a potential point of failure.

InAppBrowser: A Separate Browser for Payments

The solution uses InAppBrowser — not InAppWebView. The difference matters: an InAppBrowser is a standalone browser window with its own toolbar that floats above the app. An InAppWebView is a widget inside Flutter’s widget tree.

Why separate? Because payment providers like Datatrans call URLs that require navigation beyond normal WebView boundaries — to TWINT, credit card verification pages, external apps. An embedded WebView can’t handle that reliably.

Future<void> launchPaymentBrowser(Uri paymentUri) async {
  final paymentBrowser = PaymentBrowser(
    ref,
    checkoutWebviewController: webviewController,
    paymentSuccessCallback: () async {
      context.router.popUntilRoot();
      await context.router.navigate(const HomeTab());
    },
  );

  await paymentBrowser.openUrlRequest(
    urlRequest: URLRequest(url: WebUri.uri(paymentUri)),
    settings: InAppBrowserClassSettings(
      browserSettings: InAppBrowserSettings(
        hideUrlBar: true,
        hideToolbarBottom: true,
        hideDefaultMenuItems: true,
        closeButtonCaption: 'Zurück zum Shop',
      ),
    ),
  );
}

Platform divergence shows up immediately: iOS and Android need different close button implementations. iOS offers a native closeButtonCaption. Android needs toolbar menu item injection:

if (Platform.isAndroid) {
  browser.addMenuItem(
    InAppBrowserMenuItem(
      id: 1,
      title: 'Zurück zum Shop',
      showAsAction: true,
      order: 0,
      onClick: () {
        browser.close();
        browser.onExit();
      },
    ),
  );
}

TWINT and intent:// — When URLs Stop Being URLs

TWINT is the Swiss mobile payment solution and uses custom URL schemes: twint://, twint-issuer1:// through twint-issuer39://, twint-extended://. Forty different URL schemes for one payment provider.

The payment browser has to recognize these schemes and hand them off to the operating system to launch the TWINT app:

bool isCustomAppScheme(String url) {
  if (url.isEmpty) return false;
  final uri = Uri.parse(url);
  final scheme = uri.scheme.toLowerCase();
  if (scheme.startsWith('twint')) return true;
  if (url.contains('://applinks/')) return true;
  return false;
}

Android additionally supports intent:// URLs. These special Android URIs open apps directly — with a Play Store fallback if the app isn’t installed:

if (Platform.isAndroid && url.startsWith('intent://')) {
  try {
    await AndroidIntent.parseAndLaunch(uri.toString());
    return NavigationActionPolicy.CANCEL;
  } catch (e) {
    log('Error launching Android Intent: $e');
    return NavigationActionPolicy.ALLOW;
  }
}

iOS doesn’t know intent:// — custom URL schemes are opened directly via launchUrl there. Again, two separate code paths for two platforms.

Error Detection and Confirmation Pages

The payment browser needs to determine whether the payment succeeded. No callback, no event — just the URL. The browser stores every visited URL in a queue and checks on every page change:

class PaymentBrowser extends InAppBrowser {
  final visitedUrls = Queue<Uri>();

  
  void onLoadStart(WebUri? url) {
    if (url.toString().contains('datatrans=error')) {
      close();
      return;
    }
    if (url != null) visitedUrls.add(url);
  }

  
  Future<void> onExit() async {
    await ref.read(cartControllerProvider.notifier).refresh();

    if (lastVisitedUrl != null) {
      final isConfirmation =
        await isCheckoutConfirmationUrl(lastVisitedUrl!);
      if (isConfirmation) {
        await paymentSuccessCallback();
      } else {
        await checkoutWebviewController?.reload();
      }
    }
  }
}

When the user closes the payment browser — via the close button or a system gesture — the app determines payment success based on the last visited URL. No API call, no server event — pure URL pattern matching against remote config values.

Confirmation URLs don’t come from hardcoded values but from Firebase Remote Config. Payment URLs change without an app update.

The Checkout State Machine

The checkout has its own state machine. A cart_mutation URL parameter signals whether the cart has changed since the last checkout:

bool shouldInitCart(WidgetRef ref, AsyncValue<CartData> cartState) {
  final cartWebviewState = ref.watch(cartWebviewControllerProvider);
  final currentCartHash = cartState.value.hashCode;
  final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;
  return lastCheckoutCartHash == null ||
         currentCartHash != lastCheckoutCartHash;
}

final checkoutUrl =
  '${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}';

Why this matters: the checkout WebView persists across tab switches. When users navigate to the cart, add a product, and then return to checkout, the app has to decide whether to reinitialize the checkout or keep the existing state.

A URL blacklist from Remote Config handles this:

final blacklistHit = shopConfig
    .checkoutBlacklistRules
    .matchUri(uri);

if (blacklistHit) {
  context.router.push(StandaloneWebViewRoute(url: uri.toString()));
  return NavigationActionPolicy.CANCEL;
}

This allows removing specific URLs from the checkout flow at runtime — without an app update. If a payment provider introduces a new redirect page that breaks the checkout, the backend team adds the URL to the blacklist, and the app opens it in a separate WebView.

Native State Synchronization: The Hidden Complexity

In the Shopify project, there was a JavaScript bridge through which the website actively informed the app (“Hey, cart changed!”). That requires cooperation from the website — web-side code has to call the bridge.

This project implements an additional, passive approach: the app watches WebView API calls.

onLoadResource: The Silent Observer

Whenever the WebView loads a resource — images, scripts, API calls — the onLoadResource callback fires. The app uses this to detect specific API endpoints and update native state:

onLoadResource: (controller, resource) {
  handlePricePreferenceChange(resource, ref);
  handleWatchlistChanges(resource, ref);
  handleCompareListChange(resource, ref);
},

Concrete implementation:

void handlePricePreferenceChange(LoadedResource resource, WidgetRef ref) {
  final isPricesEndpoint = resource.url
      .toString()
      .endsWith('api/accounts/settings/prices');

  if (isPricesEndpoint) {
    ref.read(sessionControllerProvider.notifier)
      .refreshSessionState();
  }
}

void handleWatchlistChanges(LoadedResource resource, WidgetRef ref) {
  final isWatchlistOverview = resource.url
      .toString()
      .endsWith('/api/watchlists');

  if (isWatchlistOverview) {
    ref.invalidate(watchlistOverviewControllerProvider);
  }

  final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');
  final deleteItemRegex =
    RegExp(r'/api/watchlists/(.+)/items/(.+)$');
  final isItemChange = resource.url.toString()
      .contains(addItemRegex) ||
      resource.url.toString().contains(deleteItemRegex);

  if (isItemChange) {
    final watchlistId = resource.url
        .toString()
        .split('/api/watchlists/')[1]
        .split('/')[0];
    ref.read(watchlistControllerFamily(watchlistId)
      .notifier).refresh();
    ref.invalidate(watchlistOverviewControllerProvider);
  }
}

Elegant and fragile at the same time. Elegant because no changes to the web codebase are needed — the app simply observes WebView behavior. Fragile because API endpoint changes break the pattern matching.

onLoadResource fires for all resources — images, CSS, JavaScript, fonts, API calls. The app filters by URL patterns and only reacts to relevant endpoints. This works as long as the endpoint structure stays stable.

Platform Permissions: Camera, 2FA, and User-Agent Spoofing

HeyLight Pay: When the Payment Provider Needs Camera Access

HeyLight — a buy-now-pay-later payment provider — needs camera access for identity verification. In normal browsers, no problem. In a WebView inside an app? Extremely involved.

Problem 1: HeyLight detects WebView. HeyLight does browser detection and blocks anything that isn’t Safari or Chrome. Standard Flutter WebView user agent identification gets rejected.

The fix — similar to the Shopify project: user-agent spoofing. But only on iOS, because HeyLight explicitly checks for Safari:

userAgent: Platform.isIOS
    ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) '
      'AppleWebKit/605.1.15 (KHTML, like Gecko) '
      'Version/26.0 Mobile/15E148 Safari/604.1'
    : null,

Problem 2: iOS requesting camera permissions twice causes crashes. When the WebView requests camera permissions, iOS automatically shows the system dialog. If the app also calls Permission.camera.request(), you get problems — the dialog appears twice or the permission is silently denied.

onPermissionRequest: (controller, request) async {
  final resources = request.resources;

  if (resources.contains(PermissionResourceType.CAMERA)) {
    final isGranted = await Permission.camera.isGranted;

    if (Platform.isIOS) {
      return PermissionResponse(
        resources: resources,
        action: isGranted
            ? PermissionResponseAction.GRANT
            : PermissionResponseAction.DENY,
      );
    }

    var status = await Permission.camera.request();
    if (!status.isGranted) {
      await showDialog(
        context: context,
        builder: (_) => const CameraPermissionDeniedDialog(),
      );
    }

    status = await Permission.camera.status;
    return PermissionResponse(
      resources: resources,
      action: status.isGranted
          ? PermissionResponseAction.GRANT
          : PermissionResponseAction.DENY,
    );
  }
  return PermissionResponse(resources: resources);
},

Three different behaviors: iOS shows the dialog itself, Android needs manual triggering, and permanently denied permissions need user guidance to device settings. All for one payment provider that needs camera access.

TOTP/2FA: When WebView Needs to Open Another App

The web authentication supports two-factor authentication via TOTP. When setting up 2FA, an otpauth:// link is generated for an authenticator app.

Normal browsers open the authenticator app automatically. WebViews? The link is treated as navigation — and fails.

if (url.startsWith('otpauth:')) {
  await handleTotpUrl(url);
  return NavigationActionPolicy.CANCEL;
}
Future<void> handleTotpUrl(String totpUrl) async {
  try {
    final uri = Uri.parse(totpUrl);
    final canLaunch = await canLaunchUrl(uri);

    if (canLaunch) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    } else {
      showWarning(
        title: 'Keine Authenticator-App gefunden',
        subtitle: 'Bitte installiere eine Authenticator-App...',
      );
    }
  } catch (e) {
    showWarning(
      title: 'Fehler',
      subtitle: 'Der QR-Code konnte nicht verarbeitet werden.',
    );
  }
}

CSS Injection and Other Creative Solutions

Pull-to-Refresh: 101vh

Pull-to-refresh in WebView has a bug: if the page content isn’t taller than the viewport, the gesture doesn’t work. The fix? CSS injection that artificially increases content height by 1%:

class WebviewPullToRefreshEnablerPlugin implements IWebViewPlugin {
  
  List<UserScript>? get userScripts => [
    UserScript(
      source: """
        (function() {
          var style = document.createElement('style');
          style.textContent = 'main { min-height: 101vh; }';
          document.head.appendChild(style);
        })();
      """,
      injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
    ),
  ];
}

101% viewport height. Because 100% doesn’t signal to the WebView that the content can be scrolled — and without scroll capability, pull-to-refresh doesn’t fire.

Bot Detection: One Cookie for Development

Analytics services like Tealium use bot detection. During development — where developers load pages hundreds of times a day — the WebView gets flagged as a bot and blocked.

if (kDebugMode)
  Cookie(name: 'teal_bdwl', value: '1'),

A single cookie. Without it, development becomes impossible.

Pattern Recognition

Two projects. Different industries (jewelry vs. electronics). Different backends (Shopify vs. custom). Different payment providers (Shop Pay vs. Datatrans/TWINT). Yet the same patterns:

PatternProject 1 (Jewelry/Shopify)Project 2 (Electronics/Custom)
User-Agent SpoofingShopify blocks WebView UAHeyLight blocks non-Safari UA
iOS vs. Android NavigationNavigationType.BACK_FORWARD vs. isRedirectonDidReceiveServerRedirectForProvisionalNavigation vs. isRedirect
WebView Destroy & RecreateValueKey with auth tokenrebuildIndex with cookie changes
Artificial Delays700ms for back-navigation reload300ms for share dialog timing
CSS/JS InjectionConsent data, height measurementPull-to-refresh 101vh, padding fix
Remote ConfigWhitelisted domainsCheckout blacklist, payment URLs

These aren’t random overlaps. They are structural consequences of the WebView approach.

Conclusion

The takeaway from both projects: WebView complexity is not project-specific — it’s structural.

Backend origin — Shopify or custom — doesn’t matter. Payment provider — Shop Pay or Datatrans — doesn’t matter. Industry — jewelry or electronics — doesn’t matter. As soon as you embed websites in native apps, the same problems show up.

That doesn’t mean WebViews are the wrong choice. It means: plan for the complexity. Not “we’ll handle it later,” but as a permanent part of the architecture planning.

The question for decision-makers stays the same as in Part 1: Not whether, but where. Which screens native, which WebView, how do they communicate? Whoever answers this question with the knowledge from two real projects makes better decisions.

FAQ

Can payment flows like TWINT or Google Pay run directly inside the WebView?

Unreliably, at best. Payment providers like TWINT use custom URL schemes (twint://, twint-issuer1:// through twint-issuer39://) that can’t be opened directly from the WebView. Solution: a separate InAppBrowser that hands schemes off to the operating system.

What’s the difference between InAppWebView and InAppBrowser in Flutter?

InAppWebView is embedded in Flutter’s widget tree — comparable to an HTML <iframe>. InAppBrowser opens a standalone browser window with its own toolbar that floats above the app.

Why is user-agent spoofing necessary in WebView apps?

Many servers and services detect standard WebView user agents and deny access. In practice, user agents have to be hardcoded per platform.

How do you sync native app state with the WebView?

Two approaches: active (JavaScript bridge, website notifies the app) and passive (app watches WebView API calls via onLoadResource). Both have trade-offs.

Are WebView problems framework-specific or cross-platform?

The problems are cross-platform and structural. Whether Flutter, React Native, or native development — the fundamental challenges show up in every framework that uses WebViews.

KH
Khalit Hartmann Freelance Mobile & Full-Stack Developer