Skip to main content

1. Creating a Checkout Session

Build a CreateCheckoutSessionDto and call createCheckoutSession:
import 'package:flutter_cashful/api.dart';

Future<CheckoutSessionResponseDto?> createSession({
  required ApiClient apiClient,
  required String merchantId,
  required String currency,
  required num totalAmount,
  List<LineItemDto>? lineItems,
  String? customerId,
  HostedCheckoutConfigDto? hostedCheckoutConfig,
}) async {
  final checkoutsApi = CheckoutsApi(apiClient);

  final dto = CreateCheckoutSessionDto(
    merchantId: merchantId,
    currency: currency,
    totalAmount: totalAmount,
    lineItems: lineItems ?? [],
    metadata: {},
    // Callback URLs for hosted mode (WebView intercepts these):
    successUrl: 'https://cashful.africa/checkout/callback?status=success',
    cancelUrl: 'https://cashful.africa/checkout/callback?status=cancel',
    // Optional: customize the checkout UI
    hostedCheckoutConfig: hostedCheckoutConfig,
    // Optional: link to an existing customer
    customerId: customerId,
  );

  try {
    final session = await checkoutsApi.createCheckoutSession(dto);
    // session.sessionUrl contains the URL to load in the WebView
    // session.id is used for server-side verification later
    return session;
  } on ApiException catch (e) {
    debugPrint('Failed to create checkout session: ${e.code} ${e.message}');
    rethrow;
  }
}

Line Items

Each line item represents a product in the checkout:
final items = [
  LineItemDto(
    name: 'Widget Pro',
    amount: 2500,       // R25.00 -- amounts are in the smallest currency unit (cents)
    currency: 'ZAR',
    quantity: 2,
    imageUrl: 'https://example.com/widget.png',  // optional
  ),
  LineItemDto(
    name: 'Shipping',
    amount: 500,        // R5.00
    currency: 'ZAR',
    quantity: 1,
  ),
];

Hosted Checkout Config

Customize the checkout appearance and behavior:
final config = HostedCheckoutConfigDto(
  merchantAlias: 'My Store',
  merchantLegalName: 'My Store (Pty) Ltd',
  requireContact: true,        // require email/phone
  requireAddress: false,       // require physical address
  methods: [
    HostedCheckoutConfigDtoMethodsEnum.card,
    HostedCheckoutConfigDtoMethodsEnum.wallet,
    HostedCheckoutConfigDtoMethodsEnum.bank,
  ],
);

Response

CheckoutSessionResponseDto contains:
FieldTypeDescription
idStringSession ID — store this for server-side verification
sessionUrlStringURL to load in the WebView
statusStringopen, complete, expired
currencyStringISO 4217 currency code
totalAmountnum?Total in smallest currency unit
lineItemsList<LineItemDto>Line items passed in
merchantIdStringYour merchant ID
expiresAtDateTime?When the session expires
metadataMap<String, Object>Custom metadata

2. WebView Setup

Create a WebViewController, enable JavaScript, register the event channel, and set up navigation handling:
import 'package:webview_flutter/webview_flutter.dart';

late final WebViewController _webViewController;

void _initWebView() {
  _webViewController = WebViewController()
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    ..setNavigationDelegate(
      NavigationDelegate(
        onNavigationRequest: _handleNavigationRequest,
        onPageStarted: _handlePageStarted,
        onPageFinished: _handlePageFinished,
        onWebResourceError: (error) {
          debugPrint('[WebView] Error: ${error.description}');
        },
      ),
    )
    ..setOnConsoleMessage((msg) {
      debugPrint('[WebView Console] ${msg.message}');
    })
    ..addJavaScriptChannel(
      'CheckoutEvents',
      onMessageReceived: _handleCheckoutMessage,
    );
}
JavaScriptMode.unrestricted is required — the checkout page relies on JavaScript for rendering, card encryption, and event dispatch.The CheckoutEvents channel is the native-side receiver. The JavaScript bridge (next section) forwards all checkout events to this channel.

Loading the Session URL

After creating a checkout session, load the sessionUrl:
void _loadCheckout(String sessionUrl) {
  _webViewController.loadRequest(Uri.parse(sessionUrl));
}
Intercept callback URL redirects. In hosted mode, the checkout redirects to your successUrl/cancelUrl after payment. In a WebView, you want to catch these instead of navigating away:
NavigationDecision _handleNavigationRequest(NavigationRequest request) {
  if (request.url.contains('cashful.africa/checkout/callback')) {
    // The checkout is trying to redirect to the callback URL.
    // Extract the status from query params if needed.
    final uri = Uri.parse(request.url);
    final status = uri.queryParameters['status'];
    debugPrint('[Checkout] Callback redirect intercepted: status=$status');

    // Don't navigate -- the JS bridge terminal event handles the transition.
    return NavigationDecision.prevent;
  }
  return NavigationDecision.navigate;
}

