> ## 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

> Reference implementation for Flutter (Dart)

This page contains a complete reference implementation for integrating the MoonPay Developer Platform in Flutter. The implementation is entirely in Dart and uses `webview_flutter` to render MoonPay frames.

## Overview

The Flutter SDK consists of the following files:

| File          | Description                                                                                                                  |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `types.dart`  | Type definitions imported from generated OpenAPI types, plus SDK-specific types like `Result`, `Connection`, and error types |
| `api.dart`    | API client functions for fetching payment methods and quotes                                                                 |
| `client.dart` | Main `MoonPayClient` class that manages credentials and provides a unified interface                                         |
| `crypto.dart` | X25519 key generation and AES-GCM decryption for secure credential handling                                                  |
| `frames.dart` | Flutter widgets (`ConnectFrame`, `ApplePayFrame`) that wrap WebViews to render MoonPay frames                                |

## Project Setup

<Accordion title="pubspec.yaml - Dependencies" icon="box">
  ```yaml pubspec.yaml theme={null}
  dependencies:
    flutter:
      sdk: flutter
    webview_flutter: ^4.4.2
    webview_flutter_android: ^4.3.0 # Android platform
    webview_flutter_wkwebview: ^3.18.0 # iOS/macOS platform
    http: ^1.2.0 # For API requests
    cryptography: ^2.7.0 # For X25519 and AES-GCM
    convert: ^3.1.1 # For hex encoding

  dev_dependencies:
    build_runner: ^2.4.0
    openapi_generator: ^5.0.0

  # OpenAPI type generation config
  openapi_generator:
    input_spec:
      path: openapi.json
    generator_name: dart
    output_directory: lib/gen
  ```
</Accordion>

<Accordion title="Platform Configuration" icon="mobile">
  <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>
</Accordion>

<Accordion title="Type Generation" icon="gear">
  To generate Dart types from the [OpenAPI spec](/platform/guides/openapi-codegen):

  1. Download the `openapi.json` file to your project root
  2. Run the generator:

  ```bash theme={null}
  flutter pub run build_runner build
  ```

  This generates typed models in `lib/gen/` that you can import instead of manually defining types.
</Accordion>

## Source Files

<Accordion title="types.dart - Type definitions" icon="brackets-curly">
  Types are imported from the generated OpenAPI types where possible, with SDK-specific types defined separately.

  ```dart types.dart theme={null}
  // Import generated types from OpenAPI spec
  import 'package:moonpay_platform/gen/model/authorize_return_type.dart'
      as gen show AuthorizeReturnType;
  import 'package:moonpay_platform/gen/model/get_quote_response.dart'
      as gen show GetQuoteResponse;
  import 'package:moonpay_platform/gen/model/moon_pay_payments_payment_method.dart'
      as gen show MoonPayPaymentsPaymentMethod;
  import 'package:moonpay_platform/gen/model/transaction.dart'
      as gen show Transaction, TransactionStatus;
  import 'package:moonpay_platform/gen/model/fees.dart' as gen show Fees;
  import 'package:moonpay_platform/gen/model/asset.dart' as gen show Asset;

  // Re-export generated types for convenience
  export 'package:moonpay_platform/gen/model/get_quote_response.dart'
      show GetQuoteResponse;
  export 'package:moonpay_platform/gen/model/moon_pay_payments_payment_method.dart'
      show MoonPayPaymentsPaymentMethod;
  export 'package:moonpay_platform/gen/model/transaction.dart'
      show Transaction, TransactionStatus;
  export 'package:moonpay_platform/gen/model/fees.dart' show Fees;
  export 'package:moonpay_platform/gen/model/asset.dart' show Asset;

  // Alias GetQuoteResponse as Quote for convenience
  typedef Quote = gen.GetQuoteResponse;

  // Alias payment method for convenience
  typedef PaymentMethod = gen.MoonPayPaymentsPaymentMethod;

  // Result type for SDK operations (SDK-specific)
  sealed class Result<T, E> {}

  class Ok<T, E> extends Result<T, E> {
    final T value;
    Ok(this.value);
  }

  class Err<T, E> extends Result<T, E> {
    final E error;
    Err(this.error);
  }

  // Credentials derived from AuthorizeReturnType (SDK-specific)
  class Credentials {
    final String accessToken;
    final String clientToken;

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

    factory Credentials.fromJson(Map<String, dynamic> json) {
      return Credentials(
        accessToken: json['accessToken'] as String,
        clientToken: json['clientToken'] as String,
      );
    }

    /// Create from generated AuthorizeReturnType
    factory Credentials.fromAuthorizeReturnType(gen.AuthorizeReturnType auth) {
      return Credentials(
        accessToken: auth.accessToken,
        clientToken: auth.clientToken,
      );
    }
  }

  // Connection status (SDK-specific)
  enum ConnectionStatus {
    connectionRequired,
    active,
    unavailable,
    pending,
    failed,
  }

  class Connection {
    final ConnectionStatus status;
    final Credentials? credentials;
    final String? reason;

    Connection({required this.status, this.credentials, this.reason});
  }

  // Error types (SDK-specific)
  class SdkError {
    final String kind;
    final String message;

    SdkError({required this.kind, required this.message});
  }

  typedef CreateClientError = SdkError;
  typedef GetConnectionError = SdkError;
  typedef ConnectError = SdkError;
  typedef GetPaymentMethodsError = SdkError;
  typedef GetQuoteError = SdkError;
  typedef SetupApplePayError = SdkError;

  // Quote params (SDK-specific for convenience)
  class GetQuoteParams {
    final String source;
    final String destination;
    final String sourceAmount;
    final String walletAddress;
    final String paymentMethod;

    GetQuoteParams({
      required this.source,
      required this.destination,
      required this.sourceAmount,
      required this.walletAddress,
      required this.paymentMethod,
    });
  }
  ```
