> ## Documentation Index
> Fetch the complete documentation index at: https://dev.moonpay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Flutter

> Manual frame integration for Flutter using webview_flutter.

Use `webview_flutter` to embed frames in Flutter applications. The WebView communicates with your Dart code through JavaScript channels.

<Callout icon="book" iconType="regular">
  Read the [manual integration
  overview](/platform/guides/manual-integration/overview) for core concepts
  before you continue.
</Callout>

## Setup

### Dependencies

The examples below use [cryptography](https://pub.dev/packages/cryptography) for X25519 key exchange, but you can use any library that supports X25519 and AES-GCM. Add the required packages to your `pubspec.yaml`:

```yaml theme={null}
dependencies:
  webview_flutter: ^4.13.0
  cryptography: ^2.7.0
  convert: ^3.1.1
```

For platform-specific setup, add the implementation packages:

```yaml theme={null}
dependencies:
  webview_flutter_android: ^4.3.0 # Android
  webview_flutter_wkwebview: ^3.18.0 # iOS/macOS
```

Run `flutter pub get` to install the packages.

### Platform configuration

<Tabs>
  <Tab title="iOS">
    Add the following to your `ios/Runner/Info.plist`:

    ```xml theme={null}
    <key>io.flutter.embedded_views_preview</key>
    <true/>
    ```
  </Tab>

  <Tab title="Android">
    Set the minimum SDK version in `android/app/build.gradle`:

    ```groovy theme={null}
    android {
        defaultConfig {
            minSdkVersion 21
        }
    }
    ```
  </Tab>
</Tabs>

### Key generation

Create a utility class for X25519 key generation and decryption:

```dart theme={null}
import 'dart:convert';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:convert/convert.dart';

class MoonPayCrypto {
  final SimpleKeyPair _keyPair;
  final String publicKeyHex;

  MoonPayCrypto._(this._keyPair, this.publicKeyHex);

  /// Generate a new X25519 keypair for frame communication
  static Future<MoonPayCrypto> generate() async {
    final algorithm = X25519();
    final keyPair = await algorithm.newKeyPair();
    final publicKey = await keyPair.extractPublicKey();
    final publicKeyHex = hex.encode(publicKey.bytes);

    return MoonPayCrypto._(keyPair, publicKeyHex);
  }

  /// Decrypt an encrypted payload from the frame
  Future<String> decryptPayload(String encryptedValue) async {
    // The frame returns the encrypted payload as a base64-encoded JSON
    // string. Decode the base64 first, then parse the JSON.
    final decodedJson = utf8.decode(base64Decode(encryptedValue));
    final encrypted = jsonDecode(decodedJson) as Map<String, dynamic>;

    final ephemeralPublicKeyBytes = hex.decode(encrypted['ephemeralPublicKey'] as String);
    final iv = hex.decode(encrypted['iv'] as String);
    final ciphertext = hex.decode(encrypted['ciphertext'] as String);

    // Create shared secret using X25519
    final algorithm = X25519();
    final ephemeralPublicKey = SimplePublicKey(
      Uint8List.fromList(ephemeralPublicKeyBytes),
      type: KeyPairType.x25519,
    );

    final sharedSecret = await algorithm.sharedSecretKey(
      keyPair: _keyPair,
      remotePublicKey: ephemeralPublicKey,
    );

    // Derive AES key using HKDF
    final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
    final derivedKey = await hkdf.deriveKey(
      secretKey: sharedSecret,
      info: Uint8List(0),
      nonce: Uint8List(0),
    );

    // Decrypt using AES-GCM
    final aesGcm = AesGcm.with256bits();
    final secretBox = SecretBox(
      ciphertext,
      nonce: iv,
      mac: Mac.empty, // MAC is appended to ciphertext
    );

    final decrypted = await aesGcm.decrypt(
      secretBox,
      secretKey: derivedKey,
    );

    return utf8.decode(decrypted);
  }
}

/// Generate a unique channel ID for frame communication
String generateChannelId() {
  final timestamp = DateTime.now().millisecondsSinceEpoch;
  final random = (DateTime.now().microsecond * 1000).toRadixString(36);
  return 'ch_${timestamp}_$random';
}
```

### Base WebView widget

Create a reusable widget for frame communication:

```dart theme={null}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

const String frameOrigin = 'https://blocks.moonpay.com';

class FrameMessage {
  final int version;
  final String channelId;
  final String kind;
  final Map<String, dynamic>? payload;

  FrameMessage({
    required this.version,
    required this.channelId,
    required this.kind,
    this.payload,
  });

  factory FrameMessage.fromJson(Map<String, dynamic> json) {
    return FrameMessage(
      version: json['version'] as int,
      channelId: (json['meta'] as Map<String, dynamic>)['channelId'] as String,
      kind: json['kind'] as String,
      payload: json['payload'] as Map<String, dynamic>?,
    );
  }

  Map<String, dynamic> toJson() => {
    'version': version,
    'meta': {'channelId': channelId},
    'kind': kind,
    if (payload != null) 'payload': payload,
  };
}

class MoonPayWebView extends StatefulWidget {
  final String url;
  final String channelId;
  final void Function(FrameMessage) onMessage;
  final VoidCallback onHandshake;
  final double? height;

  const MoonPayWebView({
    super.key,
    required this.url,
    required this.channelId,
    required this.onMessage,
    required this.onHandshake,
    this.height,
  });

  @override
  State<MoonPayWebView> createState() => _MoonPayWebViewState();
}

class _MoonPayWebViewState extends State<MoonPayWebView> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel(
        'MoonPayBridge',
        onMessageReceived: _handleMessage,
      )
      ..setNavigationDelegate(
        NavigationDelegate(
          onPageFinished: (_) => _injectMessageBridge(),
        ),
      )
      ..loadRequest(Uri.parse(widget.url));
  }

  void _injectMessageBridge() {
    _controller.runJavaScript('''
      (function() {
        window.addEventListener('message', function(e) {
          if (MoonPayBridge && MoonPayBridge.postMessage) {
            MoonPayBridge.postMessage(typeof e.data === 'string' ? e.data : JSON.stringify(e.data));
          }
        });
      })();
    ''');
  }

  void _handleMessage(JavaScriptMessage jsMessage) {
    try {
      final data = jsonDecode(jsMessage.message) as Map<String, dynamic>;
      final meta = data['meta'] as Map<String, dynamic>?;

      if (meta?['channelId'] != widget.channelId) return;

      final message = FrameMessage.fromJson(data);

      if (message.kind == 'handshake') {
        sendMessage('ack');
        widget.onHandshake();
      }

      widget.onMessage(message);
    } catch (_) {
      // Ignore malformed messages
    }
  }

  void sendMessage(String kind, [Map<String, dynamic>? payload]) {
    final message = FrameMessage(
      version: 2,
      channelId: widget.channelId,
      kind: kind,
      payload: payload,
    );

    final jsonString = jsonEncode(message.toJson());
    // Re-encode the JSON as a string literal. The frame's bridge listens
    // for `MessageEvent`s whose `data` is a string and ignores anything
    // else, so we must post a string — not an object literal.
    final stringLiteral = jsonEncode(jsonString);
    _controller.runJavaScript('''
      window.postMessage($stringLiteral, '*');
    ''');
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: widget.height ?? double.infinity,
      child: WebViewWidget(controller: _controller),
    );
  }
}
```

### Handle JavaScript dialogs

In [test mode](/overview/test-mode#apple-pay), the Apple Pay frame renders a mock button and uses `window.confirm` to simulate the Apple Pay payment sheet (`OK` = success, `Cancel` = failed).

`webview_flutter` does not surface `window.confirm` (or `alert` / `prompt`) by default — the underlying `WKWebView` on iOS and `WebView` on Android both require explicit dialog handlers. Without one, the call returns `false` with no UI shown, the frame interprets that as the customer cancelling, and every test transaction comes back with `status: "failed"`.

Wire dialog handling via the platform-specific implementations. On iOS use `WebKitWebViewController`'s UI delegate hooks; on Android use `AndroidWebViewController` to attach a `WebChromeClient` whose `onJsConfirm` returns the customer's choice. Surface the prompt with a Flutter `AlertDialog` so it matches the rest of your UI, and call back into the controller with the result.

<Callout icon="info" iconType="regular">
  Wire dialog handling even if you only plan to ship live mode. It applies to
  any `window.confirm`, `alert`, or `prompt` the frame might surface, and makes
  test-mode debugging impossible without it.
</Callout>

***

## Check frame

The check frame verifies whether a customer already has an active connection. It's headless — no UI is rendered. Use it to skip the connect flow for returning customers. See [check frame reference](/platform/frames/check) for event details.

### Check widget

```dart theme={null}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';

class MoonPayCheckFrame extends StatefulWidget {
  final String sessionToken;
  final void Function(ConnectCredentials) onActive;
  final void Function(ConnectCredentials) onConnectionRequired;
  final void Function(ConnectError) onError;
  final VoidCallback? onPending;
  final VoidCallback? onUnavailable;

  const MoonPayCheckFrame({
    super.key,
    required this.sessionToken,
    required this.onActive,
    required this.onConnectionRequired,
    required this.onError,
    this.onPending,
    this.onUnavailable,
  });

  @override
  State<MoonPayCheckFrame> createState() => _MoonPayCheckFrameState();
}

class _MoonPayCheckFrameState extends State<MoonPayCheckFrame> {
  late final String _channelId;
  late final Future<MoonPayCrypto> _cryptoFuture;

  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
    _cryptoFuture = MoonPayCrypto.generate();
  }

  String _buildFrameUrl(String publicKeyHex) {
    final params = {
      'sessionToken': widget.sessionToken,
      'publicKey': publicKeyHex,
      'channelId': _channelId,
    };

    return '$frameOrigin/platform/v1/check-connection?${Uri(queryParameters: params).query}';
  }

  Future<void> _handleMessage(FrameMessage message, MoonPayCrypto crypto) async {
    switch (message.kind) {
      case 'complete':
        final payload = message.payload!;
        final status = payload['status'] as String;

        switch (status) {
          case 'active':
            final decryptedPayload = await crypto.decryptPayload(payload['credentials'] as String);
            final credentials = jsonDecode(decryptedPayload) as Map<String, dynamic>;
            final customer = payload['customer'] as Map<String, dynamic>?;
            final country = customer?['country'] as String?;
            final administrativeArea = customer?['administrativeArea'] as String?;
            final area = customer?['area'] as String?;
            // payload['capabilities']['ramps']['requirements']['paymentDisclosures'] is deprecated.
            widget.onActive(ConnectCredentials(
              accessToken: credentials['accessToken'] as String,
              clientToken: credentials['clientToken'] as String,
            ));
            break;
          case 'connectionRequired':
            final decryptedPayload = await crypto.decryptPayload(payload['credentials'] as String);
            final anonymousCredentials = jsonDecode(decryptedPayload) as Map<String, dynamic>;
            // payload['mismatch'] is true when the session's email and phone resolve to different customers — route through the connect flow first.
            widget.onConnectionRequired(ConnectCredentials(
              accessToken: anonymousCredentials['accessToken'] as String,
              clientToken: anonymousCredentials['clientToken'] as String,
            ));
            break;
          case 'pending':
            widget.onPending?.call();
            break;
          case 'unavailable':
            widget.onUnavailable?.call();
            break;
          case 'failed':
            widget.onError(ConnectError(
              code: 'failed',
              message: payload['reason'] as String? ?? 'Check failed',
            ));
            break;
        }
        break;

      case 'error':
        final payload = message.payload!;
        widget.onError(ConnectError(
          code: payload['code'] as String,
          message: payload['message'] as String,
        ));
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<MoonPayCrypto>(
      future: _cryptoFuture,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }

        final crypto = snapshot.data!;

        return MoonPayWebView(
          url: _buildFrameUrl(crypto.publicKeyHex),
          channelId: _channelId,
          onMessage: (msg) => _handleMessage(msg, crypto),
          onHandshake: () {},
        );
      },
    );
  }
}
```

### Usage

```dart theme={null}
class SplashScreen extends StatelessWidget {
  const SplashScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MoonPayCheckFrame(
        sessionToken: 'your-session-token',
        onActive: (credentials) {
          // Customer is already connected — skip to payment
          debugPrint('Already connected! Access token: ${credentials.accessToken}');
          Navigator.of(context).pushReplacementNamed('/payment');
        },
        onConnectionRequired: (credentials) {
          // Store both tokens in memory, then pass the clientToken to the connect frame
          CredentialsStore.instance.set(
            accessToken: credentials.accessToken,
            clientToken: credentials.clientToken,
          );
          Navigator.of(context).pushReplacementNamed(
            '/connect',
            arguments: credentials.clientToken,
          );
        },
        onError: (error) {
          debugPrint('Check failed: ${error.message}');
        },
      ),
    );
  }
}
```

***

## Connect frame

The connect frame establishes a customer connection to your application. See [connect frame reference](/platform/frames/connect) for event details.

### Connect widget

```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';

class ConnectCredentials {
  final String accessToken;
  final String clientToken;

  ConnectCredentials({required this.accessToken, required this.clientToken});
}

class ConnectError {
  final String code;
  final String message;

  ConnectError({required this.code, required this.message});
}

class MoonPayConnectFrame extends StatefulWidget {
  final String clientToken;
  final void Function(ConnectCredentials) onComplete;
  final void Function(ConnectError) onError;
  final VoidCallback? onPending;
  final VoidCallback? onUnavailable;

  const MoonPayConnectFrame({
    super.key,
    required this.clientToken,
    required this.onComplete,
    required this.onError,
    this.onPending,
    this.onUnavailable,
  });

  @override
  State<MoonPayConnectFrame> createState() => _MoonPayConnectFrameState();
}

class _MoonPayConnectFrameState extends State<MoonPayConnectFrame> {
  late final String _channelId;
  late final Future<MoonPayCrypto> _cryptoFuture;
  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
    _cryptoFuture = MoonPayCrypto.generate();
  }

  String _buildFrameUrl(String publicKeyHex) {
    final params = {
      'clientToken': widget.clientToken,
      'publicKey': publicKeyHex,
      'channelId': _channelId,
    };

    return '$frameOrigin/platform/v1/connect?${Uri(queryParameters: params).query}';
  }

  Future<void> _handleMessage(FrameMessage message, MoonPayCrypto crypto) async {
    switch (message.kind) {
      case 'complete':
        final payload = message.payload!;
        final status = payload['status'] as String;

        switch (status) {
          case 'active':
            final decryptedPayload = await crypto.decryptPayload(payload['credentials'] as String);
            final credentials = jsonDecode(decryptedPayload) as Map<String, dynamic>;
            final customer = payload['customer'] as Map<String, dynamic>?;
            final country = customer?['country'] as String?;
            final administrativeArea = customer?['administrativeArea'] as String?;
            final area = customer?['area'] as String?;
            // payload['capabilities']['ramps']['requirements']['paymentDisclosures'] is deprecated.
            widget.onComplete(ConnectCredentials(
              accessToken: credentials['accessToken'] as String,
              clientToken: credentials['clientToken'] as String,
            ));
            break;
          case 'pending':
            widget.onPending?.call();
            break;
          case 'unavailable':
            widget.onUnavailable?.call();
            break;
          case 'failed':
            widget.onError(ConnectError(
              code: 'failed',
              message: payload['reason'] as String? ?? 'Connection failed',
            ));
            break;
        }
        break;

      case 'error':
        final payload = message.payload!;
        widget.onError(ConnectError(
          code: payload['code'] as String,
          message: payload['message'] as String,
        ));
        break;
    }
  }

  void _handleHandshake() {
    // Handshake complete
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<MoonPayCrypto>(
      future: _cryptoFuture,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }

        final crypto = snapshot.data!;

        return MoonPayWebView(
          url: _buildFrameUrl(crypto.publicKeyHex),
          channelId: _channelId,
          onMessage: (msg) => _handleMessage(msg, crypto),
          onHandshake: _handleHandshake,
        );
      },
    );
  }
}
```

### Usage

```dart theme={null}
class ConnectScreen extends StatelessWidget {
  const ConnectScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // The clientToken is the anonymous token passed from the check frame's
    // `connectionRequired` response.
    final clientToken = ModalRoute.of(context)!.settings.arguments as String;

    return Scaffold(
      appBar: AppBar(title: const Text('Connect')),
      body: MoonPayConnectFrame(
        clientToken: clientToken,
        onComplete: (credentials) {
          // Replace the anonymous credentials with the authenticated ones and
          // store them in memory (e.g., Provider or Riverpod).
          debugPrint('Connected! Access token: ${credentials.accessToken}');
          Navigator.of(context).pushReplacementNamed('/home');
        },
        onError: (error) {
          debugPrint('Connection failed: ${error.message}');
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Error: ${error.message}')),
          );
        },
        onPending: () => debugPrint('Connection pending'),
        onUnavailable: () => debugPrint('Connection unavailable'),
      ),
    );
  }
}
```

***

## Apple Pay frame

The Apple Pay frame renders the Apple Pay button and handles the payment flow. See [Apple Pay frame reference](/platform/frames/apple-pay) for event details.

<Callout icon="apple" iconType="brands">
  Apple Pay only works on iOS devices with Apple Pay configured.
</Callout>

### Apple Pay widget

```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';

class TransactionResult {
  final String id;
  final String status;

  TransactionResult({required this.id, required this.status});
}

class MoonPayApplePayFrame extends StatefulWidget {
  final String clientToken;
  final String quoteSignature;
  final void Function(TransactionResult) onComplete;
  final void Function(String url) onChallenge;
  final void Function(ConnectError) onError;
  final VoidCallback onQuoteExpired;
  final VoidCallback? onReady;

  const MoonPayApplePayFrame({
    super.key,
    required this.clientToken,
    required this.quoteSignature,
    required this.onComplete,
    required this.onChallenge,
    required this.onError,
    required this.onQuoteExpired,
    this.onReady,
  });

  @override
  State<MoonPayApplePayFrame> createState() => _MoonPayApplePayFrameState();
}

class _MoonPayApplePayFrameState extends State<MoonPayApplePayFrame> {
  late final String _channelId;
  late String _currentQuote;
  final GlobalKey<_MoonPayWebViewState> _webViewKey = GlobalKey();

  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
    _currentQuote = widget.quoteSignature;
  }

  @override
  void didUpdateWidget(MoonPayApplePayFrame oldWidget) {
    super.didUpdateWidget(oldWidget);

    // Send updated quote to frame
    if (widget.quoteSignature != _currentQuote) {
      _currentQuote = widget.quoteSignature;
      _webViewKey.currentState?.sendMessage('setQuote', {
        'quote': {'signature': _currentQuote},
      });
    }
  }

  String _buildFrameUrl() {
    final params = {
      'clientToken': widget.clientToken,
      'signature': widget.quoteSignature,
      'channelId': _channelId,
    };

    return '$frameOrigin/platform/v1/apple-pay?${Uri(queryParameters: params).query}';
  }

  void _handleMessage(FrameMessage message) {
    switch (message.kind) {
      case 'ready':
        widget.onReady?.call();
        break;

      case 'complete':
        final payload = message.payload!;
        final transaction = payload['transaction'] as Map<String, dynamic>;

        if (transaction['status'] == 'failed') {
          widget.onError(ConnectError(
            code: 'transactionFailed',
            message: transaction['failureReason'] as String,
          ));
        } else {
          widget.onComplete(TransactionResult(
            id: transaction['id'] as String,
            status: transaction['status'] as String,
          ));
        }
        break;

      case 'challenge':
        final payload = message.payload!;
        final url = payload['url'] as String;
        widget.onChallenge(url);
        break;

      case 'error':
        final payload = message.payload!;
        final code = payload['code'] as String;

        if (code == 'quoteExpired') {
          widget.onQuoteExpired();
        } else {
          widget.onError(ConnectError(
            code: code,
            message: payload['message'] as String,
          ));
        }
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return MoonPayWebView(
      key: _webViewKey,
      url: _buildFrameUrl(),
      channelId: _channelId,
      onMessage: _handleMessage,
      onHandshake: () {}, // Handled in _handleMessage
      height: 56, // Apple Pay button height
    );
  }
}
```

### Usage

```dart theme={null}
class PaymentScreen extends StatefulWidget {
  final String clientToken;
  final String initialQuoteSignature;

  const PaymentScreen({
    super.key,
    required this.clientToken,
    required this.initialQuoteSignature,
  });

  @override
  State<PaymentScreen> createState() => _PaymentScreenState();
}

class _PaymentScreenState extends State<PaymentScreen> {
  late String _quoteSignature;

  @override
  void initState() {
    super.initState();
    _quoteSignature = widget.initialQuoteSignature;
  }

  Future<void> _fetchNewQuote() async {
    // Fetch a new quote from your API
    final newQuote = await yourApi.getQuote();
    setState(() => _quoteSignature = newQuote.signature);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Pay with Apple Pay')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // Your payment summary UI
            const Spacer(),
            MoonPayApplePayFrame(
              clientToken: widget.clientToken,
              quoteSignature: _quoteSignature,
              onComplete: (transaction) {
                debugPrint('Transaction initiated: ${transaction.id}');
                Navigator.of(context).pushNamed('/transaction-status');
              },
              onChallenge: (url) => debugPrint('Challenge required: $url'),
              onError: (error) {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('Payment error: ${error.message}')),
                );
              },
              onQuoteExpired: _fetchNewQuote,
              onReady: () => debugPrint('Apple Pay button ready'),
            ),
          ],
        ),
      ),
    );
  }
}
```

***

## Google Pay frame

The Google Pay frame renders the Google Pay button and handles the payment flow. See [Google Pay frame reference](/platform/frames/google-pay) for event details.

### Google Pay widget

```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';

class MoonPayGooglePayFrame extends StatefulWidget {
  final String clientToken;
  final String quoteSignature;
  final void Function(TransactionResult) onComplete;
  final void Function(String url) onChallenge;
  final void Function(ConnectError) onError;
  final VoidCallback onQuoteExpired;
  final VoidCallback? onReady;

  const MoonPayGooglePayFrame({
    super.key,
    required this.clientToken,
    required this.quoteSignature,
    required this.onComplete,
    required this.onChallenge,
    required this.onError,
    required this.onQuoteExpired,
    this.onReady,
  });

  @override
  State<MoonPayGooglePayFrame> createState() => _MoonPayGooglePayFrameState();
}

class _MoonPayGooglePayFrameState extends State<MoonPayGooglePayFrame> {
  late final String _channelId;
  late String _currentQuote;
  final GlobalKey<_MoonPayWebViewState> _webViewKey = GlobalKey();

  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
    _currentQuote = widget.quoteSignature;
  }

  @override
  void didUpdateWidget(MoonPayGooglePayFrame oldWidget) {
    super.didUpdateWidget(oldWidget);

    // Send updated quote to frame
    if (widget.quoteSignature != _currentQuote) {
      _currentQuote = widget.quoteSignature;
      _webViewKey.currentState?.sendMessage('setQuote', {
        'quote': {'signature': _currentQuote},
      });
    }
  }

  String _buildFrameUrl() {
    final params = {
      'clientToken': widget.clientToken,
      'signature': widget.quoteSignature,
      'channelId': _channelId,
    };

    return '$frameOrigin/platform/v1/google-pay?${Uri(queryParameters: params).query}';
  }

  void _handleMessage(FrameMessage message) {
    switch (message.kind) {
      case 'ready':
        widget.onReady?.call();
        break;

      case 'complete':
        final payload = message.payload!;
        final transaction = payload['transaction'] as Map<String, dynamic>;

        if (transaction['status'] == 'failed') {
          widget.onError(ConnectError(
            code: 'transactionFailed',
            message: transaction['failureReason'] as String,
          ));
        } else {
          widget.onComplete(TransactionResult(
            id: transaction['id'] as String,
            status: transaction['status'] as String,
          ));
        }
        break;

      case 'challenge':
        final payload = message.payload!;
        final url = payload['url'] as String;
        widget.onChallenge(url);
        break;

      case 'error':
        final payload = message.payload!;
        final code = payload['code'] as String;

        if (code == 'quoteExpired') {
          widget.onQuoteExpired();
        } else {
          widget.onError(ConnectError(
            code: code,
            message: payload['message'] as String,
          ));
        }
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return MoonPayWebView(
      key: _webViewKey,
      url: _buildFrameUrl(),
      channelId: _channelId,
      onMessage: _handleMessage,
      onHandshake: () {}, // Handled in _handleMessage
      height: 56, // Google Pay button height
    );
  }
}
```

### Usage

```dart theme={null}
class GooglePayScreen extends StatefulWidget {
  final String clientToken;
  final String initialQuoteSignature;

  const GooglePayScreen({
    super.key,
    required this.clientToken,
    required this.initialQuoteSignature,
  });

  @override
  State<GooglePayScreen> createState() => _GooglePayScreenState();
}

class _GooglePayScreenState extends State<GooglePayScreen> {
  late String _quoteSignature;

  @override
  void initState() {
    super.initState();
    _quoteSignature = widget.initialQuoteSignature;
  }

  Future<void> _fetchNewQuote() async {
    // Fetch a new quote from your API
    final newQuote = await yourApi.getQuote();
    setState(() => _quoteSignature = newQuote.signature);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Pay with Google Pay')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // Your payment summary UI
            const Spacer(),
            MoonPayGooglePayFrame(
              clientToken: widget.clientToken,
              quoteSignature: _quoteSignature,
              onComplete: (transaction) {
                debugPrint('Transaction initiated: ${transaction.id}');
                Navigator.of(context).pushNamed('/transaction-status');
              },
              onChallenge: (url) => debugPrint('Challenge required: $url'),
              onError: (error) {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('Payment error: ${error.message}')),
                );
              },
              onQuoteExpired: _fetchNewQuote,
              onReady: () => debugPrint('Google Pay button ready'),
            ),
          ],
        ),
      ),
    );
  }
}
```

***

## Add Card frame

The add card frame lets a customer save a new card to their account. See [add card frame reference](/platform/frames/add-card) for event details.

### What you'll need

Before you initialize the add card frame, you need:

1. A `clientToken` from a successful [connect flow](#connect-frame)

### Add Card widget

```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';

class CardResult {
  final String id;
  final String brand;
  final String last4;
  final String cardType;
  final int expirationMonth;
  final int expirationYear;
  final bool active;

  CardResult({
    required this.id,
    required this.brand,
    required this.last4,
    required this.cardType,
    required this.expirationMonth,
    required this.expirationYear,
    required this.active,
  });
}

class MoonPayAddCardFrame extends StatefulWidget {
  final String clientToken;
  final void Function(CardResult) onComplete;
  final void Function(ConnectError) onError;
  final VoidCallback? onReady;

  const MoonPayAddCardFrame({
    super.key,
    required this.clientToken,
    required this.onComplete,
    required this.onError,
    this.onReady,
  });

  @override
  State<MoonPayAddCardFrame> createState() => _MoonPayAddCardFrameState();
}

class _MoonPayAddCardFrameState extends State<MoonPayAddCardFrame> {
  late final String _channelId;

  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
  }

  String _buildFrameUrl() {
    final params = {
      'clientToken': widget.clientToken,
      'channelId': _channelId,
    };

    return '$frameOrigin/platform/v1/add-card?${Uri(queryParameters: params).query}';
  }

  void _handleMessage(FrameMessage message) {
    switch (message.kind) {
      case 'ready':
        widget.onReady?.call();
        break;

      case 'complete':
        final payload = message.payload!;
        final card = payload['card'] as Map<String, dynamic>;
        final availability = card['availability'] as Map<String, dynamic>;
        widget.onComplete(CardResult(
          id: card['id'] as String,
          brand: card['brand'] as String,
          last4: card['last4'] as String,
          cardType: card['cardType'] as String,
          expirationMonth: card['expirationMonth'] as int,
          expirationYear: card['expirationYear'] as int,
          active: availability['active'] as bool,
        ));
        break;

      case 'error':
        final payload = message.payload!;
        widget.onError(ConnectError(
          code: payload['code'] as String,
          message: payload['message'] as String,
        ));
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return MoonPayWebView(
      url: _buildFrameUrl(),
      channelId: _channelId,
      onMessage: _handleMessage,
      onHandshake: () {},
    );
  }
}
```

### Usage

```dart theme={null}
class SaveCardScreen extends StatelessWidget {
  final String clientToken;

  const SaveCardScreen({super.key, required this.clientToken});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Add Card')),
      body: MoonPayAddCardFrame(
        clientToken: clientToken,
        onComplete: (card) {
          debugPrint('Card saved: ${card.brand} •••• ${card.last4}');
          Navigator.of(context).pop();
        },
        onError: (error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Error: ${error.message}')),
          );
        },
        onReady: () => debugPrint('Add card frame ready'),
      ),
    );
  }
}
```

***

## Buy frame

The buy frame processes a card payment for a quote. It is headless — rendered at zero size — while the customer completes payment. If 3-D Secure is required, the frame emits a `challenge` event with a URL you open in a separate challenge frame. See [buy frame reference](/platform/frames/buy) for event details.

### What you'll need

Before you initialize the buy frame, you need:

1. A `clientToken` from a successful [connect flow](#connect-frame)
2. A valid [quote signature](/api-reference/platform/endpoints/quotes/get) for the transaction

### Buy widget

```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';

class MoonPayBuyFrame extends StatefulWidget {
  final String clientToken;
  final String quoteSignature;
  final void Function(TransactionResult) onComplete;
  final void Function(String url) onChallenge;
  final void Function(ConnectError) onError;
  final VoidCallback? onQuoteExpired;

  const MoonPayBuyFrame({
    super.key,
    required this.clientToken,
    required this.quoteSignature,
    required this.onComplete,
    required this.onChallenge,
    required this.onError,
    this.onQuoteExpired,
  });

  @override
  State<MoonPayBuyFrame> createState() => _MoonPayBuyFrameState();
}

class _MoonPayBuyFrameState extends State<MoonPayBuyFrame> {
  late final String _channelId;
  late String _currentQuote;
  final GlobalKey<_MoonPayWebViewState> _webViewKey = GlobalKey();

  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
    _currentQuote = widget.quoteSignature;
  }

  @override
  void didUpdateWidget(MoonPayBuyFrame oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.quoteSignature != _currentQuote) {
      _currentQuote = widget.quoteSignature;
      _webViewKey.currentState?.sendMessage('setQuote', {
        'quote': {'signature': _currentQuote},
      });
    }
  }

  String _buildFrameUrl() {
    final params = {
      'clientToken': widget.clientToken,
      'channelId': _channelId,
      'signature': widget.quoteSignature,
    };

    return '$frameOrigin/platform/v1/buy?${Uri(queryParameters: params).query}';
  }

  void _handleMessage(FrameMessage message) {
    switch (message.kind) {
      case 'complete':
        final payload = message.payload!;
        final transaction = payload['transaction'] as Map<String, dynamic>;
        widget.onComplete(TransactionResult(
          id: transaction['id'] as String,
          status: transaction['status'] as String,
        ));
        break;

      case 'challenge':
        final payload = message.payload!;
        widget.onChallenge(payload['url'] as String);
        break;

      case 'error':
        final payload = message.payload!;
        final code = payload['code'] as String;

        if (code == 'quoteExpired') {
          widget.onQuoteExpired?.call();
        } else {
          widget.onError(ConnectError(
            code: code,
            message: payload['message'] as String,
          ));
        }
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox.shrink(
      child: MoonPayWebView(
        key: _webViewKey,
        url: _buildFrameUrl(),
        channelId: _channelId,
        onMessage: _handleMessage,
        onHandshake: () {},
        height: 0,
      ),
    );
  }
}
```

### Challenge widget

```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';

class MoonPayChallengeFrame extends StatefulWidget {
  final String challengeUrl;
  final void Function(TransactionResult) onComplete;
  final VoidCallback onCancelled;
  final void Function(ConnectError) onError;

  const MoonPayChallengeFrame({
    super.key,
    required this.challengeUrl,
    required this.onComplete,
    required this.onCancelled,
    required this.onError,
  });

  @override
  State<MoonPayChallengeFrame> createState() => _MoonPayChallengeFrameState();
}

class _MoonPayChallengeFrameState extends State<MoonPayChallengeFrame> {
  late final String _channelId;

  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
  }

  void _handleMessage(FrameMessage message) {
    switch (message.kind) {
      case 'complete':
        final payload = message.payload!;
        final transaction = payload['transaction'] as Map<String, dynamic>;
        widget.onComplete(TransactionResult(
          id: transaction['id'] as String,
          status: transaction['status'] as String,
        ));
        break;

      case 'cancelled':
        widget.onCancelled();
        break;

      case 'error':
        final payload = message.payload!;
        widget.onError(ConnectError(
          code: payload['code'] as String,
          message: payload['message'] as String,
        ));
        break;
    }
  }

  String get _frameUrl {
    final uri = Uri.parse(widget.challengeUrl);
    return uri
        .replace(queryParameters: {
          ...uri.queryParameters,
          'channelId': _channelId,
        })
        .toString();
  }

  @override
  Widget build(BuildContext context) {
    return MoonPayWebView(
      url: _frameUrl,
      channelId: _channelId,
      onMessage: _handleMessage,
      onHandshake: () {},
    );
  }
}
```

### Usage

```dart theme={null}
class BuyPaymentScreen extends StatefulWidget {
  final String clientToken;
  final String initialQuoteSignature;

  const BuyPaymentScreen({
    super.key,
    required this.clientToken,
    required this.initialQuoteSignature,
  });

  @override
  State<BuyPaymentScreen> createState() => _BuyPaymentScreenState();
}

class _BuyPaymentScreenState extends State<BuyPaymentScreen> {
  late String _quoteSignature;
  String? _challengeUrl;

  @override
  void initState() {
    super.initState();
    _quoteSignature = widget.initialQuoteSignature;
  }

  Future<void> _fetchNewQuote() async {
    final newQuote = await yourApi.getQuote();
    setState(() => _quoteSignature = newQuote.signature);
  }

  void _handleComplete(TransactionResult transaction) {
    setState(() => _challengeUrl = null);
    debugPrint('Transaction initiated: ${transaction.id}');
    Navigator.of(context).pushNamed('/transaction-status');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Buy Crypto')),
      body: Stack(
        children: [
          // Your payment summary UI
          MoonPayBuyFrame(
            clientToken: widget.clientToken,
            quoteSignature: _quoteSignature,
            onComplete: _handleComplete,
            onChallenge: (url) => setState(() => _challengeUrl = url),
            onError: (error) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('Error: ${error.message}')),
              );
            },
            onQuoteExpired: _fetchNewQuote,
          ),
          if (_challengeUrl != null)
            Positioned.fill(
              child: MoonPayChallengeFrame(
                challengeUrl: _challengeUrl!,
                onComplete: _handleComplete,
                onCancelled: () => setState(() => _challengeUrl = null),
                onError: (error) {
                  setState(() => _challengeUrl = null);
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text('Error: ${error.message}')),
                  );
                },
              ),
            ),
        ],
      ),
    );
  }
}
```

***

## Widget frame

For payment methods beyond Apple Pay — including credit/debit cards, Google Pay, bank transfers, and more — use the [widget frame](/platform/frames/widget). It renders the full MoonPay buy experience inside a WebView, including payment-method selection and transaction confirmation. See [pay with widget](/platform/guides/pay-with-widget) for a full walkthrough.

### Widget widget

```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';

class MoonPayWidgetFrame extends StatefulWidget {
  final String clientToken;
  final String quoteSignature;
  final void Function(TransactionResult) onComplete;
  final void Function(ConnectError) onError;
  final void Function(String transactionId, String status)? onTransactionCreated;
  final VoidCallback? onReady;

  const MoonPayWidgetFrame({
    super.key,
    required this.clientToken,
    required this.quoteSignature,
    required this.onComplete,
    required this.onError,
    this.onTransactionCreated,
    this.onReady,
  });

  @override
  State<MoonPayWidgetFrame> createState() => _MoonPayWidgetFrameState();
}

class _MoonPayWidgetFrameState extends State<MoonPayWidgetFrame> {
  late final String _channelId;

  @override
  void initState() {
    super.initState();
    _channelId = generateChannelId();
  }

  String _buildFrameUrl() {
    final params = {
      'flow': 'buy',
      'clientToken': widget.clientToken,
      'quoteSignature': widget.quoteSignature,
      'channelId': _channelId,
    };

    return '$frameOrigin/platform/v1/widget?${Uri(queryParameters: params).query}';
  }

  void _handleMessage(FrameMessage message) {
    switch (message.kind) {
      case 'ready':
        widget.onReady?.call();
        break;

      case 'transactionCreated':
        final payload = message.payload!;
        final transaction = payload['transaction'] as Map<String, dynamic>;
        widget.onTransactionCreated?.call(
          transaction['id'] as String,
          transaction['status'] as String,
        );
        break;

      case 'complete':
        final payload = message.payload!;
        final transaction = payload['transaction'] as Map<String, dynamic>;

        if (transaction['status'] == 'failed') {
          widget.onError(ConnectError(
            code: 'transactionFailed',
            message: transaction['failureReason'] as String,
          ));
        } else {
          widget.onComplete(TransactionResult(
            id: transaction['id'] as String,
            status: transaction['status'] as String,
          ));
        }
        break;

      case 'error':
        final payload = message.payload!;
        widget.onError(ConnectError(
          code: payload['code'] as String,
          message: payload['message'] as String,
        ));
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return MoonPayWebView(
      url: _buildFrameUrl(),
      channelId: _channelId,
      onMessage: _handleMessage,
      onHandshake: () {},
    );
  }
}
```

### Usage

```dart theme={null}
class WidgetPaymentScreen extends StatelessWidget {
  final String clientToken;
  final String quoteSignature;

  const WidgetPaymentScreen({
    super.key,
    required this.clientToken,
    required this.quoteSignature,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Buy Crypto')),
      body: MoonPayWidgetFrame(
        clientToken: clientToken,
        quoteSignature: quoteSignature,
        onReady: () => debugPrint('Widget loaded'),
        onTransactionCreated: (id, status) {
          debugPrint('Transaction created: $id ($status)');
        },
        onComplete: (transaction) {
          debugPrint('Transaction complete: ${transaction.id}');
          Navigator.of(context).pushNamed('/transaction-status');
        },
        onError: (error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Widget error: ${error.message}')),
          );
        },
      ),
    );
  }
}
```