Page Lifecycle

Inject the JavaScript bridge at both lifecycle points to ensure coverage:
void _handlePageStarted(String url) {
  debugPrint('[WebView] Page started: $url');
  _webViewController.runJavaScript(_messageBridgeScript);
}

void _handlePageFinished(String url) {
  debugPrint('[WebView] Page finished: $url');
  // Re-inject to cover SPA navigation and late DOM changes
  _webViewController.runJavaScript(_messageBridgeScript);
}

Displaying the WebView

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: WebViewWidget(controller: _webViewController),
  );
}

3. JavaScript Bridge

The checkout page dispatches events through multiple platform-specific channels (window.cashful.postMessage, window.ReactNativeWebView.postMessage, window.webkit.messageHandlers.cashful.postMessage, window.Android.postMessage). The bridge intercepts all of these and funnels them into the single CheckoutEvents JavaScript channel registered on the Dart side.

The Bridge Script

static const _messageBridgeScript = '''
  (function() {
    var checkoutOrigin = window.location.origin;

    function clearStorage(win) {
      try {
        if (win.location.origin !== checkoutOrigin) return;
        win.localStorage.clear();
        win.sessionStorage.clear();
      } catch(e) {}
    }

    function clearCheckoutStorage() {
      clearStorage(window);
      try {
        for (var i = 0; i < window.frames.length; i++) {
          try { clearStorage(window.frames[i]); } catch(e) {}
        }
      } catch(e) {}
    }

    function isTerminalEvent(payload) {
      if (typeof payload === 'string') {
        try { payload = JSON.parse(payload); } catch(e) { return false; }
      }
      if (!payload || typeof payload !== 'object') return false;
      var evt = payload.event || payload.command || '';
      return evt === 'payment.completed' || evt === 'payment.failed';
    }

    function setupBridge(win) {
      var forward = function(payload) {
        if (isTerminalEvent(payload)) {
          clearCheckoutStorage();
        }
        var msg = typeof payload === 'object' ? JSON.stringify(payload) : payload;
        try { CheckoutEvents.postMessage(msg); } catch(e) {}
      };

      if (!win.cashful) {
        win.cashful = { postMessage: forward };
        win.ReactNativeWebView = { postMessage: forward };
        win.webkit = win.webkit || {};
        win.webkit.messageHandlers = win.webkit.messageHandlers || {};
        win.webkit.messageHandlers.cashful = { postMessage: forward };
        win.Android = { postMessage: forward };
      }

      var originalPostMessage = win.postMessage;
      win.postMessage = function(msg, origin) {
        forward(msg);
        originalPostMessage.call(win, msg, origin);
      };

      win.addEventListener('message', function(e) { forward(e.data); });
    }

    setupBridge(window);

    try {
      for (var i = 0; i < window.frames.length; i++) {
        try { setupBridge(window.frames[i]); } catch(e) {}
      }
    } catch(e) {}

    if (window.MutationObserver) {
      new MutationObserver(function() {
        try {
          for (var i = 0; i < window.frames.length; i++) {
            try { setupBridge(window.frames[i]); } catch(e) {}
          }
        } catch(e) {}
      }).observe(document.body || document.documentElement,
        { childList: true, subtree: true });
    }
  })();
''';

How it works

ComponentPurpose
clearStorage(win)Clears localStorage and sessionStorage for same-origin windows
clearCheckoutStorage()Runs clearStorage on the main window and all same-origin iframes
isTerminalEvent(payload)Detects payment.completed or payment.failed events
setupBridge(win)Shims all 4 platform bridge interfaces + wraps postMessage + adds message listener
forward(payload)Serializes to JSON and sends to CheckoutEvents.postMessage() (the native channel)
MutationObserverRe-runs setupBridge on dynamically added iframes (3DS auth frames, card input iframes)
The bridge is injected on both onPageStarted (before the page’s own scripts run) and onPageFinished (to catch SPA re-renders and late DOM mutations).On terminal events, the bridge automatically clears localStorage and sessionStorage within the WebView before forwarding the event to Dart. This prevents stale checkout state from persisting in the WebView’s storage.

