WalletConnect Pay Integration Guide for Kotlin/Android
This guide enables Android wallet developers to integrate WalletConnect Pay into applications that already have WalletKit configured.By following this guide, your wallet will be able to process crypto payment links, allowing users to pay merchants directly from your app.
WalletKit SDK 1.9.0+ integrated and initialized (com.reown:walletkit; bundles com.walletconnect:pay 1.3.0+, required for the data parameter in confirmPayment)
// In your build.gradle.ktsdependencies { implementation("com.reown:walletkit:$walletKitVersion") // For EIP-712 signing (if not already included) implementation("org.web3j:core:4.9.8")}
WalletKit provides isPaymentLink() to identify payment URIs. Always use this function rather than custom URL parsing to ensure compatibility as the protocol evolves.
import com.reown.walletkit.client.WalletKitfun handleUri(uri: String) { when { WalletKit.Pay.isPaymentLink(uri) -> { // Handle as payment link navigateToPaymentFlow(uri) } // Handle other URI types (WalletConnect pairing, deep links, etc.) else -> handleOtherUri(uri) }}
Payment link detection must be added wherever your app processes URIs:
QR Code Scanner
fun onQrCodeScanned(scannedUri: String) { if (WalletKit.Pay.isPaymentLink(scannedUri)) { navigateToPaymentFlow(scannedUri) } else { // Handle as WalletConnect pairing or other URI handleWalletConnectUri(scannedUri) }}
Text Input / Paste
fun onUriPasted(pastedUri: String) { if (WalletKit.Pay.isPaymentLink(pastedUri)) { navigateToPaymentFlow(pastedUri) } else { handleGenericUri(pastedUri) }}
Deep Link Handler
// In your Activity or deep link handleroverride fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.data?.toString()?.let { uri -> if (WalletKit.Pay.isPaymentLink(uri)) { navigateToPaymentFlow(uri) } else { handleDeepLink(uri) } }}
Important: The isPaymentLink() check must precede any generic HTTPS handlers to prevent payment links from accidentally opening in a browser.
WalletKit exposes payment models under Wallet.Model:
// Payment status enumerationenum class PaymentStatus { REQUIRES_ACTION, // Additional action needed PROCESSING, // Payment being processed SUCCEEDED, // Payment completed FAILED, // Payment failed EXPIRED // Payment link expired}// Payment information from merchantdata class PaymentInfo( val status: PaymentStatus, val amount: PaymentAmount, val expiresAt: Long, val merchant: MerchantInfo)// Payment option (token/chain combination)data class PaymentOption( val id: String, val amount: PaymentAmount, val account: String, val estimatedTxs: Int?, val collectData: CollectDataAction? // Per-option data collection (null if not required))// Data collection action (for KYC/compliance via WebView)data class CollectDataAction( val url: String, // WebView URL for data collection val schema: String? // JSON schema describing required fields)// Transaction result details (present when payment already completed)data class PaymentResultInfo( val txId: String, // Transaction ID val optionAmount: PaymentAmount // Token amount details)// Signing action required from walletdata class WalletRpcAction( val chainId: String, val method: String, // "eth_signTypedData_v4" or "personal_sign" val params: String // JSON array as string)
2.2 Handle Data Collection via WebView (if required)
Some payments require user information (KYC/AML compliance). Data collection requirements are specified per payment option via collectData. Show all options first, then handle data collection after the user selects an option:
fun processPaymentOptionsResponse(response: Wallet.Model.PaymentOptionsResponse) { if (response.options.isEmpty()) { showError("No payment options available") return } // Show all options — each option's collectData indicates if IC is needed showPaymentOptions(response.options, response.info)}
When selectedOption.collectData?.url is present, display the URL in a WebView before proceeding with signing. The hosted form handles rendering, validation, and Terms & Conditions acceptance. The WebView communicates completion via JavaScript bridge messages (IC_COMPLETE / IC_ERROR).The form URL accepts optional query parameters (append them with buildFormUrl before loading):
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.
Important: When using the WebView approach, do not pass collectedData to confirmPayment(). The WebView submits data directly to the backend.
Sign each WalletRpcAction with the wallet’s private key:
import org.json.JSONArrayimport org.web3j.crypto.ECKeyPairimport org.web3j.crypto.Signimport org.web3j.crypto.StructuredDataEncoderfun signWalletRpcAction( action: Wallet.Model.WalletRpcAction, privateKey: ByteArray, walletAddress: String): String { return when (action.method) { "eth_signTypedData_v4" -> signTypedDataV4(action.params, privateKey, walletAddress) "personal_sign" -> personalSign(action.params, privateKey) else -> throw UnsupportedOperationException("Unsupported method: ${action.method}") }}/** * Sign EIP-712 typed data. * The params are a JSON array: [address, typedDataJson] */fun signTypedDataV4( params: String, privateKey: ByteArray, walletAddress: String): String { val paramsArray = JSONArray(params) val requestedAddress = paramsArray.getString(0) val typedData = paramsArray.getString(1) // Verify address matches require(requestedAddress.equals(walletAddress, ignoreCase = true)) { "Requested address does not match wallet address" } // Hash the typed data using StructuredDataEncoder val encoder = StructuredDataEncoder(typedData) val hash = encoder.hashStructuredData() // Sign the hash val keyPair = ECKeyPair.create(privateKey) val signatureData = Sign.signMessage(hash, keyPair, false) // Encode signature as hex string val r = signatureData.r.toHexString() val s = signatureData.s.toHexString() val v = (signatureData.v[0].toInt() and 0xff).toString(16).padStart(2, '0') return "0x$r$s$v".lowercase()}/** * Sign a personal message. * The params are a JSON array: [messageHex, address] */fun personalSign( params: String, privateKey: ByteArray): String { val paramsArray = JSONArray(params) val messageHex = paramsArray.getString(0) // Remove "0x" prefix and decode hex to bytes val messageBytes = messageHex.removePrefix("0x").hexToByteArray() // Prefix with Ethereum signed message header val prefix = "\u0019Ethereum Signed Message:\n${messageBytes.size}" val prefixedMessage = prefix.toByteArray() + messageBytes // Hash and sign val hash = org.web3j.crypto.Hash.sha3(prefixedMessage) val keyPair = ECKeyPair.create(privateKey) val signatureData = Sign.signMessage(hash, keyPair, false) val r = signatureData.r.toHexString() val s = signatureData.s.toHexString() val v = (signatureData.v[0].toInt() and 0xff).toString(16).padStart(2, '0') return "0x$r$s$v".lowercase()}// Extension function for hex conversionfun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }fun String.hexToByteArray(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
Critical: Pass raw JSON strings directly to signing APIs. Do not parse and reconstruct the JSON data, as this can cause transformation issues leading to signature verification failures.
Submit the wallet RPC results (data) to confirm the payment (data collection, if any, was already submitted by the WebView). Each element of data is either a plain string (a signature or transaction hash) or, for chains like Tron, a JSON object encoded as a string; WalletConnect Pay forwards each element verbatim to the gateway. See Tron support for the Tron payload.
suspend fun confirmPayment( paymentId: String, optionId: String, data: List<String>): Result<Wallet.Model.ConfirmPaymentResponse> { return WalletKit.Pay.confirmPayment( Wallet.Params.ConfirmPayment( paymentId = paymentId, optionId = optionId, data = data ) )}// Handle the responsefun handleConfirmationResult(response: Wallet.Model.ConfirmPaymentResponse) { when (response.status) { Wallet.Model.PaymentStatus.SUCCEEDED -> { showSuccessScreen("Payment completed successfully!") } Wallet.Model.PaymentStatus.PROCESSING -> { // Payment is being processed asynchronously showSuccessScreen("Payment is being processed...") // Optionally poll for updates using response.pollInMs } Wallet.Model.PaymentStatus.FAILED -> { showErrorScreen("Payment failed. Please try again.") } Wallet.Model.PaymentStatus.EXPIRED -> { showErrorScreen("Payment link has expired.") } Wallet.Model.PaymentStatus.REQUIRES_ACTION -> { // Additional action needed (rare case) showErrorScreen("Additional action required.") } }}
Wallet.Params.ConfirmPayment fields:
data class ConfirmPayment( val paymentId: String, val optionId: String, @Deprecated("Use data instead") val signatures: List<String> = emptyList(), // Deprecated alias for data, still accepted val collectedData: List<Wallet.Model.CollectDataFieldResult>? = null, val data: List<String>? = null // Wallet RPC results in action order: plain strings or JSON-encoded objects (e.g. Tron); falls back to signatures when null)
The signatures field 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 omitted.
Implement a state machine to manage the payment flow:
sealed class PaymentUiState { data object Loading : PaymentUiState() data class Options( val paymentInfo: Wallet.Model.PaymentInfo?, val options: List<Wallet.Model.PaymentOption> ) : PaymentUiState() data class WebViewDataCollection( val url: String ) : PaymentUiState() data class Processing( val message: String, val paymentInfo: Wallet.Model.PaymentInfo? = null ) : PaymentUiState() data class Success( val message: String, val paymentInfo: Wallet.Model.PaymentInfo? = null ) : PaymentUiState() data class Error(val message: String) : PaymentUiState()}
fun formatPaymentAmount(amount: Wallet.Model.PaymentAmount): String { val value = amount.value.toBigDecimalOrNull() ?: return "${amount.value} ${amount.unit}" val decimals = amount.display?.decimals ?: 18 val divisor = BigDecimal.TEN.pow(decimals) val formattedValue = value.divide(divisor, decimals, RoundingMode.HALF_UP) .stripTrailingZeros() .toPlainString() val symbol = amount.display?.assetSymbol ?: amount.unit return "$formattedValue $symbol"}
WalletKit exposes typed errors for payment operations:
sealed class PaymentError : Exception() { data class InvalidPaymentLink(override val message: String) : PaymentError() data class PaymentExpired(override val message: String) : PaymentError() data class PaymentNotFound(override val message: String) : PaymentError() data class InvalidRequest(override val message: String) : PaymentError() data class ComplianceFailed(override val message: String) : PaymentError() data class NetworkError(override val message: String) : PaymentError() data class InternalError(override val message: String) : PaymentError()}
fun handlePaymentError(error: Throwable): String { return when (error) { is PaymentError.InvalidPaymentLink -> "Invalid payment link format" is PaymentError.PaymentExpired -> "This payment link has expired" is PaymentError.PaymentNotFound -> "Payment not found" is PaymentError.ComplianceFailed -> "Unable to process payment due to compliance requirements" is PaymentError.NetworkError -> "Network error. Please check your connection" else -> error.message ?: "An unexpected error occurred" }}
suspend fun pollPaymentStatus( paymentId: String, optionId: String, pollIntervalMs: Long) { delay(pollIntervalMs) // Re-call getPaymentOptions or implement a status check endpoint // Continue polling until status is final (SUCCEEDED, FAILED, EXPIRED)}
class PaymentFlowExample( private val walletRepository: WalletRepository) { suspend fun processPayment(paymentLink: String): Result<Unit> = runCatching { // 1. Get accounts in CAIP-10 format val accounts = walletRepository.getAccountsAsCaip10() // 2. Get payment options val optionsResponse = WalletKit.Pay.getPaymentOptions(paymentLink, accounts) .getOrThrow() val paymentId = optionsResponse.paymentId // 3. Let user select payment option val selectedOption = showOptionsAndGetSelection(optionsResponse.options) // 4. Handle data collection via WebView if required for selected option selectedOption.collectData?.url?.let { url -> showDataCollectionWebView(url) // Wait for IC_COMPLETE before proceeding } // 5. Get required signing actions val actions = WalletKit.Pay.getRequiredPaymentActions( Wallet.Params.RequiredPaymentActions(paymentId, selectedOption.id) ).getOrThrow() // 6. Sign all actions val data = actions .filterIsInstance<Wallet.Model.RequiredAction.WalletRpc>() .map { walletRepository.signWalletRpcAction(it.action) } // 7. Confirm payment val confirmResponse = WalletKit.Pay.confirmPayment( Wallet.Params.ConfirmPayment( paymentId = paymentId, optionId = selectedOption.id, data = data ) ).getOrThrow() // 8. Handle result when (confirmResponse.status) { Wallet.Model.PaymentStatus.SUCCEEDED -> { /* Success! */ } Wallet.Model.PaymentStatus.PROCESSING -> { // Optionally poll for final status confirmResponse.pollInMs?.let { delay(it) } } else -> throw Exception("Payment ${confirmResponse.status}") } }}