</Accordion>

<Accordion title="api.dart - API client functions" icon="cloud">
  Uses the generated `PaymentMethod` and `Quote` types from the OpenAPI spec.

  ```dart api.dart theme={null}
  import 'dart:convert';
  import 'package:http/http.dart' as http;
  import 'types.dart';

  const String _moonpayApiUrl = 'https://api.moonpay.com';

  Future<Result<List<PaymentMethod>, GetPaymentMethodsError>> getPaymentMethods({
    required String accessToken,
  }) async {
    try {
      final response = await http.get(
        Uri.parse('$_moonpayApiUrl/platform/v1/payment-methods'),
        headers: {
          'Authorization': 'Bearer $accessToken',
          'Content-Type': 'application/json',
        },
      );

      if (response.statusCode == 200) {
        final List<dynamic> data = jsonDecode(response.body);
        // PaymentMethod.fromJson is generated from OpenAPI spec
        final methods = data.map((item) => PaymentMethod.fromJson(item)).toList();
        return Ok(methods);
      }

      final errorData = jsonDecode(response.body);
      return Err(SdkError(
        kind: 'genericError',
        message: errorData['message'] ?? 'Failed to fetch payment methods',
      ));
    } catch (e) {
      return Err(SdkError(
        kind: 'genericError',
        message: e.toString(),
      ));
    }
  }

  Future<Result<Quote, GetQuoteError>> getQuote({
    required String accessToken,
    required GetQuoteParams params,
  }) async {
    try {
      final response = await http.post(
        Uri.parse('$_moonpayApiUrl/platform/v1/quotes/buy'),
        headers: {
          'Authorization': 'Bearer $accessToken',
          'Content-Type': 'application/json',
        },
        body: jsonEncode({
          'source': {
            'asset': {'code': params.source},
            'amount': params.sourceAmount,
          },
          'destination': {
            'asset': {'code': params.destination},
          },
          'wallet': {'address': params.walletAddress},
          'paymentMethod': {'type': params.paymentMethod},
        }),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        if (data['data'] != null) {
          // Quote.fromJson is generated from OpenAPI spec (aliased from GetQuoteResponse)
          final quote = Quote.fromJson(data['data']);
          if (quote.signature == null || quote.signature!.isEmpty) {
            return Err(SdkError(
              kind: 'validationError',
              message: 'Quote is not executable - signature missing',
            ));
          }
          return Ok(quote);
        }
      }

      final errorData = jsonDecode(response.body);
      return Err(SdkError(
        kind: 'genericError',
        message: errorData['message'] ?? 'Failed to get quote',
      ));
    } catch (e) {
      return Err(SdkError(
        kind: 'genericError',
        message: e.toString(),
      ));
    }
  }
  ```