4. Event Handling

Event Payload Structure

All events dispatched by the checkout follow this envelope:
{
  "source": "cashful_checkout",
  "event": "<event_type>",
  "data": { ... }
}
The data object varies by event type. Here are the two terminal event payloads:payment.completed:
{
  "source": "cashful_checkout",
  "event": "payment.completed",
  "data": {
    "transactionId": "sb_txn_1740000000_abc123",
    "amount": 5500,
    "paymentMethod": "Card",
    "contactInfo": {
      "email": "john.doe@example.com",
      "phone": "+27 82 123 4567"
    },
    "orderItems": [
      { "id": "item_1", "name": "Premium Widget", "quantity": 2, "price": 2500 },
      { "id": "item_2", "name": "Shipping", "quantity": 1, "price": 500 }
    ]
  }
}
payment.failed:
{
  "source": "cashful_checkout",
  "event": "payment.failed",
  "data": {
    "transactionId": "sb_txn_1740000000_def456",
    "amount": 5500,
    "reason": "card_declined"
  }
}
Note: The payment.failed payload is smaller — it omits paymentMethod, contactInfo, and orderItems, but includes a reason string.

Event Lifecycle

Events fire in this order during a successful payment:
EventWhenData fields
checkout.viewedCheckout page rendered{}
customer.submittedCustomer fills in details and submitsCustomer info
payment.initiatedUser submits payment (card, bank, etc.)Payment method info
payment.completedPayment succeededtransactionId, amount, paymentMethod, contactInfo, orderItems
checkout.completedFull checkout flow doneSession summary
On failure:
EventWhenData fields
payment.failedPayment was declined or erroredtransactionId, amount, reason
Other events:
EventWhen
checkout.expiredThe session expired before payment

Terminal Events

payment.completed and payment.failed are terminal — they signal the end of the checkout flow. Your app should transition to a result screen and clean up the WebView when either is received.

Parsing Events in Dart

void _handleCheckoutMessage(JavaScriptMessage message) {
  try {
    final decoded = jsonDecode(message.message);
    final eventType = decoded['event']?.toString()
        ?? decoded['command']?.toString()
        ?? 'unknown';
    final eventData = decoded is Map
        ? Map<String, dynamic>.from(decoded)
        : null;

    debugPrint('[Checkout Event] $eventType');

    // Check for terminal events
    if (eventType == 'payment.completed') {
      _handlePaymentCompleted(eventData);
    } else if (eventType == 'payment.failed') {
      _handlePaymentFailed(eventData);
    }
  } catch (_) {
    debugPrint('[Checkout] Unparseable message: ${message.message}');
  }
}

5. Terminal Event Handling and Cleanup

When a terminal event arrives, perform cleanup to prevent stale state in the WebView:

Step 1: Disable the Bridge

Replace all bridge endpoints with no-ops so duplicate events aren’t forwarded:
static const _disableBridgeScript = '''
  try {
    function noop(){}
    window.cashful = { postMessage: noop };
    window.ReactNativeWebView = { postMessage: noop };
    if (window.webkit && window.webkit.messageHandlers) {
      window.webkit.messageHandlers.cashful = { postMessage: noop };
    }
    window.Android = { postMessage: noop };
  } catch(e) {}
''';

void _disableBridge() {
  _webViewController.runJavaScript(_disableBridgeScript);
}

Step 2: Clear Cookies

Future<void> _clearCookies() async {
  await WebViewCookieManager().clearCookies();
}

Step 3: Navigate Away

Destroy the checkout page’s execution context:
Future<void> _navigateAway() async {
  _webViewController.loadRequest(Uri.parse('about:blank'));
  await Future.delayed(const Duration(milliseconds: 100));
}

Full Cleanup Sequence

bool _hasHandledTerminalEvent = false;

void _handlePaymentCompleted(Map<String, dynamic>? eventData) {
  if (_hasHandledTerminalEvent) return;
  _hasHandledTerminalEvent = true;

  _performCleanup();
  _showResult(success: true, data: eventData);
}

void _handlePaymentFailed(Map<String, dynamic>? eventData) {
  if (_hasHandledTerminalEvent) return;
  _hasHandledTerminalEvent = true;

  _performCleanup();
  _showResult(success: false, data: eventData);
}

Future<void> _performCleanup() async {
  _disableBridge();
  await _clearCookies();
  await Future.delayed(const Duration(seconds: 1));
  await _navigateAway();
}
The _hasHandledTerminalEvent guard ensures cleanup runs exactly once, even if duplicate terminal events arrive.

