WalletConnect Pay Integration Guide for Flutter (via WalletKit)
This guide enables Flutter wallet developers to integrate WalletConnect Pay for processing crypto payment links through reown_walletkit. The integration allows wallet applications to accept and process payment requests from merchants using the WalletConnect Pay protocol.
Payment Link → Detect → Get Options → [WebView Data Collection] → Get Actions → Sign Actions → Confirm Payment → Result
Payment Link Detection: Identify incoming payment links from QR codes, deep links, or text input
Get Payment Options: Retrieve available payment methods with merchant information
WebView Data Collection (optional): Display WebView form for KYC/compliance data if required by the payment. The WebView submits data directly to the backend, so this must happen BEFORE fetching actions.
Get Actions: Fetch the required signing actions via getRequiredPaymentActions (only needed when the selected option’s actions are empty)
Use walletKit.isPaymentLink() to detect payment links. This check must occur at ALL URI entry points in your app.
/// Check if a URI is a payment linkbool isPaymentLink(String uri) { return walletKit.isPaymentLink(uri);}
CRITICAL: Add payment link detection to:
QR code scanner results
Deep link handlers (cold start and warm start)
Paste/text input handlers
Universal link handlers
Important: Payment links are HTTPS URLs. Ensure the isPaymentLink() check happens BEFORE any generic HTTPS URL handling to prevent opening payment links in a browser.
// Example: In your pairing/URI handlerFuture<void> handleUri(String uri) async { if (walletKit.isPaymentLink(uri)) { // Handle as payment // See fetchPaymentOptions on Step 4 } else { // Handle as standard WalletConnect pairing await walletKit.pair(uri: Uri.parse(uri)); }}
Retrieve available payment options using the wallet’s accounts in CAIP-10 format.
/// Get payment options for a payment linkFuture<PaymentOptionsResponse> fetchPaymentOptions(String paymentLink) async { // Get wallet accounts in CAIP-10 format // Format: eip155:{chainId}:{address} final accounts = await getWalletAccounts(); // Example: ['eip155:1:0x123...', 'eip155:137:0x123...', 'eip155:42161:0x123...'] final request = GetPaymentOptionsRequest( paymentLink: paymentLink, accounts: accounts, includePaymentInfo: true, // Include merchant and payment details ); try { final response = await walletKit.getPaymentOptions(request: request); return response; } on GetPaymentOptionsError catch (e) { // Handle specific error codes // e.code: 'PaymentExpired', 'PaymentNotFound', 'InvalidAccount', etc. throw e; }}/// Helper: Get wallet accounts in CAIP-10 formatFuture<List<String>> getWalletAccounts() async { final List<String> accounts = []; // For each supported chain, add the account // Adjust this based on your wallet's key management for (final chainId in supportedChainIds) { final address = await getAddressForChain(chainId); accounts.add('$chainId:$address'); } return accounts;}
Response Structure:
class PaymentOptionsResponse { final String paymentId; // Unique ID for this payment session final PaymentInfo? info; // Merchant and payment details final List<PaymentOption> options; // Available payment methods final CollectDataAction? collectData; // Optional data collection with WebView URL final PaymentResultInfo? resultInfo; // Transaction result details (present when payment already completed)}class PaymentResultInfo { final String txId; // Transaction ID final PayAmount optionAmount; // Token amount details}class CollectDataAction { final String url; // WebView URL for data collection final String? schema; // JSON schema describing required fields}class PaymentInfo { final PaymentStatus status; final PayAmount amount; // Payment amount with display info final int expiresAt; // Unix timestamp final MerchantInfo merchant; // Merchant name and icon final BuyerInfo? buyer;}class PaymentOption { final String id; // Option ID for subsequent requests final String account; // CAIP-10 account to pay from final PayAmount amount; // Amount in this option's token final int etaSeconds; // Estimated completion time final List<Action> actions; // Signing actions (may be empty initially) final CollectDataAction? collectData; // Per-option data collection (null if not required)}
If the selected option’s collectData is not null and has a url, display the URL in a WebView for data collection. The hosted form handles rendering, validation, and T&C acceptance.The form URL accepts optional query parameters — append them to selectedOption.collectData.url before loading, preserving existing query parameters:
prefill — base64url-encoded JSON of known user fields. Keys must match the required fields from collectData.schema (e.g. fullName, dob, pobAddress).
theme — light or dark, to match your wallet’s color mode.
themeVariables — a base64url string exported from the WalletConnect Pay Dashboard that overrides design tokens (font, font size, some colors, button/input border radius). Append it verbatim.
String buildFormUrl( String baseUrl, { Map<String, String> prefillData = const {}, String? theme, // "light" or "dark" String? themeVariables, // base64url string exported from the Pay Dashboard}) { final uri = Uri.parse(baseUrl); final params = {...uri.queryParameters}; if (prefillData.isNotEmpty) { params['prefill'] = base64Url.encode(utf8.encode(jsonEncode(prefillData))).replaceAll('=', ''); } if (theme != null) params['theme'] = theme; if (themeVariables != null) params['themeVariables'] = themeVariables; return uri.replace(queryParameters: params).toString();}
If the selected payment option has empty actions, fetch them explicitly.
/// Get signing actions for a payment optionFuture<List<Action>> fetchRequiredActions( String paymentId, String optionId,) async { final request = GetRequiredPaymentActionsRequest( paymentId: paymentId, optionId: optionId, ); try { return await walletKit.getRequiredPaymentActions(request: request); } on GetRequiredActionsError catch (e) { throw e; }}
Action Structure:
class Action { final WalletRpcAction walletRpc;}class WalletRpcAction { final String chainId; // CAIP-2 chain ID (e.g., 'eip155:1') final String method; // JSON-RPC method (e.g., 'eth_signTypedData_v4') final String params; // JSON string of parameters}
CRITICAL: The params field contains a JSON string. Handle it appropriately for your signing library.
/// Sign all actions and return the wallet RPC results, in action orderFuture<List<Object>> signActions(List<Action> actions) async { final List<Object> data = []; for (final action in actions) { final signature = await signAction(action); data.add(signature); } return data;}/// Sign a single actionFuture<String> signAction(Action action) async { final walletRpc = action.walletRpc; final chainId = walletRpc.chainId; final method = walletRpc.method; final params = walletRpc.params; switch (method) { case 'eth_signTypedData_v4': return await signTypedDataV4(chainId, params); case 'personal_sign': return await personalSign(chainId, params); default: throw UnimplementedError('Unsupported signing method: $method'); }}
import 'dart:convert';import 'package:eth_sig_util_plus/eth_sig_util_plus.dart' as eth_sig_util;/// Sign typed data (EIP-712)String signTypedDataV4(String chainId, String params) { // Parse the params - it's a JSON array: [address, typedData] final decodedParams = jsonDecode(params) as List<dynamic>; final typedData = decodedParams.last; // The typed data object or string // Get the typed data as a JSON string final String jsonData; if (typedData is String) { jsonData = typedData; } else { jsonData = jsonEncode(typedData); } // Normalize hex values (some values may have odd-length hex strings) final normalizedData = _normalizeHexValues(jsonData); // Get the private key for the chain final privateKey = getPrivateKeyForChain(chainId); // Sign using eth_sig_util final signature = eth_sig_util.EthSigUtil.signTypedData( privateKey: privateKey, jsonData: normalizedData, version: eth_sig_util.TypedDataVersion.V4, ); return signature;}/// Normalize hex values to have even lengthString _normalizeHexValues(String jsonString) { // Pad odd-length hex values (e.g., "0x186a0" -> "0x0186a0") return jsonString.replaceAllMapped( RegExp(r'"0x([0-9a-fA-F]+)"'), (match) { final hex = match.group(1)!; return hex.length % 2 == 0 ? match.group(0)! : '"0x0$hex"'; }, );}
import 'dart:convert';import 'dart:typed_data';import 'package:eth_sig_util_plus/eth_sig_util_plus.dart' as eth_sig_util;import 'package:eth_sig_util_plus/util/utils.dart' as eth_sig_util_util;/// Sign a personal messageString personalSign(String chainId, String params) { // Parse the params - it's a JSON array: [message, address] final decodedParams = jsonDecode(params) as List<dynamic>; final message = decodedParams.first as String; // Get the private key final privateKey = getPrivateKeyForChain(chainId); final credentials = EthPrivateKey.fromHex(privateKey); // Sign the message final Uint8List messageBytes; if (message.startsWith('0x')) { messageBytes = eth_sig_util_util.hexToBytes(message.substring(2)); } else { messageBytes = utf8.encode(message); } final signature = credentials.signPersonalMessageToUint8List(messageBytes); return eth_sig_util_util.bytesToHex(signature, include0x: true);}
Submit the wallet RPC results (data) to complete the payment. Each element of data is either a plain String (a signature or transaction hash) or, for chains like Tron, a Map<String, dynamic> JSON object; the SDK forwards each element verbatim. See Tron support for the Tron payload.
/// Confirm the payment with the wallet RPC resultsFuture<ConfirmPaymentResponse> confirmPayment({ required String paymentId, required String optionId, required List<Object> data,}) async { final request = ConfirmPaymentRequest( paymentId: paymentId, optionId: optionId, data: data, maxPollMs: 60000, // Poll for up to 60 seconds ); try { final response = await walletKit.confirmPayment(request: request); return response; } on ConfirmPaymentError catch (e) { // Handle specific errors // e.code: 'InvalidSignature', 'PaymentExpired', 'RouteExpired', etc. throw e; }}
CRITICAL: The data list must match the actions array order exactly. Misalignment causes payment failures.
The signatures parameter is deprecated in favor of data and will be removed in a future major version. Existing integrations that pass signatures keep working; the SDK uses it as a fallback when data is null.
/// Handle the payment responseFuture<void> handlePaymentResult(ConfirmPaymentResponse response) async { switch (response.status) { case PaymentStatus.succeeded: // Payment completed successfully showSuccessScreen(); break; case PaymentStatus.processing: // Payment is being processed if (!response.isFinal && response.pollInMs != null) { // Continue polling await Future.delayed(Duration(milliseconds: response.pollInMs!)); // Call confirmPayment again to check status } break; case PaymentStatus.failed: showErrorScreen('Payment failed'); break; case PaymentStatus.expired: showErrorScreen('Payment expired'); break; case PaymentStatus.requires_action: // Should not reach here after signing showErrorScreen('Additional action required'); break; }}
extension DateTimeFormatting on DateTime { /// Format date as YYYY-MM-DD for data collection String get formatted { return '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; }}
Check for payment links BEFORE generic URL handling:
if (walletKit.isPaymentLink(uri)) { await processPayment(uri); // Handle as payment} else if (uri.startsWith('wc:')) { await walletKit.pair(uri: Uri.parse(uri)); // WalletConnect pairing} else if (uri.startsWith('https://')) { // Generic HTTPS - this should come LAST}
// Check if URI is a payment linkwalletKit.isPaymentLink(uri)// Get payment optionswalletKit.getPaymentOptions(request: GetPaymentOptionsRequest(...))// Get signing actionswalletKit.getRequiredPaymentActions(request: GetRequiredPaymentActionsRequest(...))// Confirm paymentwalletKit.confirmPayment(request: ConfirmPaymentRequest(...))// Direct access to Pay instancewalletKit.pay