</Accordion>

<Accordion title="client.dart - Main client" icon="code">
  Manages credentials internally. The `connect` event handler captures credentials automatically.

  ```dart client.dart theme={null}
  import 'types.dart';
  import 'api.dart' as api;

  class MoonPayClient {
    final String sessionToken;
    Credentials? _credentials;

    MoonPayClient._({required this.sessionToken});

    /// Handle ConnectFrame events - captures credentials on successful connection
    void Function(String kind, dynamic payload) createConnectEventHandler({
      void Function(String kind, dynamic payload)? onEvent,
    }) {
      return (kind, payload) {
        if (kind == 'complete' && payload is Map && payload['status'] == 'active') {
          final creds = payload['credentials'];
          if (creds is Credentials) {
            _credentials = creds;
          }
        }
        onEvent?.call(kind, payload);
      };
    }

    /// Get payment methods for the connected user
    Future<Result<List<PaymentMethod>, GetPaymentMethodsError>> getPaymentMethods() async {
      if (_credentials == null) {
        return Err(SdkError(
          kind: 'validationError',
          message: 'No active connection. Call getConnection() or connect() first.',
        ));
      }
      return api.getPaymentMethods(accessToken: _credentials!.accessToken);
    }

    /// Get a quote for a buy transaction
    Future<Result<Quote, GetQuoteError>> getQuote(GetQuoteParams params) async {
      if (_credentials == null) {
        return Err(SdkError(
          kind: 'validationError',
          message: 'No active connection. Call getConnection() or connect() first.',
        ));
      }
      return api.getQuote(accessToken: _credentials!.accessToken, params: params);
    }

    /// Get Apple Pay parameters (uses internally stored clientToken)
    Result<ApplePayParams, SetupApplePayError> getApplePayParams(String quoteSignature) {
      if (_credentials == null) {
        return Err(SdkError(
          kind: 'genericError',
          message: 'No active connection. Call getConnection() or connect() first.',
        ));
      }
      return Ok(ApplePayParams(
        clientToken: _credentials!.clientToken,
        quoteSignature: quoteSignature,
      ));
    }
  }

  /// Apple Pay frame parameters
  class ApplePayParams {
    final String clientToken;
    final String quoteSignature;
    ApplePayParams({required this.clientToken, required this.quoteSignature});
  }

  /// Create a new MoonPay client
  Result<MoonPayClient, CreateClientError> createClient({
    required String sessionToken,
  }) {
    if (sessionToken.isEmpty) {
      return Err(SdkError(
        kind: 'validationError',
        message: 'Invalid sessionToken',
      ));
    }

    return Ok(MoonPayClient._(sessionToken: sessionToken));
  }
  ```
</Accordion>