6. Server-Side Verification

Never rely solely on client-side events for order fulfillment. After receiving a payment.completed event, verify the session status server-side:
Future<bool> verifyPayment(ApiClient apiClient, String sessionId) async {
  final checkoutsApi = CheckoutsApi(apiClient);

  try {
    final session = await checkoutsApi.retrieveCheckoutSession(sessionId);
    if (session == null) return false;

    return session.status == 'complete';
  } on ApiException catch (e) {
    debugPrint('Verification failed: ${e.code} ${e.message}');
    return false;
  }
}
Use this in your terminal event handler:
void _handlePaymentCompleted(Map<String, dynamic>? eventData) async {
  if (_hasHandledTerminalEvent) return;
  _hasHandledTerminalEvent = true;

  _performCleanup();

  // Verify server-side before confirming to the user
  final verified = await verifyPayment(_apiClient, _sessionId!);

  if (verified) {
    _showResult(success: true, data: eventData);
  } else {
    // Event said success but server disagrees -- handle cautiously
    debugPrint('[Checkout] Server verification failed after payment.completed');
    _showResult(success: false, data: {
      'reason': 'Payment verification pending. Please check your order status.',
    });
  }
}
In production, this verification should happen on your backend, not in the mobile app. The app sends the session ID to your server, your server calls retrieveCheckoutSession with its own API credentials, and returns the verified result to the app.

7. Sandbox and Testing

Sandbox Environment

Point the SDK to the sandbox API and ensure the checkout loads in sandbox mode:
final apiClient = ApiClient(
  basePath: 'https://sandbox.api.cashful.africa',
  authentication: auth,
);
The checkout page rendered in the WebView detects sandbox mode automatically. You’ll see:
  • An orange “Test Mode” banner at the top of the checkout page
  • A test card dropdown with pre-filled card numbers (no real card needed)
  • A test customer dropdown with pre-filled customer details

Test Cards

Card NumberBrandResult
4242 4242 4242 4242VisaSuccess
5555 5555 5555 4444MastercardSuccess
4000 0000 0000 0002VisaDeclined
4000 0000 0000 9995VisaInsufficient Funds
All test cards use any future expiry date (e.g., 12/28) and any 3-digit CVC (e.g., 123).

Test Customers

The sandbox checkout UI provides pre-filled customer profiles:
NameEmailPhone
John Doejohn.doe@example.com+27 82 123 4567
Jane Smithjane.smith@example.com+27 83 987 6543
Bob Johnsonbob.johnson@example.com+27 84 555 1234

Sandbox Behavior Differences

AspectSandboxProduction
Card encryptionSkipped (raw test numbers)Evervault client-side encryption
3DS authenticationSkipped entirelyReal bank 3DS challenge
3DS redirectGET redirect with 3s delay back to checkoutHidden form POST to iVeri 3DS portal
Payment processingIn-memory lookup against test card listReal gateway call to iVeri
Transaction IDssb_txn_<timestamp>_<random>Real acquirer transaction IDs
Auth codesSB<random>Real acquirer auth codes
Event payloadsInclude sandbox: true flagNo sandbox flag
Result timingSynchronous (immediate)Asynchronous (after 3DS callback)

Switching to Production

When you’re ready to go live:
  1. Change the base URL to https://api.cashful.africa
  2. Use your production API key
  3. Use real card numbers (real charges will occur)
  4. Ensure your backend handles webhook events for order fulfillment

8. Complete Working Example

A full reference implementation showing session creation, WebView checkout, event handling, server-side verification, and result display.
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_cashful/api.dart';
import 'package:webview_flutter/webview_flutter.dart';

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

class CashfulCheckoutConfig {
  // IMPORTANT: In production, move session creation to your backend.
  // The API key should never be shipped in a mobile app binary.
  static const String baseUrl = 'https://sandbox.api.cashful.africa';
  static const String apiKey = 'YOUR_API_KEY';
  static const String merchantId = 'YOUR_MERCHANT_ID';
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

void main() => runApp(const MaterialApp(home: CheckoutDemoPage()));

// ---------------------------------------------------------------------------
// Checkout event model
// ---------------------------------------------------------------------------

class CheckoutEvent {
  final DateTime timestamp;
  final String type;
  final String message;
  final Map<String, dynamic>? data;

  CheckoutEvent({required this.type, required this.message, this.data})
      : timestamp = DateTime.now();

  bool get isTerminal =>
      message == 'payment.completed' || message == 'payment.failed';

