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
pubspec.yaml - Dependencies
pubspec.yaml - Dependencies
pubspec.yaml
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
Platform Configuration
Platform Configuration
- iOS
- Android
Add the following to your
ios/Runner/Info.plist:<key>io.flutter.embedded_views_preview</key>
<true/>
Set the minimum SDK version in
android/app/build.gradle:android {
defaultConfig {
minSdkVersion 21
}
}
Type Generation
Type Generation
To generate Dart types from the OpenAPI spec:This generates typed models in
- Download the
openapi.jsonfile to your project root - Run the generator:
flutter pub run build_runner build
lib/gen/ that you can import instead of manually defining types.Source Files
types.dart - Type definitions
types.dart - Type definitions
Types are imported from the generated OpenAPI types where possible, with SDK-specific types defined separately.
types.dart
// 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,
});
}
api.dart - API client functions
api.dart - API client functions
Uses the generated
PaymentMethod and Quote types from the OpenAPI spec.api.dart
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(),
));
}
}
client.dart - Main client
client.dart - Main client
Manages credentials internally. The
connect event handler captures credentials automatically.client.dart
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));
}
crypto.dart - Encryption utilities
crypto.dart - Encryption utilities
Matches the Web implementation’s cryptographic approach: X25519 key exchange, HKDF key derivation (no salt, no info), and AES-GCM decryption.
crypto.dart
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';
}
frames.dart - Frame handling
frames.dart - Frame handling
Matches the Web SDK’s frame handling pattern. Uses
MoonPayBridge JavaScript channel with message bridge injection.frames.dart
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);
}
}