UseDocumentation Index
Fetch the complete documentation index at: https://dev.moonpay.com/llms.txt
Use this file to discover all available pages before exploring further.
webview_flutter to embed frames in Flutter applications. The WebView communicates with your Dart code through JavaScript channels.
Read the manual integration
overview for core concepts
before you continue.
Setup
Dependencies
The examples below use cryptography for X25519 key exchange, but you can use any library that supports X25519 and AES-GCM. Add the required packages to yourpubspec.yaml:
dependencies:
webview_flutter: ^4.13.0
cryptography: ^2.7.0
convert: ^3.1.1
dependencies:
webview_flutter_android: ^4.3.0 # Android
webview_flutter_wkwebview: ^3.18.0 # iOS/macOS
flutter pub get to install the packages.
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
}
}
Key generation
Create a utility class for X25519 key generation and decryption: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: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, the Apple Pay frame renders a mock button and useswindow.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.
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.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 for event details.Check widget
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>;
// Check payload['capabilities']['ramps']['requirements']['paymentDisclosures']
// to determine if payment disclosures are required before transacting
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>;
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
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 for event details.Connect widget
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>;
// Check payload['capabilities']['ramps']['requirements']['paymentDisclosures']
// to determine if payment disclosures are required before transacting
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
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 for event details.Apple Pay only works on iOS devices with Apple Pay configured.
Apple Pay widget
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(ConnectError) onError;
final VoidCallback onQuoteExpired;
final VoidCallback? onReady;
const MoonPayApplePayFrame({
super.key,
required this.clientToken,
required this.quoteSignature,
required this.onComplete,
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 '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
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');
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Payment error: ${error.message}')),
);
},
onQuoteExpired: _fetchNewQuote,
onReady: () => debugPrint('Apple 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 for event details.What you’ll need
Before you initialize the add card frame, you need:- A
clientTokenfrom a successful connect flow
Add Card widget
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
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 achallenge event with a URL you open in a separate challenge frame. See buy frame reference for event details.
What you’ll need
Before you initialize the buy frame, you need:- A
clientTokenfrom a successful connect flow - A valid quote signature for the transaction
Buy widget
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
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
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. It renders the full MoonPay buy experience inside a WebView, including payment-method selection and transaction confirmation. See pay with widget for a full walkthrough.Widget widget
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
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}')),
);
},
),
);
}
}