<Accordion title="crypto.dart - Encryption utilities" icon="lock">
  Matches the Web implementation's cryptographic approach: X25519 key exchange, HKDF key derivation (no salt, no info), and AES-GCM decryption.

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

  /// X25519 keypair with hex-encoded keys (matches Web KeyPair type)
  class KeyPair {
    final SimpleKeyPair _keyPair;
    final String privateKey; // hex-encoded (not used directly, kept for API parity)
    final String publicKey;  // hex-encoded

    KeyPair._(this._keyPair, this.privateKey, this.publicKey);

    /// Generate a new X25519 keypair
    static Future<KeyPair> generate() async {
      final algorithm = X25519();
      final keyPair = await algorithm.newKeyPair();
      final publicKey = await keyPair.extractPublicKey();
      final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
      return KeyPair._(
        keyPair,
        hex.encode(privateKeyBytes),
        hex.encode(publicKey.bytes),
      );
    }

    /// Get the underlying SimpleKeyPair for cryptographic operations
    SimpleKeyPair get simpleKeyPair => _keyPair;
  }

  /// Decrypt credentials from the connect frame
  /// Matches Web: JSON.parse(atob(encryptedData))
  Future<Credentials> decrypt(String encryptedData, KeyPair keyPair) async {
    // Base64 decode first, then parse JSON (matches Web: atob then JSON.parse)
    final decodedPayload = utf8.decode(base64Decode(encryptedData));
    final parsed = jsonDecode(decodedPayload) as Map<String, dynamic>;

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

    // X25519 key agreement
    final x25519 = X25519();
    final ephemeralPublicKey = SimplePublicKey(
      Uint8List.fromList(ephemeralPublicKeyBytes),
      type: KeyPairType.x25519,
    );

    final sharedSecret = await x25519.sharedSecretKey(
      keyPair: keyPair.simpleKeyPair,
      remotePublicKey: ephemeralPublicKey,
    );

    // HKDF key derivation - matches Web: hkdf(sha256, sharedSecret, undefined, undefined, 32)
    // No salt (undefined), no info (undefined), 32 bytes output
    final hkdfAlgorithm = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
    final encryptionKey = await hkdfAlgorithm.deriveKey(
      secretKey: sharedSecret,
      info: <int>[],  // undefined in Web
      nonce: <int>[], // undefined in Web (salt)
    );

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

    final plainTextBytes = await aesGcm.decrypt(secretBox, secretKey: encryptionKey);
    final credentialsJson = jsonDecode(utf8.decode(plainTextBytes)) as Map<String, dynamic>;
    return Credentials.fromJson(credentialsJson);
  }

  /// Generate a unique channel ID (matches Web: `ch_${crypto.randomUUID()}`)
  String generateChannelId() {
    // Dart doesn't have crypto.randomUUID(), use timestamp + random
    final timestamp = DateTime.now().millisecondsSinceEpoch;
    final random = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
    return 'ch_${timestamp}_$random';
  }
  ```
</Accordion>

<Accordion title="frames.dart - Frame handling" icon="window-restore">
  Matches the Web SDK's frame handling pattern. Uses `MoonPayBridge` JavaScript channel with message bridge injection.

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

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

  typedef MessageDirection = String; // 'inbound' | 'outbound'

  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>?,
      );
    }
  }

  FrameMessage? parseMessage(dynamic data, String channelId) {
    try {
      final Map<String, dynamic> json = data is String ? jsonDecode(data) : data;
      if (json['version'] != 2 || (json['meta'] as Map?)?['channelId'] != channelId) return null;
      return FrameMessage.fromJson(json);
    } catch (_) {
      return null;
    }
  }

  Credentials decryptCredentials(String encrypted, KeyPair keyPair) {
    // Note: In Flutter this is async, but we wrap it for API parity
    throw UnimplementedError('Use decryptCredentialsAsync instead');
  }

  Future<Credentials> decryptCredentialsAsync(String encrypted, KeyPair keyPair) async {
    return decrypt(encrypted, keyPair);
  }

  String buildFrameUrl(String path, Map<String, String> params) {
    final url = Uri.parse('$frameOrigin$path');
    return url.replace(queryParameters: params).toString();
  }

  // Connect Frame Widget
  class ConnectFrame extends StatefulWidget {
    final String sessionToken;
    final String? theme;
    final Function(String kind, dynamic payload)? onEvent;

    const ConnectFrame({
      super.key,
      required this.sessionToken,
      this.theme,
      this.onEvent,
    });

    @override
    State<ConnectFrame> createState() => _ConnectFrameState();
  }

  class _ConnectFrameState extends State<ConnectFrame> {
    late final WebViewController _controller;
    late final Future<KeyPair> _keyPairFuture;
    late final String _channelId;

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

    void _initController(KeyPair keyPair) {
      _controller = WebViewController()
        ..setJavaScriptMode(JavaScriptMode.unrestricted)
        ..addJavaScriptChannel(
          'MoonPayBridge',
          onMessageReceived: (msg) => _handleMessage(msg, keyPair),
        )
        ..setNavigationDelegate(
          NavigationDelegate(onPageFinished: (_) => _injectMessageBridge()),
        )
        ..loadRequest(Uri.parse(buildFrameUrl('/platform/v1/connect', {
          'sessionToken': widget.sessionToken,
          'publicKey': keyPair.publicKey,
          'channelId': _channelId,
          if (widget.theme != null) 'theme': widget.theme!,
        })));
    }

    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 _sendMessage(String kind, [Map<String, dynamic>? payload]) {
      final data = {
        'version': 2,
        'meta': {'channelId': _channelId},
        'kind': kind,
        if (payload != null) 'payload': payload,
      };
      _controller.runJavaScript('window.postMessage(${jsonEncode(data)}, "*");');
    }

    Future<void> _handleMessage(JavaScriptMessage jsMessage, KeyPair keyPair) async {
      final message = parseMessage(jsMessage.message, _channelId);
      if (message == null) return;

      switch (message.kind) {
        case 'handshake':
          _sendMessage('ack');
          break;

        case 'ready':
          widget.onEvent?.call('ready', null);
          break;

        case 'complete':
          final payload = message.payload!;
          final status = payload['status'] as String;
          if (status == 'connectionRequired') return;

          if (status == 'active' && payload['credentials'] != null) {
            try {
              final credentials = await decrypt(payload['credentials'] as String, keyPair);
              widget.onEvent?.call('complete', {'status': 'active', 'credentials': credentials});
            } catch (e) {
              widget.onEvent?.call('error', {'kind': 'genericError', 'message': 'Failed to decrypt credentials'});
            }
          } else if (status == 'failed') {
            widget.onEvent?.call('complete', {'status': 'failed', 'reason': payload['reason'] ?? 'Connection failed'});
          } else {
            widget.onEvent?.call('complete', {'status': status});
          }
          break;

        case 'error':
          final payload = message.payload as Map<String, dynamic>;
          widget.onEvent?.call('error', {'kind': payload['code'], 'message': payload['message']});
          break;
      }
    }

    @override
    Widget build(BuildContext context) {
      return FutureBuilder<KeyPair>(
        future: _keyPairFuture,
        builder: (context, snapshot) {
          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }
          _initController(snapshot.data!);
          return WebViewWidget(controller: _controller);
        },
      );
    }
  }

  // Apple Pay Frame Widget
  class ApplePayFrame extends StatefulWidget {
    final String clientToken;
    final String quoteSignature;
    final Function(String kind, dynamic payload)? onEvent;

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

    @override
    State<ApplePayFrame> createState() => _ApplePayFrameState();
  }

  class _ApplePayFrameState extends State<ApplePayFrame> {
    late final WebViewController _controller;
    late final String _channelId;

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

      _controller = WebViewController()
        ..setJavaScriptMode(JavaScriptMode.unrestricted)
        ..addJavaScriptChannel('MoonPayBridge', onMessageReceived: _handleMessage)
        ..setNavigationDelegate(
          NavigationDelegate(onPageFinished: (_) => _injectMessageBridge()),
        )
        ..loadRequest(Uri.parse(buildFrameUrl('/platform/v1/apple-pay', {
          'clientToken': widget.clientToken,
          'signature': widget.quoteSignature,
          'channelId': _channelId,
        })));
    }

    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 _sendMessage(String kind, [Map<String, dynamic>? payload]) {
      final data = {
        'version': 2,
        'meta': {'channelId': _channelId},
        'kind': kind,
        if (payload != null) 'payload': payload,
      };
      _controller.runJavaScript('window.postMessage(${jsonEncode(data)}, "*");');
    }

    void setQuote(String signature) {
      _sendMessage('setQuote', {'quote': {'signature': signature}});
    }

    void _handleMessage(JavaScriptMessage jsMessage) {
      final message = parseMessage(jsMessage.message, _channelId);
      if (message == null) return;

      switch (message.kind) {
        case 'handshake':
          _sendMessage('ack');
          break;

        case 'ready':
          widget.onEvent?.call('ready', null);
          break;

        case 'complete':
          final payload = message.payload as Map<String, dynamic>;
          widget.onEvent?.call('complete', {'transaction': payload['transaction']});
          break;

        case 'error':
          final payload = message.payload as Map<String, dynamic>;
          final errorKind = payload['kind'] ?? payload['code'];
          if (errorKind == 'quoteExpired') {
            widget.onEvent?.call('quoteExpired', {'setQuote': setQuote});
          } else if (errorKind == 'applePayUnavailable') {
            widget.onEvent?.call('unsupported', null);
          } else {
            widget.onEvent?.call('error', payload);
          }
          break;
      }
    }

    @override
    Widget build(BuildContext context) {
      return WebViewWidget(controller: _controller);
    }
  }
  ```
</Accordion>
