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://platform.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;
// True while the frame shows no UI: the headless buy button, reset, and
// auth until it sends `ready`
final bool hidden;
const MoonPayWebView({
super.key,
required this.url,
required this.channelId,
required this.onMessage,
required this.onHandshake,
this.height,
this.hidden = false,
});
@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));
// A hidden frame shouldn't paint over your UI, even at 1x1
if (widget.hidden) {
_controller.setBackgroundColor(Colors.transparent);
}
}
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) {
// Keep a hidden frame at 1x1, not zero size. On Android, a zero-size
// platform view may never attach to the window, and the WebView is
// then throttled. IgnorePointer stops it from taking taps. The widget
// tree is the same either way, so changing `hidden` keeps the WebView.
return IgnorePointer(
ignoring: widget.hidden,
child: SizedBox(
width: widget.hidden ? 1 : null,
height: widget.hidden ? 1 : widget.height ?? double.infinity,
child: WebViewWidget(controller: _controller),
),
);
}
}
Handle JavaScript dialogs
In test mode, the Buy Button frame renders a mock Apple Pay or Google Pay button for a wallet quote and useswindow.prompt to simulate the Apple Pay or Google Pay payment sheet. OK authorizes the test payment and Cancel declines it.
webview_flutter does not surface window.prompt (or alert / confirm) by default — the underlying WKWebView on iOS and WebView on Android both require explicit dialog handlers. Without one, the call returns null with no UI shown, the frame interprets that as the customer cancelling, and every test transaction comes back with status: "failed".
This WebView dialog requirement is captured on the Buy Button frame reference, which is the source of truth.
Wire dialog handling via the platform-specific implementations. On iOS use WebKitWebViewController’s UI delegate hooks to handle the text input panel; on Android use AndroidWebViewController to attach a WebChromeClient whose onJsPrompt returns the entered text, or cancels when the customer declines. 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>;
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
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>;
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
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'),
),
);
}
}
Auth frame
The auth frame authenticates a customer with email or SMS one-time passcode and returns fully scoped credentials — without the hosted KYC UI in the connect frame. Launch it after the check frame returnsconnectionRequired, typically when you drive onboarding through the Customer API. See auth frame reference for event details.
Auth widget
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
class MoonPayAuthFrame extends StatefulWidget {
final String clientToken;
final VoidCallback? onReady;
final void Function({
required String status,
ConnectCredentials? credentials,
String? customerId,
}) onComplete;
final void Function(ConnectError) onError;
final VoidCallback? onClose;
const MoonPayAuthFrame({
super.key,
required this.clientToken,
this.onReady,
required this.onComplete,
required this.onError,
this.onClose,
});
@override
State<MoonPayAuthFrame> createState() => _MoonPayAuthFrameState();
}
class _MoonPayAuthFrameState extends State<MoonPayAuthFrame> {
late final String _channelId;
late final Future<MoonPayCrypto> _cryptoFuture;
bool _showFrame = false;
@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/v2/auth?${Uri(queryParameters: params).query}';
}
Future<void> _handleMessage(FrameMessage message, MoonPayCrypto crypto) async {
switch (message.kind) {
case 'ready':
setState(() => _showFrame = true);
widget.onReady?.call();
break;
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>?;
widget.onComplete(
status: status,
credentials: ConnectCredentials(
accessToken: credentials['accessToken'] as String,
clientToken: credentials['clientToken'] as String,
),
customerId: customer?['id'] as String?,
);
break;
case 'termsAcceptanceRequired':
case 'pending':
case 'unavailable':
widget.onComplete(status: status);
break;
}
break;
case 'error':
final payload = message.payload!;
widget.onError(ConnectError(
code: payload['code'] as String,
message: payload['message'] as String,
));
break;
case 'close':
widget.onClose?.call();
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: () {},
hidden: !_showFrame,
);
},
);
}
}
Usage
class AuthScreen extends StatelessWidget {
const AuthScreen({super.key});
@override
Widget build(BuildContext context) {
final clientToken = ModalRoute.of(context)!.settings.arguments as String;
return Scaffold(
appBar: AppBar(title: const Text('Sign in')),
body: MoonPayAuthFrame(
clientToken: clientToken,
onComplete: ({required status, credentials, customerId}) {
if (status == 'active' && credentials != null) {
CredentialsStore.instance.set(
accessToken: credentials.accessToken,
clientToken: credentials.clientToken,
);
debugPrint('Authenticated: $customerId');
Navigator.of(context).pushReplacementNamed('/home');
} else if (status == 'termsAcceptanceRequired') {
Navigator.of(context).pushNamed('/terms');
}
},
onError: (error) {
debugPrint('Auth failed: ${error.message}');
},
onClose: () => Navigator.of(context).pop(),
),
);
}
}
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 Button frame
The Buy Button frame takes a quote to payment. It renders the Apple Pay, Google Pay, or card button that matches the payment method in your quote, and supports guest checkout when your quote allows it. For card and bank transfer quotes, you can also run it in headless mode behind your own purchase button. See the Buy Button frame reference for event details, and Pay with the buy button for the full walkthrough.Apple Pay only works on iOS devices with Apple Pay configured. Google Pay
needs extra setup in an Android WebView and isn’t available in an iOS WebView.
See Mobile WebViews. For the
Apple Pay button in test mode, also handle JavaScript
dialogs.
What you’ll need
Before you initialize the Buy Button frame, you need:- A
clientTokenfrom a successful connect flow - A valid quote signature for the transaction
Buy Button widget
The widget renders the button at 44px tall. See Size. Passheadless: true to run the purchase without a button, as described in Headless mode.
import 'dart:async';
import 'package:flutter/material.dart';
class TransactionResult {
// Null when a failed transaction was never created
final String? id;
final String status;
final String? failureCode;
final String? failureReason;
TransactionResult({
required this.id,
required this.status,
this.failureCode,
this.failureReason,
});
}
class MoonPayBuyButtonFrame extends StatefulWidget {
final String clientToken;
final String quoteSignature;
final String? externalTransactionId;
final bool headless;
final VoidCallback? onReady;
final void Function(TransactionResult) onComplete;
final void Function(String url) onChallenge;
final VoidCallback? onCancelled;
final void Function(ConnectError) onError;
final VoidCallback? onQuoteExpired;
const MoonPayBuyButtonFrame({
super.key,
required this.clientToken,
required this.quoteSignature,
this.externalTransactionId,
this.headless = false,
this.onReady,
required this.onComplete,
required this.onChallenge,
this.onCancelled,
required this.onError,
this.onQuoteExpired,
});
@override
State<MoonPayBuyButtonFrame> createState() => _MoonPayBuyButtonFrameState();
}
class _MoonPayBuyButtonFrameState extends State<MoonPayBuyButtonFrame> {
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(MoonPayBuyButtonFrame 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,
'channelId': _channelId,
'signature': widget.quoteSignature,
if (widget.headless) 'headless': 'true',
if (widget.externalTransactionId != null)
'externalTransactionId': widget.externalTransactionId!,
};
return '$frameOrigin/platform/v1/buy-button?${Uri(queryParameters: params).query}';
}
void _handleMessage(FrameMessage message) {
switch (message.kind) {
case 'ready':
widget.onReady?.call();
break;
case 'complete':
// Check status: a failed payment also arrives as `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,
failureCode: transaction['failureCode'] as String?,
failureReason: transaction['failureReason'] as String?,
));
break;
case 'challenge':
widget.onChallenge(message.payload!['url'] as String);
break;
case 'cancelled':
// The customer dismissed the Apple Pay or Google Pay sheet.
// The button stays on screen, so the customer can tap it again.
widget.onCancelled?.call();
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 MoonPayWebView(
key: _webViewKey,
url: _buildFrameUrl(),
channelId: _channelId,
onMessage: _handleMessage,
onHandshake: () {},
height: 44,
// Headless mode renders nothing, so keep the frame at 1x1
hidden: widget.headless,
);
}
}
Challenge widget
When the Buy Button frame emits achallenge event, for example for 3-D Secure, render the challenge frame at the URL. Opened without resume, as below, it finishes the purchase for every payment method and sends complete. To have the customer finish an Apple Pay or Google Pay purchase on your buy button instead, see Finish on your buy button.
import 'dart:async';
import 'package:flutter/material.dart';
class MoonPayChallengeFrame extends StatefulWidget {
final String challengeUrl;
final String clientToken;
final void Function(TransactionResult) onComplete;
final VoidCallback onCancelled;
final void Function(ConnectError) onError;
const MoonPayChallengeFrame({
super.key,
required this.challengeUrl,
required this.clientToken,
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':
// Check status: a failed payment also arrives as `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,
failureCode: transaction['failureCode'] as String?,
failureReason: transaction['failureReason'] 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,
'clientToken': widget.clientToken,
'channelId': _channelId,
})
.toString();
}
@override
Widget build(BuildContext context) {
return MoonPayWebView(
url: _frameUrl,
channelId: _channelId,
onMessage: _handleMessage,
onHandshake: () {},
);
}
}
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;
String? _challengeUrl;
bool _done = false;
@override
void initState() {
super.initState();
_quoteSignature = widget.initialQuoteSignature;
}
Future<void> _fetchNewQuote() async {
// Fetch a new quote from your API
final newQuote = await yourApi.getQuote();
if (!mounted) return;
setState(() => _quoteSignature = newQuote.signature);
}
// The purchase finished: unmount both frames. Mount a new buy button to retry.
void _endPurchase() {
setState(() {
_challengeUrl = null;
_done = true;
});
}
void _handleComplete(TransactionResult transaction) {
_endPurchase();
if (transaction.status == 'failed') {
// Branch on failureCode; failureReason is developer-facing only
debugPrint('Transaction failed: ${transaction.failureCode}');
return;
}
debugPrint('Transaction initiated: ${transaction.id}');
Navigator.of(context).pushNamed('/transaction-status');
}
void _showError(ConnectError error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Payment error: ${error.message}')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Buy Crypto')),
body: Stack(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Your payment summary UI
const Spacer(),
// After the purchase, show your result screen or a retry path
if (!_done)
MoonPayBuyButtonFrame(
clientToken: widget.clientToken,
quoteSignature: _quoteSignature,
onComplete: _handleComplete,
onChallenge: (url) => setState(() => _challengeUrl = url),
onError: _showError,
onQuoteExpired: _fetchNewQuote,
onReady: () => debugPrint('Buy Button frame ready'),
),
],
),
),
if (_challengeUrl != null)
Positioned.fill(
child: MoonPayChallengeFrame(
challengeUrl: _challengeUrl!,
clientToken: widget.clientToken,
onComplete: _handleComplete,
onCancelled: _endPurchase,
onError: (error) {
_endPurchase();
_showError(error);
},
),
),
],
),
);
}
}
Headless mode
For card and bank transfer quotes, you can draw your own purchase button and passheadless: true to MoonPayBuyButtonFrame. The frame loads with headless=true, renders nothing, and starts the purchase as soon as it loads. The base widget keeps it at 1x1 and transparent. Mount it when the customer confirms the purchase in your UI, not before. See Headless mode.
In headless mode, the frame:
- Sends
ready, then exactly one ofcomplete,challenge, orerror. - Never sends
buttonPressedorcancelled. - Accepts card and bank transfer (SEPA) quotes only. An Apple Pay or Google Pay quote returns
errorwithunsupportedPaymentMethod. - Needs a connected customer. A guest checkout quote returns
errorwithinvalidQuote. - Reads
setQuoteonly after anerrorwithquoteExpired.
challenge, render the challenge widget without resume. It finishes the purchase and sends its own complete.
For a bank transfer (SEPA) quote, onComplete receives the transaction id. Fetch the transaction by id, and show the customer the bank details from bankTransferDepositInfo and the amount to send from source.amount natively so they can send the transfer. See Pay with bank transfer.
For bank transfers, the customer must include the payment
reference from
bankTransferDepositInfo with their transfer, or it is rejected. Render the
deposit details, including the reference, in your own UI. See the transaction
object.PaymentScreen from the usage example above with two changes: show your own purchase button and loading state, and mount the frame with headless: true only after the customer taps your button.
After complete, or an error other than quoteExpired, unmount the frame so a retry mounts a new one. After a challenge, call _endHeadlessPurchase() from the challenge widget’s callbacks too.
bool _confirmed = false;
// Unmounts the headless frame after its final event
void _endHeadlessPurchase() => setState(() => _confirmed = false);
// In build():
ElevatedButton(
// Disabled while a purchase runs, so a second tap can't start another
onPressed: _confirmed ? null : () => setState(() => _confirmed = true),
child: const Text('Buy'),
),
// Mount only after the customer confirms: mounting starts the purchase
if (_confirmed)
MoonPayBuyButtonFrame(
headless: true,
clientToken: widget.clientToken,
quoteSignature: _quoteSignature,
onComplete: (transaction) {
_endHeadlessPurchase();
_handleComplete(transaction);
},
onChallenge: (url) => setState(() => _challengeUrl = url),
onError: (error) {
_endHeadlessPurchase();
_showError(error);
},
onQuoteExpired: _fetchNewQuote,
),
Widget frame
To hand the whole purchase to MoonPay, including amount entry and payment method selection, use the widget frame. It renders the full MoonPay buy experience inside a WebView, including payment collection 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 String? externalTransactionId;
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,
this.externalTransactionId,
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,
if (widget.externalTransactionId != null)
'externalTransactionId': widget.externalTransactionId!,
};
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}')),
);
},
),
);
}
}
Reset frame
The reset frame is headless. Use it when the customer signs out to clear MoonPay session state on MoonPay’s domain. See reset frame reference. Pass yourclientToken so your app is authorized to embed the frame. Do not block sign-out if reset fails.
Reset widget
import 'package:flutter/material.dart';
class MoonPayResetFrame extends StatefulWidget {
final String clientToken;
final VoidCallback onComplete;
final void Function(ConnectError)? onError;
const MoonPayResetFrame({
super.key,
required this.clientToken,
required this.onComplete,
this.onError,
});
@override
State<MoonPayResetFrame> createState() => _MoonPayResetFrameState();
}
class _MoonPayResetFrameState extends State<MoonPayResetFrame> {
late final String _channelId;
@override
void initState() {
super.initState();
_channelId = generateChannelId();
}
String _buildFrameUrl() {
final params = {
'channelId': _channelId,
'clientToken': widget.clientToken,
};
return '$frameOrigin/platform/v1/reset?${Uri(queryParameters: params).query}';
}
void _handleMessage(FrameMessage message) {
switch (message.kind) {
case 'complete':
widget.onComplete();
break;
case 'error':
final payload = message.payload!;
widget.onError?.call(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: () {},
hidden: true,
);
}
}
Usage
// After clearing local credentials, mount briefly during sign-out:
MoonPayResetFrame(
clientToken: clientToken,
onComplete: () => debugPrint('MoonPay session cleared'),
);