  bool get isSuccess => message == 'payment.completed';
}

// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------

class CheckoutDemoPage extends StatefulWidget {
  const CheckoutDemoPage({super.key});

  @override
  State<CheckoutDemoPage> createState() => _CheckoutDemoPageState();
}

class _CheckoutDemoPageState extends State<CheckoutDemoPage> {
  // SDK
  late final ApiClient _apiClient;
  late final CheckoutsApi _checkoutsApi;

  // WebView
  late final WebViewController _webViewController;

  // State
  String? _sessionId;
  bool _isLoading = false;
  bool _isCheckoutVisible = false;
  bool _isFinished = false;
  bool _hasHandledTerminalEvent = false;
  bool? _paymentSuccess;
  Map<String, dynamic>? _resultData;
  final List<CheckoutEvent> _events = [];
  String? _errorMessage;

  // -------------------------------------------------------------------------
  // JavaScript bridge scripts
  // -------------------------------------------------------------------------

  static const _messageBridgeScript = '''
    (function() {
      var checkoutOrigin = window.location.origin;

      function clearStorage(win) {
        try {
          if (win.location.origin !== checkoutOrigin) return;
          win.localStorage.clear();
          win.sessionStorage.clear();
        } catch(e) {}
      }

      function clearCheckoutStorage() {
        clearStorage(window);
        try {
          for (var i = 0; i < window.frames.length; i++) {
            try { clearStorage(window.frames[i]); } catch(e) {}
          }
        } catch(e) {}
      }

      function isTerminalEvent(payload) {
        if (typeof payload === 'string') {
          try { payload = JSON.parse(payload); } catch(e) { return false; }
        }
        if (!payload || typeof payload !== 'object') return false;
        var evt = payload.event || payload.command || '';
        return evt === 'payment.completed' || evt === 'payment.failed';
      }

      function setupBridge(win) {
        var forward = function(payload) {
          if (isTerminalEvent(payload)) {
            clearCheckoutStorage();
          }
          var msg = typeof payload === 'object'
            ? JSON.stringify(payload) : payload;
          try { CheckoutEvents.postMessage(msg); } catch(e) {}
        };

        if (!win.cashful) {
          win.cashful = { postMessage: forward };
          win.ReactNativeWebView = { postMessage: forward };
          win.webkit = win.webkit || {};
          win.webkit.messageHandlers = win.webkit.messageHandlers || {};
          win.webkit.messageHandlers.cashful = { postMessage: forward };
          win.Android = { postMessage: forward };
        }

        var originalPostMessage = win.postMessage;
        win.postMessage = function(msg, origin) {
          forward(msg);
          originalPostMessage.call(win, msg, origin);
        };

        win.addEventListener('message', function(e) { forward(e.data); });
      }

      setupBridge(window);

      try {
        for (var i = 0; i < window.frames.length; i++) {
          try { setupBridge(window.frames[i]); } catch(e) {}
        }
      } catch(e) {}

      if (window.MutationObserver) {
        new MutationObserver(function() {
          try {
            for (var i = 0; i < window.frames.length; i++) {
              try { setupBridge(window.frames[i]); } catch(e) {}
            }
          } catch(e) {}
        }).observe(document.body || document.documentElement,
          { childList: true, subtree: true });
      }
    })();
  ''';

  static const _disableBridgeScript = '''
    try {
      function noop(){}
      window.cashful = { postMessage: noop };
      window.ReactNativeWebView = { postMessage: noop };
      if (window.webkit && window.webkit.messageHandlers) {
        window.webkit.messageHandlers.cashful = { postMessage: noop };
      }
      window.Android = { postMessage: noop };
    } catch(e) {}
  ''';

  // -------------------------------------------------------------------------
  // Lifecycle
  // -------------------------------------------------------------------------

  @override
  void initState() {
    super.initState();
    _initSdk();
    _initWebView();
  }

  void _initSdk() {
    final auth = ApiKeyAuth('header', 'x-api-key');
    auth.apiKey = CashfulCheckoutConfig.apiKey;

    _apiClient = ApiClient(
      basePath: CashfulCheckoutConfig.baseUrl,
      authentication: auth,
    );
    _checkoutsApi = CheckoutsApi(_apiClient);
  }

  void _initWebView() {
    _webViewController = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: _handleNavigationRequest,
          onPageStarted: (url) {
            _webViewController.runJavaScript(_messageBridgeScript);
          },
          onPageFinished: (url) {
            _webViewController.runJavaScript(_messageBridgeScript);
          },
          onWebResourceError: (error) {
            debugPrint('[WebView] Error: ${error.description}');
          },
        ),
      )
      ..setOnConsoleMessage((msg) {
        debugPrint('[WebView Console] ${msg.message}');
      })
      ..addJavaScriptChannel(
        'CheckoutEvents',
        onMessageReceived: _handleCheckoutMessage,
      );
  }

  // -------------------------------------------------------------------------
  // Checkout session creation
  // -------------------------------------------------------------------------

  Future<void> _startCheckout() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
      _events.clear();
      _hasHandledTerminalEvent = false;
      _isFinished = false;
      _paymentSuccess = null;
      _resultData = null;
    });

    try {
      final dto = CreateCheckoutSessionDto(
        merchantId: CashfulCheckoutConfig.merchantId,
        currency: 'ZAR',
        totalAmount: 5500, // R55.00
        lineItems: [
          LineItemDto(
            name: 'Premium Widget',
            amount: 2500,
            currency: 'ZAR',
            quantity: 2,
          ),
          LineItemDto(
            name: 'Shipping',
            amount: 500,
            currency: 'ZAR',
            quantity: 1,
          ),
        ],
        metadata: {
          'orderId': 'order_${DateTime.now().millisecondsSinceEpoch}',
        },
        hostedCheckoutConfig: HostedCheckoutConfigDto(
          merchantAlias: 'Demo Store',
          requireContact: true,
          methods: [HostedCheckoutConfigDtoMethodsEnum.card],
        ),
      );

      final session = await _checkoutsApi.createCheckoutSession(dto);

      if (session == null) {
        setState(() {
          _isLoading = false;
          _errorMessage = 'Failed to create checkout session: null response';
        });
        return;
      }

      _sessionId = session.id;
      _webViewController.loadRequest(Uri.parse(session.sessionUrl));

      setState(() {
        _isLoading = false;
        _isCheckoutVisible = true;
      });
    } on ApiException catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = 'API error ${e.code}: ${e.message}';
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = 'Unexpected error: $e';
      });
    }
  }

  // -------------------------------------------------------------------------
  // Navigation handling
  // -------------------------------------------------------------------------

  NavigationDecision _handleNavigationRequest(NavigationRequest request) {
    if (request.url.contains('cashful.africa/checkout/callback')) {
      debugPrint('[Checkout] Callback redirect intercepted: ${request.url}');
      return NavigationDecision.prevent;
    }
    return NavigationDecision.navigate;
  }

  // -------------------------------------------------------------------------
  // Event handling
  // -------------------------------------------------------------------------

  void _handleCheckoutMessage(JavaScriptMessage message) {
    try {
      final decoded = jsonDecode(message.message);
      final eventType = decoded['event']?.toString() ??
          decoded['command']?.toString() ??
          'unknown';
      final eventData =
          decoded is Map ? Map<String, dynamic>.from(decoded) : null;

      final event = CheckoutEvent(
        type: 'received',
        message: eventType,
        data: eventData,
      );

      setState(() => _events.insert(0, event));

      if (event.isTerminal) {
        _handleTerminalEvent(event);
      }
    } catch (_) {
      debugPrint('[Checkout] Unparseable: ${message.message}');
    }
  }

  // -------------------------------------------------------------------------
  // Terminal event handling and cleanup
  // -------------------------------------------------------------------------

  Future<void> _handleTerminalEvent(CheckoutEvent event) async {
    if (_hasHandledTerminalEvent) return;
    _hasHandledTerminalEvent = true;

    // 1. Disable the bridge
    _webViewController.runJavaScript(_disableBridgeScript);

    // 2. Clear cookies
    await WebViewCookieManager().clearCookies();

    // 3. Brief pause for pending operations
    await Future.delayed(const Duration(seconds: 1));
    if (!mounted) return;

    // 4. Navigate away
    _webViewController.loadRequest(Uri.parse('about:blank'));
    await Future.delayed(const Duration(milliseconds: 100));

    // 5. Server-side verification
    bool verified = false;
    if (event.isSuccess && _sessionId != null) {
      verified = await _verifyPayment(_sessionId!);
    }

    if (!mounted) return;

    // 6. Show result
    final payloadData = event.data?['data'];

    setState(() {
      _isCheckoutVisible = false;
      _isFinished = true;
      _paymentSuccess = event.isSuccess && (event.isSuccess ? verified : true);
      _resultData = payloadData is Map
          ? Map<String, dynamic>.from(payloadData)
          : null;
    });
  }

  Future<bool> _verifyPayment(String sessionId) async {
    try {
      final session = await _checkoutsApi.retrieveCheckoutSession(sessionId);
      return session?.status == 'complete';
    } catch (e) {
      debugPrint('[Checkout] Verification error: $e');
      return false;
    }
  }

  // -------------------------------------------------------------------------
  // Reset for new checkout
  // -------------------------------------------------------------------------

  void _resetCheckout() {
    setState(() {
      _sessionId = null;
      _isCheckoutVisible = false;
      _isFinished = false;
      _hasHandledTerminalEvent = false;
      _paymentSuccess = null;
      _resultData = null;
      _events.clear();
      _errorMessage = null;
    });
  }

  // -------------------------------------------------------------------------
  // UI
  // -------------------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Cashful Checkout'),
        actions: [
          if (_isFinished)
            IconButton(
              icon: const Icon(Icons.refresh),
              onPressed: _resetCheckout,
              tooltip: 'New Checkout',
            ),
        ],
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    if (_isCheckoutVisible) {
      return WebViewWidget(controller: _webViewController);
    }
    if (_isFinished) {
      return _buildResultView();
    }
    return _buildStartView();
  }

  // -- Start view --

  Widget _buildStartView() {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Icon(Icons.shopping_cart_outlined, size: 64,
                color: Colors.grey),
            const SizedBox(height: 16),
            const Text(
              'Cashful Checkout Demo',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 8),
            Text(
              'Creates a checkout session via the SDK and opens it in a WebView.',
              textAlign: TextAlign.center,
              style: TextStyle(color: Colors.grey[600]),
            ),
            const SizedBox(height: 24),
            const Text('Order Summary',
                style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
            const SizedBox(height: 8),
            _buildOrderRow('Premium Widget x2', 'R50.00'),
            _buildOrderRow('Shipping x1', 'R5.00'),
            const Divider(),
            _buildOrderRow('Total', 'R55.00',
                bold: true),
            const SizedBox(height: 24),
            if (_errorMessage != null) ...[
              Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.red[50],
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(_errorMessage!,
                    style: TextStyle(color: Colors.red[700])),
              ),
              const SizedBox(height: 16),
            ],
            SizedBox(
              width: double.infinity,
              child: FilledButton.icon(
                onPressed: _isLoading ? null : _startCheckout,
                icon: _isLoading
                    ? const SizedBox(
                        width: 16,
                        height: 16,
                        child:
                            CircularProgressIndicator(strokeWidth: 2,
                                color: Colors.white))
                    : const Icon(Icons.payment),
                label: Text(_isLoading ? 'Creating session...' : 'Pay R55.00'),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildOrderRow(String label, String amount, {bool bold = false}) {
    final style = bold
        ? const TextStyle(fontWeight: FontWeight.bold)
        : const TextStyle();
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [Text(label, style: style), Text(amount, style: style)],
      ),
    );
  }

  // -- Result view --

  Widget _buildResultView() {
    final success = _paymentSuccess ?? false;

    return ListView(
      padding: const EdgeInsets.all(24),
      children: [
        const SizedBox(height: 32),
        Icon(
          success ? Icons.check_circle_outline : Icons.error_outline,
          size: 80,
          color: success ? Colors.green : Colors.red,
        ),
        const SizedBox(height: 16),
        Text(
          success ? 'Payment Successful' : 'Payment Failed',
          textAlign: TextAlign.center,
          style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
        ),
        if (!success && _resultData?['reason'] != null) ...[
          const SizedBox(height: 8),
          Text(
            _resultData!['reason'].toString(),
            textAlign: TextAlign.center,
            style: TextStyle(color: Colors.grey[600]),
          ),
        ],
        const SizedBox(height: 24),

        // Transaction details
        if (_resultData != null)
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Transaction Details',
                      style:
                          TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
                  const SizedBox(height: 12),
                  if (_resultData!['transactionId'] != null)
                    _buildDetailRow(
                        'Transaction ID', _resultData!['transactionId']),
                  if (_resultData!['amount'] != null)
                    _buildDetailRow('Amount',
                        'R${(_resultData!['amount'] / 100).toStringAsFixed(2)}'),
                  if (_resultData!['paymentMethod'] != null)
                    _buildDetailRow(
                        'Method', _resultData!['paymentMethod']),
                  if (_resultData!['reason'] != null)
                    _buildDetailRow(
                        'Reason', _resultData!['reason']),
                ],
              ),
            ),
          ),

        // Order items (present on payment.completed)
        if (success && _resultData?['orderItems'] != null)
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Order Items',
                      style:
                          TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
                  const SizedBox(height: 12),
                  ...(_resultData!['orderItems'] as List).map((item) {
                    final name = item['name'] ?? '';
                    final qty = item['quantity'] ?? 1;
                    final price = item['price'] ?? 0;
                    return Padding(
                      padding: const EdgeInsets.symmetric(vertical: 4),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          Text('${qty}x  $name'),
                          Text('R${(price * qty / 100).toStringAsFixed(2)}'),
                        ],
                      ),
                    );
                  }),
                ],
              ),
            ),
          ),

        // Contact info (present on payment.completed)
        if (success && _resultData?['contactInfo'] != null)
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Contact Info',
                      style:
                          TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
                  const SizedBox(height: 12),
                  if ((_resultData!['contactInfo'] as Map)['email'] != null)
                    _buildDetailRow('Email',
                        _resultData!['contactInfo']['email']),
                  if ((_resultData!['contactInfo'] as Map)['phone'] != null)
                    _buildDetailRow('Phone',
                        _resultData!['contactInfo']['phone']),
                ],
              ),
            ),
          ),

        const SizedBox(height: 16),

        // Event history
        if (_events.isNotEmpty)
          ExpansionTile(
            title: Text('Event History (${_events.length})'),
            children: _events.map((e) {
              return ListTile(
                dense: true,
                leading: Icon(
                  e.isTerminal ? Icons.flag : Icons.arrow_forward,
                  size: 16,
                  color: e.isTerminal
                      ? (e.isSuccess ? Colors.green : Colors.red)
                      : Colors.blue,
                ),
                title: Text(e.message,
                    style: const TextStyle(fontSize: 13,
                        fontFamily: 'monospace')),
                subtitle: Text(
                  '${e.timestamp.hour.toString().padLeft(2, '0')}:'
                  '${e.timestamp.minute.toString().padLeft(2, '0')}:'
                  '${e.timestamp.second.toString().padLeft(2, '0')}',
                  style: const TextStyle(fontSize: 11),
                ),
              );
            }).toList(),
          ),

        const SizedBox(height: 24),
        OutlinedButton.icon(
          onPressed: _resetCheckout,
          icon: const Icon(Icons.refresh),
          label: const Text('New Checkout'),
        ),
      ],
    );
  }

  Widget _buildDetailRow(String label, dynamic value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 120,
            child: Text(label,
                style: TextStyle(
                    color: Colors.grey[600], fontWeight: FontWeight.w500)),
          ),
          Expanded(
            child: Text(value.toString(),
                style: const TextStyle(fontFamily: 'monospace')),
          ),
        ],
      ),
    );
  }
}

Running the Example

  1. Replace YOUR_API_KEY and YOUR_MERCHANT_ID in CashfulCheckoutConfig
  2. Run with: flutter run
  3. Tap “Pay R55.00” to create a session and open the checkout
  4. In sandbox mode, select a test card from the dropdown and complete payment
  5. The app transitions to the result screen with transaction details

9. Security Considerations

API Key Exposure

The example above stores the API key directly in the app for simplicity. Do not do this in production. An API key in a mobile app binary can be extracted via reverse engineering.Production architecture:
Flutter App                  Your Backend               Cashful API
    |                            |                          |
    |-- POST /create-order ----->|                          |
    |   { items, amount }        |-- createCheckoutSession ->|
    |                            |<-- sessionUrl, id --------|
    |<-- { sessionUrl, id } -----|                          |
    |                                                       |
    |-- WebView(sessionUrl) ---------------------------------|
    |                                                       |
    |<-- payment.completed (JS bridge) ---------------------|
    |                                                       |
    |-- POST /verify-order ----->|                          |
    |   { sessionId }            |-- retrieveCheckoutSession ->|
    |                            |<-- status: complete --------|
    |<-- { verified: true } -----|                          |
Your backend:
  • Holds the API key securely
  • Creates checkout sessions on behalf of the app
  • Verifies payment status before fulfilling orders
  • Processes webhook events for async payment confirmations

What Stays Client-Side

These are safe to keep in the Flutter app:
  • WebView rendering of sessionUrl (the URL itself is not secret — it’s a one-time-use session)
  • JavaScript bridge and event handling
  • Result display UI

What Must Be Server-Side in Production

  • API key storage
  • Checkout session creation (createCheckoutSession)
  • Payment verification (retrieveCheckoutSession)
  • Order fulfillment logic