# WalletConnect Agents SDK
Source: https://docs.walletconnect.com/agents/overview
The **WalletConnect Agents SDK** is a monorepo of CLI tools and libraries that bring WalletConnect capabilities to the terminal and AI agent environments. It enables wallet connection, message signing, cross-chain bridging, and WCT staking without a browser UI.
**Beta (v0.1.x)** — This project is under active development. APIs and CLI interfaces may change between releases. Not yet recommended for production use.
## Packages
WCT staking on Optimism — stake, unstake, claim rewards
## Install
```bash theme={null}
npm install -g @walletconnect/cli-sdk @walletconnect/staking-cli
```
Get a Project ID from [WalletConnect Cloud](https://cloud.walletconnect.com), then configure it:
```bash theme={null}
walletconnect config set project-id
# or
export WALLETCONNECT_PROJECT_ID=
```
***
## WalletConnect Pay CLI
**Experimental** — This package is under active development. APIs, commands, and behavior may change significantly between releases.
Create and complete WalletConnect Pay payments from the terminal. Supports proxy mode (no API keys needed) and direct API mode.
### Commands
```
Usage: walletconnect-pay [options]
Commands:
status Check the status of a payment
create Create a new payment (merchant credentials required)
checkout Complete a payment using a connected wallet
Options:
--staging Use the staging API
--proxy Proxy through frontend (no API keys needed)
--help Show this help message
```
### Authentication
By default, the CLI proxies through the WalletConnect Pay frontend — no API keys needed. For direct API access:
```bash theme={null}
export WC_PAY_WALLET_API_KEY=
# Required for merchant operations
export WC_PAY_PARTNER_API_KEY=
export WC_PAY_MERCHANT_ID=
```
### Travel Rule compliance
Some payments require Information Capture data. Provide via CLI flags or environment variables:
```bash theme={null}
walletconnect-pay checkout \
--name "John Doe" \
--dob "1990-01-15" \
--pob-country "US" \
--pob-address "New York, NY"
```
Or via environment variables:
```bash theme={null}
export WC_PAY_NAME="John Doe"
export WC_PAY_DOB="1990-01-15"
export WC_PAY_POB_COUNTRY="US"
export WC_PAY_POB_ADDRESS="New York, NY"
```
### Examples
```bash theme={null}
# Check a payment's status
walletconnect-pay status pay_abc123 --staging
# Create a $10 payment (1000 = minor units / cents)
walletconnect-pay create 1000 --staging
# Complete a payment with a connected wallet
walletconnect-pay checkout pay_abc123 --staging
```
### Programmatic usage
```typescript theme={null}
import { createPayClient, createFrontendPayClient } from "@walletconnect/pay-cli";
// Direct API client
const client = createPayClient({
walletApiKey: "your-wallet-api-key",
sdkVersion: "0.5.0",
staging: true,
});
// Proxy through the frontend (no API keys needed)
const proxyClient = createFrontendPayClient({
frontendUrl: "https://staging.pay.walletconnect.com",
});
const payment = await client.getPayment("pay_abc123");
```
***
## WalletConnect CLI
Connect a wallet, sign messages, send transactions, and bridge tokens across chains from the terminal.
### Commands
```
Usage: walletconnect [options]
Commands:
connect Connect to a wallet via QR code
whoami Show current session info
sign Sign a message with the connected wallet
sign-typed-data Sign EIP-712 typed data (JSON string)
send-transaction Send a transaction (EVM or Solana)
swidge Bridge/swap tokens across chains via LI.FI
disconnect Disconnect the current session
config set Set a config value (e.g. project-id)
config get Get a config value
Options:
--browser Use browser UI instead of terminal QR code
--json Output as JSON (for whoami)
--chain Specify chain (e.g. evm, solana, eip155:10) for connect
--version Show version number
--help Show this help message
```
### Examples
```bash theme={null}
# Connect a wallet (shows QR code in terminal)
walletconnect connect
# Connect using a browser-based QR code
walletconnect connect --browser
# Sign a message
walletconnect sign "Hello from the terminal"
# Check connected wallet
walletconnect whoami
# Send a transaction on Optimism
walletconnect send-transaction '{"to":"0x...","value":"0x0","chainId":"eip155:10"}'
# Disconnect
walletconnect disconnect
```
### Swidge (Cross-Chain Bridge/Swap)
Bridge or swap tokens across EVM chains, powered by [LI.FI](https://li.fi). The connected wallet approves and executes each transaction.
```bash theme={null}
# Bridge 5 WCT from Optimism to Ethereum mainnet
walletconnect swidge --from-chain eip155:10 --to-chain eip155:1 \
--from-token WCT --to-token WCT --amount 5
# Swap USDC on Base to ETH on Optimism
walletconnect swidge --from-chain eip155:8453 --to-chain eip155:10 \
--from-token USDC --to-token ETH --amount 10
# Bridge ETH from Ethereum to Base
walletconnect swidge --from-chain eip155:1 --to-chain eip155:8453 \
--amount 0.01
```
When sending a transaction, the CLI automatically checks if the connected wallet has sufficient ETH on the target chain. If funds are insufficient:
* **Interactive (TTY):** Prompts you to bridge from another chain
* **Pipe / agent mode:** Auto-bridges from the chain with the most funds
### Programmatic usage
```typescript theme={null}
import { WalletConnectCLI, withWallet } from "@walletconnect/cli-sdk";
// High-level helper — connect → use → cleanup
await withWallet(
{
projectId: "your-project-id",
metadata: { name: "My App", description: "...", url: "...", icons: [] },
},
async (wallet, { accounts }) => {
const txHash = await wallet.request({
chainId: "eip155:1",
request: {
method: "eth_sendTransaction",
params: [{ from: accounts[0].split(":").pop(), to: "0x...", value: "0x0" }],
},
});
},
);
// Direct client usage
const wallet = new WalletConnectCLI({
projectId: "your-project-id",
metadata: { name: "My App", description: "...", url: "...", icons: [] },
chains: ["eip155:1", "eip155:10"],
ui: "browser", // or "terminal" (default)
});
const { accounts } = await wallet.connect();
const signature = await wallet.request({
chainId: "eip155:1",
request: { method: "personal_sign", params: ["0x48656c6c6f", accounts[0]] },
});
await wallet.disconnect();
await wallet.destroy();
```
***
## WalletConnect Staking CLI
Stake WCT tokens on Optimism, check staking positions, and claim rewards.
### Commands
```
Usage: walletconnect-staking [options]
Commands:
stake Stake WCT (approve + createLock/updateLock)
unstake Withdraw all staked WCT (after lock expires)
claim Claim staking rewards
status Show staking position, rewards, and APY
balance Show WCT token balance
Options:
--address=0x... Use address directly (for read-only commands)
--browser Use browser UI for wallet connection
--help Show this help message
```
### Examples
```bash theme={null}
# Stake 1000 WCT for 52 weeks
walletconnect-staking stake 1000 52
# Check staking position and rewards (read-only, no wallet needed)
walletconnect-staking status --address=0x...
# Check WCT balance
walletconnect-staking balance --address=0x...
# Claim staking rewards (requires wallet)
walletconnect-staking claim
# Withdraw all staked WCT (after lock expires)
walletconnect-staking unstake
```
### Programmatic usage
```typescript theme={null}
import { stake, status, balance, fetchStaking, formatWCT } from "@walletconnect/staking-cli";
```
The staking package exports transaction builders, formatting utilities, and API helpers for building custom staking integrations.
***
## Agent Skills
Install the accompanying Claude Code / agent skills:
```bash theme={null}
npx skills add WalletConnect/agent-sdk
```
This adds the `walletconnect-pay`, `walletconnect`, and `walletconnect-staking` skills to your Claude Code environment, enabling AI agents to interact with wallets and payments directly from the terminal.
## Source
View the source, file issues, and contribute on GitHub.
# Delete a merchant
Source: https://docs.walletconnect.com/api-reference/2026-02-18/delete-v1-merchants-merchantid
api/2026-02-18.json DELETE /v1/merchants/{merchantId}
Soft-delete a merchant. The merchant is hidden from listings and can no longer accept new payments. Already-deleted merchants return `deleted: true` so the call is idempotent.
# Delete a crypto settlement
Source: https://docs.walletconnect.com/api-reference/2026-02-18/delete-v1-merchants-merchantid-settlements-crypto-id
api/2026-02-18.json DELETE /v1/merchants/{merchantId}/settlements/crypto/{id}
Remove a crypto settlement. Future payments in the deleted asset will no longer be auto-settled.
# Get a payment
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-gateway-payment-id
api/2026-02-18.json GET /v1/gateway/payment/{id}
This endpoint returns only basic information to display to the user to begin the payment flow.
# Get the payment status
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-gateway-payment-id-status
api/2026-02-18.json GET /v1/gateway/payment/{id}/status
Retrieves the status of a payment by its ID. Returns a polling delay to wait
before checking the status again.
# List merchants
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-merchants
api/2026-02-18.json GET /v1/merchants
List the partner's merchants. Supports filtering by `status`, free-text `search` over name and ID, and cursor-based pagination via `cursor` and `limit`.
# Get payments for a merchant
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-merchants-merchant-id-payments
api/2026-02-18.json GET /v1/merchants/{merchant_id}/payments
**Deprecated:** Use `GET /v1/merchants/payments` with partner `api-key` and `merchant-id` headers instead.
Retrieve paginated payments with optional filters.
# Get a merchant
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-merchants-merchantid
api/2026-02-18.json GET /v1/merchants/{merchantId}
Fetch a merchant's profile along with its configured `settlements` (crypto and fiat). Crypto settlements use [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) asset IDs and [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) destination accounts.
# List settlements
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-merchants-merchantid-settlements
api/2026-02-18.json GET /v1/merchants/{merchantId}/settlements
List the merchant's configured fiat and crypto settlement destinations.
# List payments for a merchant
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-merchants-payments
api/2026-02-18.json GET /v1/merchants/payments
Retrieve paginated payments with optional filters.
# List payments
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-payments
api/2026-02-18.json GET /v1/payments
Retrieve paginated payments with optional filters.
# Get payment details
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-payments-id
api/2026-02-18.json GET /v1/payments/{id}
Retrieves the full details of a payment by its ID, including settlement
information when available.
# Get the payment status
Source: https://docs.walletconnect.com/api-reference/2026-02-18/get-v1-payments-id-status
api/2026-02-18.json GET /v1/payments/{id}/status
Retrieves the status of a payment by its ID. Returns a polling delay to wait
before checking the status again.
# Update a merchant
Source: https://docs.walletconnect.com/api-reference/2026-02-18/patch-v1-merchants-merchantid
api/2026-02-18.json PATCH /v1/merchants/{merchantId}
Update a merchant's profile. At least one of `merchantName`, `merchantEmail`, or `iconUrl` must be provided. Pass `iconUrl: null` to unset the existing logo.
# POST /v1/gateway/payment/{id}/cancel
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-gateway-payment-id-cancel
api/2026-02-18.json POST /v1/gateway/payment/{id}/cancel
# Confirm a payment
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-gateway-payment-id-confirm
api/2026-02-18.json POST /v1/gateway/payment/{id}/confirm
This endpoint confirms a payment and submits it to the blockchain for processing.
# Fetches an action
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-gateway-payment-id-fetch
api/2026-02-18.json POST /v1/gateway/payment/{id}/fetch
This endpoint fetches an action for a payment.
# Get payment options
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-gateway-payment-id-options
api/2026-02-18.json POST /v1/gateway/payment/{id}/options
This endpoint takes a list of accounts and returns a list of options that can be used to complete the payment.
# Create a merchant
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-merchants
api/2026-02-18.json POST /v1/merchants
Create a new merchant under the authenticated partner. Requires an `Idempotency-Key` header so retries safely return the originally-created merchant rather than creating duplicates.
# Create crypto settlements
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-merchants-merchantid-settlements-crypto
api/2026-02-18.json POST /v1/merchants/{merchantId}/settlements/crypto
Register one or more crypto settlement destinations for the merchant. Each entry pairs a [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) `asset` with a [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) `destination` wallet on the same chain. Each `(merchant, asset)` pair must be unique — registering the same asset twice returns `settlement_asset_conflict`.
# Create a new payment
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-payments
api/2026-02-18.json POST /v1/payments
Creates a new payment with the provided payment details.
# POST /v1/payments/{id}/cancel
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-payments-id-cancel
api/2026-02-18.json POST /v1/payments/{id}/cancel
# Mark a payment as refunded
Source: https://docs.walletconnect.com/api-reference/2026-02-18/post-v1-refunds
api/2026-02-18.json POST /v1/refunds
Request a full refund for a payment. Calling a second time on the same payment returns 409.
# Update a crypto settlement destination
Source: https://docs.walletconnect.com/api-reference/2026-02-18/put-v1-merchants-merchantid-settlements-crypto-id
api/2026-02-18.json PUT /v1/merchants/{merchantId}/settlements/crypto/{id}
Change the destination wallet for an existing crypto settlement. The new `destination` must be a [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) account on the same chain as the settlement's `asset`.
# API Reference
Source: https://docs.walletconnect.com/api-reference/index
Reference for the WalletConnect Pay API.
This section contains the full WalletConnect Pay API reference, organized by service area.
## Gateway
Endpoints for wallets to fulfill payments (API-first / non-SDK flow): get payment options, fetch actions, confirm payments, check status, and cancel.
If you're looking for the integration overview, start with **[API-first integration (Non-SDK wallets)](/payments/wallets/api-first)**.
## Payments
Merchant-facing endpoints for creating and managing payments.
## Merchants
Endpoints for merchant configuration and management.
## Settlement
Endpoints for managing settlement accounts and configurations.
# API Versioning
Source: https://docs.walletconnect.com/api-reference/versioning
How the WalletConnect Pay API is versioned using date-based versions, account pinning, and preview releases.
The WalletConnect Pay API uses date-based versions in `YYYY-MM-DD` format. Each version is a stable snapshot of the API's behavior and response shapes.
## How it works
When a merchant account is created, it gets pinned to the current API version. All requests from that account use the pinned version by default, so existing integrations keep working without any changes.
You can override the version on any request with the `WCP-Version` header:
```http Request header theme={null}
POST /v1/payments HTTP/1.1
WCP-Version: 2026-02-04
```
If you omit the header, the request uses your account's pinned version. Overrides can only target versions newer than your pinned version.
To permanently upgrade your pinned version, use the merchant dashboard. Downgrades are not supported.
Use the version selector at the top of these docs to browse the API reference for a specific version.
## Preview versions
Preview versions let you test upcoming changes before they reach GA. They use a `.preview` suffix:
```http Preview version header theme={null}
WCP-Version: 2026-06-01.preview
```
Previews require an explicit header on every request. They are not covered by the same stability guarantees as GA versions, and breaking changes may land with minimal notice.
## Client compatibility requirements
For the stability guarantees to hold, your client must:
* Ignore unknown fields in API responses. New fields can appear in any version.
* Tolerate unknown enum values. New statuses and types ship without a version bump.
* Handle unknown webhook event types without crashing.
* Treat unrecognized error codes as generic errors rather than hard-failing.
These are all additive, non-breaking changes that apply across versions.
## Breaking vs non-breaking changes
Breaking changes only ship in new dated versions. A change is breaking if it removes or renames a field, changes a field's type, alters default behavior, removes an endpoint, or makes an optional parameter required.
Non-breaking changes apply to all versions: new endpoints, optional parameters, response fields, enum values, webhook types, and error codes. No version bump needed.
# CEX Exchange Integrations
Source: https://docs.walletconnect.com/payments/cex-coverage
Centralized exchanges supported by WalletConnect Pay, enabling users to pay directly from their CEX account balance.
This page outlines the centralized exchanges WalletConnect Pay supports for **Pay with CEX**. When a user selects a supported exchange at checkout, WalletConnect Pay creates the payment order and deeplinks the user into the exchange app to review and approve the payment. The exchange executes the transaction internally and settles funds directly into the merchant's own exchange account. WalletConnect Pay never holds, custodies, or moves funds in this flow. Compliance, KYC, and settlement are handled end-to-end by the exchange.
For merchants, enabling a CEX integration requires onboarding directly with each exchange and activating it via the WalletConnect Pay Merchant Dashboard.
## Supported Exchanges
| Exchange | Status | Website |
| -------------- | ----------- | ------------------------------------------ |
| Binance Pay | In Progress | [pay.binance.com](https://pay.binance.com) |
| Crypto.com Pay | Coming Soon | [crypto.com](https://crypto.com) |
| Bybit Pay | Coming Soon | [bybit.com](https://bybit.com) |
| Coinbase | Coming Soon | [coinbase.com](https://coinbase.com) |
| KuCoin Pay | Coming Soon | [kucoin.com](https://kucoin.com) |
| OKX | Coming Soon | [okx.com](https://okx.com) |
| Gate Pay | Coming Soon | [gate.io](https://gate.io) |
| Bitget Pay | Coming Soon | [bitget.com](https://bitget.com) |
| Kraken | Coming Soon | [kraken.com](https://kraken.com) |
| Bitfinex | Coming Soon | [bitfinex.com](https://bitfinex.com) |
| Luno | Coming Soon | [luno.com](https://luno.com) |
## Adding more Central Exchange Integrations
WalletConnect Pay can enable additional integrations based on merchant demand and expected volume. If your business needs a specific integration not listed above, [contact us](https://share.hsforms.com/19Dpp4ayYR9uriB3xNAh0JAnxw6s) and we will work with you to enable it.
# Merchant API Reference
Source: https://docs.walletconnect.com/payments/ecommerce/api-reference
API reference for the WalletConnect Pay Merchant API used in ecommerce checkout integrations.
## Authentication
Every request to the Merchant API requires the following headers:
| Header | Required | Description |
| -------------- | ----------- | -------------------------------------------------------------------------------- |
| `Content-Type` | Conditional | Must be `application/json` for requests with a JSON body |
| `Api-Key` | Yes | Partner API key from the [Merchant Dashboard](/payments/merchant/onboarding) |
| `Merchant-Id` | Yes | Merchant identifier from the [Merchant Dashboard](/payments/merchant/onboarding) |
Keep your `Api-Key` secret. Never expose it in client-side code — all API calls should be made from your backend server.
## Base URLs
| Environment | URL |
| ----------- | ------------------------------------------- |
| Production | `https://api.pay.walletconnect.com` |
| Staging | `https://staging.api.pay.walletconnect.com` |
***
## Create Payment
Creates a new payment for the buyer to complete on the WalletConnect Pay checkout portal.
```
POST /v1/merchant/payment
```
### Request Body
| Field | Type | Required | Description |
| --------------------- | ------ | -------- | -------------------------------------------------------------- |
| `amount` | object | Yes | The payment amount |
| `amount.unit` | string | Yes | Currency in `iso4217/{CODE}` format (e.g., `iso4217/USD`) |
| `amount.value` | string | Yes | Amount in minor units as a string (e.g., `"5000"` for \$50.00) |
| `referenceId` | string | Yes | Your internal order or reference identifier |
| `checkout` | object | No | Checkout redirect configuration |
| `checkout.successUrl` | string | No | HTTPS URL to redirect the buyer after a successful payment |
| `checkout.errorUrl` | string | No | HTTPS URL to redirect the buyer after a failed payment |
The `amount.value` is expressed in the currency's **minor units**. For USD, `"5000"` equals \$50.00 (5000 cents). For JPY (which has no minor unit), `"5000"` equals ¥5,000.
Both `successUrl` and `errorUrl` must be valid HTTPS URLs. If either is missing or invalid, the redirect feature is disabled entirely for that payment — the checkout portal will show generic terminal views instead.
### Request Example
```json theme={null}
{
"amount": {
"unit": "iso4217/USD",
"value": "5000"
},
"referenceId": "order_abc123",
"checkout": {
"successUrl": "https://yourstore.com/order/abc123/success",
"errorUrl": "https://yourstore.com/order/abc123/failed"
}
}
```
### Response
| Field | Type | Description |
| ------------ | ------- | ---------------------------------------------------------------------------- |
| `paymentId` | string | Unique payment identifier (e.g., `pay_xxx`) |
| `gatewayUrl` | string | URL to the checkout portal for this payment — use this to redirect the buyer |
| `status` | string | Initial payment status (typically `pending`) |
| `expiresAt` | integer | Unix timestamp (seconds) when the payment expires |
| `isFinal` | boolean | Whether the payment is already in a terminal state |
| `pollInMs` | integer | Suggested polling interval in milliseconds for status checks |
### Response Example
```json theme={null}
{
"paymentId": "pay_abc123def456",
"gatewayUrl": "https://pay.walletconnect.com/?pid=pay_abc123def456",
"status": "pending",
"expiresAt": 1709831400,
"isFinal": false,
"pollInMs": 1000
}
```
### Code Examples
```bash cURL theme={null}
curl -X POST https://api.pay.walletconnect.com/v1/merchant/payment \
-H "Content-Type: application/json" \
-H "Api-Key: YOUR_API_KEY" \
-H "Merchant-Id: YOUR_MERCHANT_ID" \
-d '{
"amount": {
"unit": "iso4217/USD",
"value": "5000"
},
"referenceId": "order_abc123",
"checkout": {
"successUrl": "https://yourstore.com/order/abc123/success",
"errorUrl": "https://yourstore.com/order/abc123/failed"
}
}'
```
```typescript TypeScript theme={null}
const response = await fetch(
"https://api.pay.walletconnect.com/v1/merchant/payment",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Api-Key": process.env.WCP_API_KEY,
"Merchant-Id": process.env.WCP_MERCHANT_ID,
},
body: JSON.stringify({
amount: {
unit: "iso4217/USD",
value: String(order.totalCents),
},
referenceId: order.id,
checkout: {
successUrl: `${process.env.BASE_URL}/order/${order.id}/success`,
errorUrl: `${process.env.BASE_URL}/order/${order.id}/failed`,
},
}),
}
);
const { paymentId, gatewayUrl } = await response.json();
```
### Error Responses
| Status Code | Description |
| ----------- | -------------------------------------------------- |
| `400` | Invalid request — check required fields and format |
| `401` | Invalid or missing API key |
| `404` | Merchant not found for the given `Merchant-Id` |
| `500` | Internal server error |
***
## Verify Payment Status
After the buyer completes (or abandons) the checkout, verify the payment status from your backend.
```
GET /v1/merchant/payment/{id}/status
```
### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | -------------------------------------------------- |
| `id` | string | The `paymentId` returned when creating the payment |
### Headers
| Header | Required | Description |
| ------------- | -------- | ------------------- |
| `Api-Key` | Yes | Partner API key |
| `Merchant-Id` | Yes | Merchant identifier |
### Response
| Field | Type | Description |
| --------- | ------- | ------------------------------------------------ |
| `status` | string | Payment status (see below) |
| `isFinal` | boolean | Whether the payment has reached a terminal state |
### Payment Statuses
| Status | Terminal | Description |
| ------------ | -------- | ----------------------------------------------------- |
| `pending` | No | Payment created, waiting for buyer action |
| `processing` | No | Transaction submitted, awaiting on-chain confirmation |
| `succeeded` | Yes | Payment completed successfully |
| `failed` | Yes | Payment failed or was rejected |
| `expired` | Yes | Payment window closed without completion |
### Code Example
```bash cURL theme={null}
curl https://api.pay.walletconnect.com/v1/merchant/payment/pay_abc123def456/status \
-H "Api-Key: YOUR_API_KEY" \
-H "Merchant-Id: YOUR_MERCHANT_ID"
```
```typescript TypeScript theme={null}
async function verifyPaymentStatus(paymentId: string): Promise {
const response = await fetch(
`https://api.pay.walletconnect.com/v1/merchant/payment/${paymentId}/status`,
{
headers: {
"Api-Key": process.env.WCP_API_KEY,
"Merchant-Id": process.env.WCP_MERCHANT_ID,
},
}
);
const { status, isFinal } = await response.json();
return status;
}
```
# Online Checkout Integration Guide
Source: https://docs.walletconnect.com/payments/ecommerce/integration
Step-by-step guide to integrate WalletConnect Pay into your ecommerce checkout flow.
Integrate WalletConnect Pay into your online checkout so buyers can pay with crypto from any wallet, using the assets they already hold.
## Prerequisites
Before you begin, make sure you have:
* **Completed merchant onboarding** — sign up and complete KYB verification on the [Merchant Dashboard](/payments/merchant/onboarding)
* **API Key** — available in your Merchant Dashboard after onboarding (used as the `Api-Key` header)
* **Merchant ID** — your merchant identifier from the Merchant Dashboard
* **A backend server** — all API calls must be made server-side to keep your API Key secure
## How It Works
The checkout integration follows a redirect-based flow. Your backend creates a payment, redirects the buyer to the WalletConnect Pay checkout portal, and verifies the result after the buyer returns.
```mermaid theme={null}
sequenceDiagram
participant B as Buyer
participant MF as Merchant Frontend
participant MB as Merchant Backend
participant API as WalletConnect Pay API
participant BX as Checkout Portal
B->>MF: Click "Pay with Crypto"
MF->>MB: Create payment request
MB->>API: POST /v1/merchant/payment
API-->>MB: { paymentId, gatewayUrl }
MB-->>MF: gatewayUrl
MF->>BX: Redirect buyer to checkout portal
Note over BX,B: Buyer connects wallet, selects payment option, and signs the transaction
alt Payment succeeds
BX->>MF: Redirect to successUrl?payment_id={paymentId}
else Payment fails
BX->>MF: Redirect to errorUrl?payment_id={paymentId}
end
MF->>MB: Verify payment status
MB->>API: GET /v1/merchant/payment/{paymentId}/status
API-->>MB: { status: "succeeded" }
MB-->>MF: Order confirmed
MF->>B: Show order confirmation
```
## Integration Steps
From your backend, call the Merchant API to create a payment with the order amount and redirect URLs.
```typescript theme={null}
// Server-side only
const response = await fetch(
"https://api.pay.walletconnect.com/v1/merchant/payment",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Api-Key": process.env.WCP_API_KEY,
"Merchant-Id": process.env.WCP_MERCHANT_ID,
},
body: JSON.stringify({
amount: {
unit: "iso4217/USD",
value: String(order.totalCents), // e.g., "5000" for $50.00
},
referenceId: order.id,
checkout: {
successUrl: `${process.env.BASE_URL}/order/${order.id}/success`,
errorUrl: `${process.env.BASE_URL}/order/${order.id}/failed`,
},
}),
}
);
const { paymentId, gatewayUrl } = await response.json();
```
Store the `paymentId` in your database alongside the order so you can verify the payment later.
The `amount.value` is in **minor currency units** — for USD, `"5000"` equals \$50.00. See the [API Reference](/payments/ecommerce/api-reference) for details.
Redirect the buyer to the `gatewayUrl` returned by the API. This takes them to the WalletConnect Pay checkout portal where they can connect their wallet, choose a payment option, and complete the transaction.
```typescript theme={null}
// Client-side: redirect to checkout portal
window.location.href = gatewayUrl;
```
The checkout portal handles the entire buyer-side payment flow — no additional integration is needed on your end for this step.
After the payment completes (or fails), the checkout portal redirects the buyer back to your site:
* **Success**: `{successUrl}?payment_id={paymentId}`
* **Failure**: `{errorUrl}?payment_id={paymentId}`
The redirect happens automatically after a 3-second countdown on the checkout portal.
```typescript theme={null}
const url = new URL(window.location.href);
const paymentId = url.searchParams.get("payment_id");
if (!paymentId) {
throw new Error("Missing payment_id in redirect URL");
}
const status = await verifyPayment(paymentId);
```
**Never trust the redirect URL alone.** Always look up the expected `paymentId` for the order from your own database and verify it matches the redirect parameter. Verify the payment status server-side before fulfilling any order.
From your backend, call the status endpoint to get the authoritative payment result.
```typescript theme={null}
const order = await getOrderByPaymentId(paymentId);
const response = await fetch(
`https://api.pay.walletconnect.com/v1/merchant/payment/${paymentId}/status`,
{
headers: {
"Api-Key": process.env.WCP_API_KEY,
"Merchant-Id": process.env.WCP_MERCHANT_ID,
},
}
);
const { status, isFinal } = await response.json();
if (status === "succeeded") {
await fulfillOrder(order.id);
} else if (status === "processing") {
// Transaction submitted but not yet confirmed — check again shortly
} else {
// "failed" or "expired" — inform the buyer
}
```
If `isFinal` is `false` (status is `processing`), poll the endpoint at a reasonable interval until the payment reaches a terminal state.
| Status | Terminal | Action |
| ------------ | -------- | -------------------------------------- |
| `succeeded` | Yes | Fulfill the order |
| `failed` | Yes | Show error, offer retry |
| `expired` | Yes | Show expiry message, offer new payment |
| `processing` | No | Poll again after a short delay |
See the full [API Reference](/payments/ecommerce/api-reference) for endpoint details and error codes.
## Checkout URL Requirements
Both `successUrl` and `errorUrl` must be valid **HTTPS** URLs. If either is missing or fails validation, the redirect feature is disabled for that payment — the checkout portal will show a generic success or error message instead.
* The checkout portal appends `?payment_id={id}` to your callback URLs automatically
* There is no `cancelUrl` — if the buyer abandons the flow, they simply close the tab
* Include your order reference in the URL path (e.g., `/order/{orderId}/success`) so you can correlate the redirect with the right order
## Merchant Branding
The checkout portal displays your merchant name and icon to the buyer during the payment flow. These are configured in the [Merchant Dashboard](/payments/merchant/onboarding):
* **`merchant.name`** — displayed in the payment summary
* **`merchant.iconUrl`** — displayed alongside your name
For best results, use a square icon with a minimum size of 72x72px in PNG, SVG, or WebP format.
## Testing
Use the staging environment to test your integration before going live:
| Environment | API Base URL |
| ----------- | ------------------------------------------- |
| Production | `https://api.pay.walletconnect.com` |
| Staging | `https://staging.api.pay.walletconnect.com` |
Contact the WalletConnect team to obtain staging credentials.
## Example Implementation
A complete working reference implementation is available in the WalletConnect buyer-experience repository:
Try the checkout flow end-to-end with a live demo store.
Full-stack example showing payment creation, checkout redirect, and order verification.
The example includes:
* **API client** — payment creation with checkout URLs
* **Checkout route** — creates a payment and redirects the buyer
* **Order confirmation page** — receives the redirect and displays order status
# WalletConnect Pay for Ecommerce and Online Checkout
Source: https://docs.walletconnect.com/payments/ecommerce/overview
Add crypto and stablecoin payments to your online checkout with a single API integration.
WalletConnect Pay enables you to add crypto and stablecoin payments to your online checkout with a single integration, offering a familiar, wallet-based experience for customers worldwide.
Before integrating, complete [merchant onboarding](/payments/merchant/onboarding) to obtain your API Key and Merchant ID.
## How It Works
Your backend creates a payment via a single API call and redirects the buyer to the WalletConnect Pay checkout portal. The buyer connects their wallet, selects a payment option, and completes the transaction. After payment, the buyer is redirected back to your site and you verify the result server-side.
1. Your backend calls `POST /v1/merchant/payment` with the order amount and redirect URLs
2. You redirect the buyer to the checkout portal using the `gatewayUrl` from the response
3. The buyer connects their wallet, picks a token and network, and signs the transaction
4. The checkout portal redirects the buyer back to your `successUrl` or `errorUrl`
5. Your backend verifies the payment status via `GET /v1/merchant/payment/{id}/status`
## What does WalletConnect Pay offer for ecommerce?
Let customers pay online using the wallets and assets they already use, all through a single, consistent checkout experience.
WalletConnect Pay is designed to fit cleanly into existing checkout and operations workflows. It can align with PSP compliance requirements (like required information capture and screening steps) and follows payment patterns customers already recognize and trust.
WalletConnect Pay can provide rewards that give customers a reason to choose WalletConnect Pay at checkout—supporting adoption with no added effort from merchants or PSPs.
Support higher-value online transactions, reduce acceptance costs, and settle faster with flexible crypto or fiat payouts.
Integrate WalletConnect Pay to let your users pay across POS and ecommerce. You keep custody and control of the experience—while WalletConnect Pay handles PSP compatibility behind the scenes.
## Get Started
Step-by-step guide to integrate WalletConnect Pay checkout into your site.
Merchant API endpoint reference for creating and verifying payments.
Set up your merchant account and obtain your API credentials.
Try the checkout flow end-to-end with a live demo store.
Full-stack Next.js reference implementation.
# Fiat Coverage
Source: https://docs.walletconnect.com/payments/fiat-coverage
Fiat currencies supported by WalletConnect Pay for offramp settlement, with detail on how additional corridors are enabled.
This page outlines the fiat currencies WalletConnect Pay currently supports for **settlement via offramp**. When a merchant or customer is configured to receive fiat instead of crypto, accepted crypto is converted and paid out in one of the currencies listed below.
For the tokens and chains that can be accepted from the buyer (and which combinations settle to fiat versus direct crypto), see [Token and Chain Coverage](/payments/token-and-chain-coverage).
## Supported fiat currencies
| Currency | ISO Code | Region |
| ------------- | -------- | ------ |
| US Dollar | USD | Global |
| Euro | EUR | Europe |
| British Pound | GBP | UK |
## Coming soon
The following currencies are planned and can be enabled for merchants on request, subject to expected volume.
| Currency | ISO Code | Region |
| ------------------ | -------- | ------------ |
| UAE Dirham | AED | UAE |
| Argentine Peso | ARS | Argentina |
| Australian Dollar | AUD | Australia |
| Bangladeshi Taka | BDT | Bangladesh |
| Brazilian Real | BRL | Brazil |
| Canadian Dollar | CAD | Canada |
| Chilean Peso | CLP | Chile |
| Colombian Peso | COP | Colombia |
| Costa Rican Colón | CRC | Costa Rica |
| Ghanaian Cedi | GHS | Ghana |
| Hong Kong Dollar | HKD | Hong Kong |
| Indonesian Rupiah | IDR | Indonesia |
| Israeli Shekel | ILS | Israel |
| Indian Rupee | INR | India |
| Japanese Yen | JPY | Japan |
| Kenyan Shilling | KES | Kenya |
| South Korean Won | KRW | South Korea |
| Mexican Peso | MXN | Mexico |
| Malaysian Ringgit | MYR | Malaysia |
| Nigerian Naira | NGN | Nigeria |
| Nepalese Rupee | NPR | Nepal |
| Philippine Peso | PHP | Philippines |
| Pakistani Rupee | PKR | Pakistan |
| Singapore Dollar | SGD | Singapore |
| Thai Baht | THB | Thailand |
| Turkish Lira | TRY | Turkey |
| South African Rand | ZAR | South Africa |
## Adding more currencies
WalletConnect Pay can enable additional fiat corridors based on merchant demand and expected volume. If your business needs a currency not listed above — for example PLN, CHF, SEK, NOK, DKK, RON, or others — [contact us](https://share.hsforms.com/19Dpp4ayYR9uriB3xNAh0JAnxw6s) with your expected monthly volume and target markets, and we will work with you to enable it.
Fiat coverage will continue to expand as additional offramp corridors are activated and as new partner relationships are integrated.
# How to Pay with WalletConnect Pay
Source: https://docs.walletconnect.com/payments/for-users
WalletConnect Pay lets you pay with crypto from any wallet, any asset, and on any network.
## What you’ll see at checkout
When a store supports WalletConnect Pay, you’ll typically see a **“Pay with Crypto”** option and one of the following:
* **A WalletConnect QR code** shown on the POS device. You can use any wallet that supports WalletConnect Pay to scan the QR code directly from within the wallet and make the payment.
* **A QR code you can scan with your phone camera**, which opens a payment page on your mobile browser where you can connect your wallet with WalletConnect and make the payment.
## How paying works
1. Scan the QR code with a mobile wallet that supports WalletConnect Pay
1. Or scan the QR code with your mobile camera to open the payment page on your mobile browser and connect your wallet.
2. Your wallet shows the **payment intent** (what you’re paying, how much, and where it’s going).
3. Choose the **token** and **network** you want to use (for example, USDC on Ethereum).
4. Approve the payment in your wallet.
5. Wait for the transaction result and confirmation.
## What to double-check before you approve
* **Amount and currency**: make sure it matches what you expect.
* **Network and fees**: fees and confirmation times depend on the network you choose.
* **Where you’re sending funds**: only approve if the payment details look right.
## Your security
WalletConnect Pay never requires your seed phrase. If any page or app asks for it, don’t proceed.
# Merchant logo specification
Source: https://docs.walletconnect.com/payments/merchant-api/logo-specification
Required dimensions, formats, and file size for the iconUrl field on the Merchant API.
The `iconUrl` field on the Merchant API accepts a public HTTPS URL pointing to a merchant logo. The logo renders at the moment of payment, so dimensions and format are a direct trust signal. A stretched or pixelated logo undermines buyer confidence at the exact step where confidence matters most.
This guide covers the recommended specification for the `iconUrl` field used by [`POST /v1/merchants`](/api-reference/2026-02-18/post-v1-merchants) (create) and [`PATCH /v1/merchants/{merchantId}`](/api-reference/2026-02-18/patch-v1-merchants-merchantid) (update).
## Where the logo appears
The merchant logo is surfaced at the two main moments of the buyer's payment flow:
### Wallet payment screen
When the buyer scans the WalletConnect QR code, the wallet opens a payment screen with the merchant logo as the primary visual anchor above the amount and selected payment method.
Logos and brands shown are for illustrative purposes only and do not imply affiliation or endorsement.
### Buyer checkout
On the WalletConnect Pay buyer checkout page (opened when a buyer scans the payment QR with a generic camera or QR scanner), the merchant logo anchors the "Pay X to \" headline above the payment options.
Logos and brands shown are for illustrative purposes only and do not imply affiliation or endorsement.
Because the logo renders inside different container shapes across surfaces (square cards on web, rounded squares in wallets), the asset needs to work across all of them. The specification below is designed for that.
## Specification
| Field | Value |
| ---------------------- | --------------------------- |
| Aspect ratio | 1:1 (square), ±5% tolerance |
| Minimum dimensions | 500 × 500 px |
| Recommended dimensions | 1024 × 1024 px |
| Maximum dimensions | 4096 × 4096 px |
| Accepted formats | JPEG, PNG, WebP, AVIF |
| Max file size | 2 MB |
| Transparency | Allowed in PNG and WebP |
### Format guidance
* **PNG** or **WebP** for logos with transparent backgrounds.
* **JPEG** for flat photographic logos with no transparency.
* **AVIF** if your asset pipeline already produces it; otherwise prefer PNG.
* **1024 × 1024 px** is the recommended size: it gives retina headroom across surfaces while staying well under the 2 MB cap.
### Why square
Logos render inside square and rounded-square containers across surfaces. Non-square images get center-cropped, and the result is unpredictable: a tall rectangle that looks fine in one wallet may have its bottom half clipped in another. Square assets with the ±5% tolerance render consistently everywhere.
Create a merchant with a logo hosted on your own CDN:
```http theme={null}
POST /v1/merchants HTTP/1.1
Host: api.pay.walletconnect.com
Authorization: Bearer
Idempotency-Key: 9f1c2e8a-3b6d-4e2a-b1d4-2f0a7c5e8e11
Content-Type: application/json
{
"merchantName": "Acme Store",
"merchantEmail": "billing@acme.store",
"iconUrl": "https://cdn.acme.store/brand/logo-1024.png"
}
```
```http theme={null}
HTTP/1.1 201 Created
Content-Type: application/json
{
"merchant": {
"id": "mrch_7kBz2qR9xPvLmN4Yw",
"name": "Acme Store",
"email": "billing@acme.store",
"status": "active",
"createdAt": "2026-05-14T10:30:00.000Z"
}
}
```
The URL must be publicly reachable over HTTPS at the time of the call. To change the logo later, call `PATCH /v1/merchants/{merchantId}` with a new `iconUrl`. Simply swapping the asset at your CDN URL will not update what buyers see at checkout.
# Quickstart
Source: https://docs.walletconnect.com/payments/merchant/quickstart
Go from zero to your first payment with the WalletConnect Pay Merchant API in six steps.
This guide takes you from zero to a live payment in six steps:
1. **Create an API key** in the dashboard.
2. **Authenticate** your requests with the key.
3. **Create your first merchant** via `POST /v1/merchants`.
4. **Configure crypto settlement** via `POST /v1/merchants/{merchantId}/settlements/crypto`.
5. **Create your first payment** via `POST /v1/payments`.
6. **Check the payment status** via `GET /v1/payments/{id}/status`.
Don't have access to the dashboard yet? [Contact sales](https://share.hsforms.com/19Dpp4ayYR9uriB3xNAh0JAnxw6s) to get started.
1. Open [**API Keys**](https://merchant.pay.walletconnect.com/en/api-keys) in the dashboard and sign in if prompted.
2. Click **Create key**. Copy the key — it is shown only once.
3. Store it in a secret manager. Anyone with this key can act on behalf of your account.
Every request to the Merchant API uses the `Api-Key` header.
```bash theme={null}
export WCP_API_KEY="wcp_…" # the key you just created
export WCP_BASE="https://api.pay.walletconnect.com"
```
A quick sanity check — list your merchants:
```bash theme={null}
curl "$WCP_BASE/v1/merchants" \
-H "Api-Key: $WCP_API_KEY"
```
A `200 OK` with a (possibly empty) `data` array confirms the key works. A `401` means the key is wrong; a `403` means the key is valid but lacks permission for this account.
Pin requests to a specific API version with the `WCP-Version` header (for example, `WCP-Version: 2026-02-18`). Without it, your account's default version is used. See [Versioning](/api-reference/versioning) for the full policy.
A **merchant** is the entity that receives payments. One account can hold many merchants — typically one per brand, storefront, or legal entity.
```bash theme={null}
curl -X POST "$WCP_BASE/v1/merchants" \
-H "Api-Key: $WCP_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"merchantName": "Acme Store",
"merchantEmail": "billing@acme.store"
}'
```
The response includes the merchant `id` — save it, you'll need it for every payment.
```json theme={null}
{
"merchant": {
"id": "mrch_7kBz2qR9xPvLmN4Yw",
"name": "Acme Store",
"email": "billing@acme.store",
"status": "active",
"createdAt": "2026-02-18T10:30:00.000Z"
}
}
```
```bash theme={null}
export WCP_MERCHANT_ID="mrch_7kBz2qR9xPvLmN4Yw"
```
`Idempotency-Key` is required on this endpoint. Use a fresh UUID per merchant — replays with the same key return the original result instead of creating a duplicate.
See the full schema and field reference in [Create a merchant](/api-reference/latest/post-v1-merchants).
Before you can accept payments, tell us where to deliver settled funds. Each settlement entry pairs a [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) `asset` (the token you want to settle in) with a [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) `destination` wallet on the same chain.
```bash theme={null}
curl -X POST "$WCP_BASE/v1/merchants/$WCP_MERCHANT_ID/settlements/crypto" \
-H "Api-Key: $WCP_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"settlements": [
{
"asset": "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"destination": "eip155:8453:0x1234567890abcdef1234567890abcdef12345678"
}
]
}'
```
The example above registers USDC on Base. For the full list of supported `asset` values and the chains we settle to, see [Token & Chain Coverage](/payments/token-and-chain-coverage).
Each `(merchant, asset)` pair must be unique — registering the same asset twice returns `settlement_asset_conflict`. To change a destination, use [Update a crypto settlement](/api-reference/latest/put-v1-merchants-merchantid-settlements-crypto-id) instead.
See the full schema in [Create crypto settlements](/api-reference/latest/post-v1-merchants-merchantid-settlements-crypto).
A **payment** is a request for funds against one of your merchants. You pass the merchant via the `Merchant-Id` header and your own order identifier as `referenceId`.
```bash theme={null}
curl -X POST "$WCP_BASE/v1/payments" \
-H "Api-Key: $WCP_API_KEY" \
-H "Merchant-Id: $WCP_MERCHANT_ID" \
-H "Content-Type: application/json" \
-d '{
"referenceId": "ORDER-123",
"amount": {
"unit": "iso4217/USD",
"value": "100"
}
}'
```
The response includes the payment `id` and a link your buyer can open in any WalletConnect-compatible wallet. Hand that link to your checkout, share it directly, or render it as a QR code.
```bash theme={null}
export WCP_PAYMENT_ID="pay_…" # from the response above
```
Poll the status endpoint to know when the payment has completed:
```bash theme={null}
curl "$WCP_BASE/v1/payments/$WCP_PAYMENT_ID/status" \
-H "Api-Key: $WCP_API_KEY" \
-H "Merchant-Id: $WCP_MERCHANT_ID"
```
```json theme={null}
{
"status": "succeeded",
"isFinal": true,
"pollInMs": null,
"info": {
"txId": "0xabc123…",
"optionAmount": {
"unit": "caip19/eip155:8453/erc20:0x...",
"value": "1000000"
}
}
}
```
Use `isFinal` to decide when to stop polling. While the payment is still in flight, the response returns `isFinal: false` and a `pollInMs` hint for how long to wait before polling again. Once `isFinal` is `true`, `status` is one of `succeeded`, `failed`, `cancelled`, or `expired` and `pollInMs` is `null`.
Prefer webhooks for production — they remove the need to poll. The status endpoint is the right tool for one-off checks, scripts, and debugging.
See the full response schema in [Get the payment status](/api-reference/latest/get-v1-payments-id-status).
# WalletConnect Pay
Source: https://docs.walletconnect.com/payments/overview
Digital payments across blockchains remain inconsistent. Payments are fragmented by geography and Rail. For example, a few countries in Latin America run on PIX and Mercado Pago, Europe on SEPA and cards, Southeast Asia on QR wallets like GrabPay and GCash, China on Alipay and WeChat Pay, India on UPI.
Each region invented its own system, none of them interoperate, and merchants and PSPs are forced to support whatever their local stack dictates. Users adapt to the rail in front of them rather than the method they prefer.
In the world of Crypto payments, each wallet, merchant, and provider implements its own integration model. This fragmentation slows innovation and limits interoperability.
To address this, WalletConnect introduces the **WalletConnect Pay**.
**WalletConnect Pay** provides a complete end-to-end crypto payment solution that fits directly into an existing Payment Service Provider (PSP) stack, allowing crypto payments to be offered without changing how payments operate today.
It also aligns with common **payment compliance requirements**, such as required customer and transaction information capture and screening steps, so providers can keep the same operational and compliance controls they rely on today.
## Who is WalletConnect Pay for?
There are a couple of primary actors that can benefit from WalletConnect Pay:
* **Payment Service Providers (PSPs) and Acquirers** - PSPs and Acquirers can integrate WalletConnect Pay into their existing payment stack, allowing them to offer their merchants and customers an option to accept crypto payments without changing how payments operate today.
* **Merchants** - PSPs and Acquirers can offer their merchants an option to accept crypto payments without changing how payments operate today.
* **Wallets** - Wallets can work closely with WalletConnect to provide a seamless payment experience for their users by integrating the WalletConnect Pay SDK into their wallet.
Are you a PSP or Acquirer? Fill out this form to learn how you can integrate WalletConnect Pay.
Are you a Wallet or Pay Provider? Fill out this form to learn how you can integrate WalletConnect Pay.
## What would WalletConnect Pay look like in action?
1. The merchant presents a WalletConnect QR code with the payment intent.
2. The customer scans the QR with their wallet:
* The wallet displays the payment intent.
* The customer picks the token and network of their choice (e.g., USDC on Ethereum) and approves the payment.
* The wallet builds the transaction and sends it to the network.
3. Or alternatively, the customer scans the QR code with their QR scanner:
* This takes the customer to a payment gateway URL that is pre-filled with the payment intent.
* The customer can connect their wallet (using WalletConnect).
* The wallet displays the payment intent.
* The customer picks the token and network of their choice (e.g., USDC on Ethereum) and approves the payment.
* The wallet builds the transaction and sends it to the network.
4. The merchant receives the transaction result and the customer is notified.
5. The merchant then receives fiat or crypto settlement from the PSP.
## What does WalletConnect Pay offer?
Buyers can pay from any wallet at online checkout and in-person.
Balance checks, routing, and compliance (screening, KYC/KYT) are completed before a payment is confirmed.
Fixed, transparent fees, consistent settlement paths (crypto or fiat), in-flow swaps and bridges, and more.
Full audit trails and data visibility for operations, support, and reporting.
Clear documentation, reference implementations, and operational dashboards aligned with traditional payment workflows.
## What else does WalletConnect Pay offer?
WalletConnect Pay is designed to fit into existing checkout workflows so crypto payments can feel like a natural extension of how payments already run today. WalletConnect Pay is also available as a compliant e-commerce (checkout) solution.
To learn more about what’s available and what’s coming next, contact us by filling out the form below:
Are you a Payment Method Aggregators or an ecommerce platform? Contact us to learn more.
## Get Started
Integrate WalletConnect Pay into your wallet.
Add crypto payments to your online checkout.
# How it works
Source: https://docs.walletconnect.com/payments/psps/headless-sdk/how-it-works
Before you install anything, it helps to understand the shape of the SDK: the stages a payment moves through, how the runtime plugs into your app, and why your Engine API key never reaches the browser.
## The payment lifecycle
Whatever UI you build, the runtime moves a payment through the same stages. Your job is to render each stage and call the matching action.
The buyer arrives with a payment ID (from a QR code, link, or your checkout). The runtime fetches the payment intent — amount, merchant, accepted tokens.
The buyer connects a wallet through the `WalletProvider` seam. The runtime reads their accounts across the supported networks.
Given the connected accounts, the Engine returns the concrete ways to pay — token, network, amount, fees, and whether compliance data is required.
The buyer picks an option. The runtime builds the transaction(s) and the exact wallet-RPC actions to sign.
The `Signer` drives the wallet through the required signatures (e.g. a permit + the payment).
Signed results are submitted to confirm the payment. The runtime then polls status until the payment succeeds, fails, or expires.
## How it fits together
The Headless SDK follows a **headless runtime + host** model. The payment flow lives entirely in the SDK; your application is the **host** that consumes it. The runtime reaches the outside world only through five injectable **seams** — so you can swap in your own transport, wallet, timing, and analytics, and reuse the exact same payment machine.
```mermaid theme={null}
flowchart TD
Host["Your gateway — the host your UI, branding, routing"]
React["@walletconnect/pay-react usePaymentSession"]
State["@walletconnect/pay-state payment runtime"]
Host -->|consumes| React
React -->|drives| State
State -->|reaches the outside world only through seams| Seams
subgraph Seams["Injectable seams"]
direction LR
Transport["Transport"]
Wallet["WalletProvider"]
Signer["Signer"]
Clock["Clock"]
Telemetry["Telemetry"]
end
Transport --> T2["your server → Engine API"]
Wallet --> W2["AppKit / your wallet"]
Signer --> S2["signing strategy"]
Clock --> C2["timers / polling"]
Telemetry --> A2["analytics"]
```
The five seams the runtime depends on:
| Seam | What it abstracts | Provided by |
| ---------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `Transport` | Engine HTTP calls | `pay-core` `createHttpTransport` → your server route |
| `WalletProvider` | connect / accounts / provider / switch | `pay-appkit`, or your own wallet integration |
| `Signer` | sign a payment option's wallet-RPC actions | `pay-appkit` `createAppKitSigner` — one call (wraps `pay-state`'s signing; drop to its raw strategies only for a custom, non-AppKit wallet) |
| `Clock` | intervals + page visibility (for polling) | browser timers (the SDK ships a default) |
| `Telemetry` | analytics breadcrumbs | your analytics pipeline (optional) |
`@walletconnect/pay-state` ships browser-ready `Clock` and `Telemetry` defaults (`browserClock`, `noopTelemetry`). In practice a React/Next.js gateway wires just the `Transport` (pointed at your server route); the `WalletProvider` and `Signer` come ready-made from `pay-appkit` (`` + `createAppKitSigner`), and it reuses `browserClock` for the rest. `Telemetry` is optional.
## The Engine API key never reaches the browser
WalletConnect Pay's Engine API is authenticated with a secret API key that **must stay server-side**. The SDK enforces this split: the browser talks to *your* server, and your server talks to the Engine.
```mermaid theme={null}
flowchart LR
Browser["Browser Transport seam (no secret)"]
Server["Your server route createEngineClient (holds the API key)"]
Engine["WalletConnect Pay Engine API"]
Browser -->|"fetch /api/wcp/payment/:id/*"| Server
Server -->|"Api-Key header"| Engine
```
`pay-core` exposes two entry points for exactly this: `createHttpTransport` for the browser (talks to *your* server) and `createEngineClient` (imported from `@walletconnect/pay-core/server`) which holds the API key and talks to the Engine. In a Next.js app, your Route Handler sits in the middle.
## Next steps
Build a complete checkout in React / Next.js, step by step.
The public API of pay-core, pay-state, pay-react, and pay-appkit.
# Implementation
Source: https://docs.walletconnect.com/payments/psps/headless-sdk/implementation
This page walks through building a complete checkout on the Headless SDK, with **React / Next.js** and **JavaScript** examples side by side. New to the SDK? Read [How it works](/payments/psps/headless-sdk/how-it-works) first for the architecture and the role of each seam.
Wallet connection is now **zero-config**: the SDK owns the entire Reown AppKit setup. You install only the `@walletconnect/pay-*` packages and never touch `@reown/*`, `wagmi`, or `viem` directly.
You build three things:
1. A **server proxy** — routes that forward to the Engine with your secret key.
2. A **browser transport** — points the runtime at those routes.
3. The **AppKit provider** — one component (`` in React) or one factory call (`createPayAppKit` in JavaScript).
The wallet seam, the signer, and the clock all come from the SDK. Then `usePaymentSession` (React) or `createPaymentController` (JavaScript) ties everything together and gives you a snapshot to render.
The browser never holds the Engine API key. It talks to *your* server, and your server talks to the WalletConnect Pay Engine — see [The Engine API key never reaches the browser](/payments/psps/headless-sdk/how-it-works#the-engine-api-key-never-reaches-the-browser).
## Prerequisites
* **Node 18+**. The React example uses **Next.js** (App Router); the JavaScript example is framework-neutral.
* A **Reown Project ID** — create one at [dashboard.reown.com](https://dashboard.reown.com). Enable the **headless** feature on the project.
* A **WalletConnect Pay Gateway API key** for the Engine (server-side). [Talk to us](https://share.hsforms.com/1XsMCkUxFT2Cte8SCeAh89wnxw6s) to get onboarded.
## Install
Install only the Headless SDK. Wallet connectivity (`@reown/appkit`, `wagmi`, `viem`, `@solana/web3.js`, `@tanstack/react-query`) comes transitively through `@walletconnect/pay-appkit` — you don't add or configure any of it.
```bash theme={null}
npm install @walletconnect/pay-core @walletconnect/pay-state \
@walletconnect/pay-appkit @walletconnect/pay-react
```
`@walletconnect/pay-react` is the React hook binding — omit it if you're not using React.
## Step 1 — Server proxy (keep the API key server-side)
Create a server-only module that constructs the Engine client once and forwards calls. The key comes from server env and never ships to the browser. This is framework-agnostic — any server works; the example uses Next.js Route Handlers.
```typescript lib/server/engine.ts theme={null}
import 'server-only'
import { createEngineClient } from '@walletconnect/pay-core/server'
const client = createEngineClient({
apiUrl: process.env.WCP_API_URL ?? 'https://staging.api.pay.walletconnect.org',
apiKey: process.env.WCP_WALLET_API_KEY ?? '' // secret — server-side only
})
/** Forward a browser call to the Engine and return an EngineResponse-shaped Response. */
export async function callEngine(
path: string,
init: { method: 'GET' | 'POST'; body?: unknown }
): Promise {
const paymentId = path.split('/')[4]! // /v1/gateway/payment/:id/...
let result
if (path.endsWith('/options')) {
result = await client.getPaymentOptions(paymentId, init.body as never)
} else if (path.endsWith('/fetch')) {
result = await client.fetchOptionActions(paymentId, init.body as never)
} else if (path.endsWith('/confirm')) {
result = await client.confirmPayment(paymentId, init.body as never)
} else if (path.endsWith('/status')) {
result = await client.getPaymentStatus(paymentId)
} else {
result = await client.getPayment(paymentId)
}
return Response.json(result)
}
```
Then expose one route per Engine call under `/api/wcp/payment/[id]`. The browser transport (Step 2) calls exactly these paths:
```typescript app/api/wcp/payment/[id]/options/route.ts theme={null}
import { callEngine } from '@/lib/server/engine'
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const body = await req.json()
return callEngine(`/v1/gateway/payment/${id}/options`, { method: 'POST', body })
}
```
```typescript app/api/wcp/payment/[id]/status/route.ts theme={null}
import { callEngine } from '@/lib/server/engine'
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return callEngine(`/v1/gateway/payment/${id}/status`, { method: 'GET' })
}
```
Create the same handler for each route the transport uses:
| Route Handler | Method | Engine call |
| ------------------------------------------- | ------ | -------------------- |
| `app/api/wcp/payment/[id]/route.ts` | `GET` | `getPayment` |
| `app/api/wcp/payment/[id]/options/route.ts` | `POST` | `getPaymentOptions` |
| `app/api/wcp/payment/[id]/fetch/route.ts` | `POST` | `fetchOptionActions` |
| `app/api/wcp/payment/[id]/confirm/route.ts` | `POST` | `confirmPayment` |
| `app/api/wcp/payment/[id]/status/route.ts` | `GET` | `getPaymentStatus` |
These proxy routes are a **starting point**, not production-ready — add your own origin allowlist, rate limiting, and auth before shipping. Their only job here is to keep the Engine key off the browser.
## Step 2 — Browser transport
On the client, point the runtime at your proxy. `createHttpTransport` issues requests to `${baseUrl}/payment/:id/...`, matching the routes above.
```typescript theme={null}
import { createHttpTransport } from '@walletconnect/pay-core'
const transport = createHttpTransport({ baseUrl: '/api/wcp' })
```
That's the entire `Transport` seam. It speaks the same five methods as the server client, but routes through your origin — no key, no CORS.
## Step 3 — Set up AppKit (zero-config)
The SDK constructs the AppKit instance, the Wagmi/Solana adapters, and the WalletConnect-owned network set for you, in headless mode (no built-in modal — you render your own wallet picker). You supply only your `projectId` and `metadata`.
In **React**, render `` once near the root. It owns AppKit's client-only construction, the `WagmiProvider` + `QueryClientProvider` tree, and an SSR-safe context. In **JavaScript**, call `createPayAppKit` and `await` its async construction.
```tsx React — components/providers.tsx theme={null}
'use client'
import { PayAppKitProvider } from '@walletconnect/pay-appkit/react'
const projectId = process.env.NEXT_PUBLIC_APPKIT_PROJECT_ID ?? ''
export function Providers({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
```
```typescript JavaScript — appkit.ts theme={null}
import { createPayAppKit } from '@walletconnect/pay-appkit'
const payAppKit = createPayAppKit({
projectId: import.meta.env.VITE_APPKIT_PROJECT_ID ?? '',
metadata: {
name: 'Acme Pay',
description: 'Headless checkout',
url: window.location.origin,
icons: []
}
})
// Construction is client-only and async — await it before reading the instance.
await payAppKit.whenReady()
export const appKit = payAppKit.getInstance()
```
`` accepts an optional `queryClient` (a host with its own passes it to share one cache; omit it for a fully internal one) and optional `themeVariables` (e.g. a host font). Both `createPayAppKit` and the provider load the Reown modules through a client-only dynamic import, so AppKit's UI never enters your SSR bundle.
## Step 4 — Build the checkout
Assemble the seams and drive the session. The **wallet seam** comes from the SDK's wallet-list hook/controller, and the **signer** is a single built-in call — `createAppKitSigner(wallet)` — so you no longer wire up signing strategies by hand. `clock` is `browserClock`.
In React, `useAppKitWalletProvider` turns the AppKit instance into the `WalletProvider` seam **and** a ready-made picker controller (list, search, pagination, the pairing QR URI). Read the instance from `getPayAppKitInstance()` once `usePayAppKit().isReady` is true. In JavaScript, `createAppKitWalletList` is the framework-neutral equivalent.
```tsx React — components/checkout.tsx theme={null}
'use client'
import { createHttpTransport } from '@walletconnect/pay-core'
import { createAppKitSigner } from '@walletconnect/pay-appkit'
import {
getPayAppKitInstance,
useAppKitWalletProvider,
usePayAppKit
} from '@walletconnect/pay-appkit/react'
import { browserClock } from '@walletconnect/pay-state'
import { usePaymentSession } from '@walletconnect/pay-react'
import { useMemo } from 'react'
export function Checkout({ paymentId }: { paymentId: string }) {
// The provider constructs AppKit asynchronously; read the instance once it's ready.
const { isReady } = usePayAppKit()
const appKit = isReady ? getPayAppKitInstance() : undefined
// The wallet seam + a ready-made picker (list, search, pagination, QR URI).
const { wallet, wallets, wcUri, getWcUri } = useAppKitWalletProvider(appKit, {
wcPayUrl: typeof window !== 'undefined' ? window.location.href : undefined
})
// Assemble the runtime seams. The signer is one built-in call.
const seams = useMemo(
() => ({
transport: createHttpTransport({ baseUrl: '/api/wcp' }),
clock: browserClock,
signer: createAppKitSigner(wallet)
}),
[wallet]
)
const {
snapshot,
connectWallet,
disconnectWallet,
selectOption,
confirmSelection,
submitInfoCapture
} = usePaymentSession({ paymentId, seams, wallet })
return
{/* render per snapshot.state — see Step 5 */}
}
```
```typescript JavaScript — main.ts theme={null}
import { createHttpTransport } from '@walletconnect/pay-core'
import { createAppKitSigner, createAppKitWalletList } from '@walletconnect/pay-appkit'
import { browserClock, createPaymentController } from '@walletconnect/pay-state'
import { appKit } from './appkit'
// The framework-neutral wallet-list controller: list / search / paginate / QR URI /
// connect — and `walletList.wallet`, the seam the runtime drives.
const walletList = createAppKitWalletList(appKit, {
wcPayUrl: window.location.href
})
const wallet = walletList.wallet
const controller = createPaymentController({
paymentId,
wallet,
seams: {
transport: createHttpTransport({ baseUrl: '/api/wcp' }),
clock: browserClock,
signer: createAppKitSigner(wallet)
}
})
controller.subscribe(() => render(controller.getSnapshot()))
controller.start()
```
In React, render the checkout from a route wrapped in your providers:
```tsx app/[paymentId]/page.tsx theme={null}
import { Checkout } from '@/components/checkout'
import { Providers } from '@/components/providers'
export default async function PaymentPage({ params }: { params: Promise<{ paymentId: string }> }) {
const { paymentId } = await params
return
}
```
## Step 5 — Render the snapshot
`snapshot.state` is a single string you switch on. Each state maps to one piece of UI; the named actions advance the flow. The logic is the same for React and JavaScript — the only difference is where the snapshot comes from (`usePaymentSession` vs `controller.getSnapshot()`).
```tsx theme={null}
switch (snapshot.state) {
case 'ReadyForWallet':
// Show the QR (from getWcUri/wcUri) and a wallet picker. On pick:
return connectWallet(w, w.namespaces[0])} />
case 'ConnectingWallet':
return
case 'LoadingOptions':
return
case 'OptionsReady':
return (
selectOption(opt, rank)}
/>
)
case 'NoOptions':
return
case 'InformationCapture':
// Render snapshot.collectData.fields, then:
return
case 'OptionSelected':
case 'RequiresApproval':
return (
)
case 'AwaitingWalletApproval':
return
case 'WaitingForConfirmation':
return
case 'Succeeded':
return
case 'Failed':
case 'PaymentExpired':
case 'PaymentCancelled':
case 'InvalidPayment':
case 'SanctionedUser':
return
}
```
That's a full gateway. Connect → options → (optional KYC) → confirm → sign → settle, all driven by the runtime; you only render and call actions. Once a wallet is connected, `disconnectWallet(namespace?)` drops one namespace or all of them.
## Environment variables
```bash .env.local theme={null}
# Reown AppKit project ID — required for wallet connection / QR pairing (public)
NEXT_PUBLIC_APPKIT_PROJECT_ID=
# WalletConnect Pay Engine — server-side only, NEVER exposed to the browser
WCP_API_URL=https://staging.api.pay.walletconnect.org
WCP_WALLET_API_KEY=
```
In a Vite / non-Next.js host, expose the project ID under that toolchain's client env convention (e.g. `VITE_APPKIT_PROJECT_ID`) and keep `WCP_WALLET_API_KEY` on the server only.
## Reference apps
The full React checkout — `` + `usePaymentSession`, no `@reown/*` in the app.
The same checkout with no framework — `createPaymentController` + manual subscribe + imperative render.
## Next steps
The full public API of pay-core, pay-state, pay-react, and pay-appkit.
The Gateway and Payments endpoints behind the SDK.
# Headless SDK
Source: https://docs.walletconnect.com/payments/psps/headless-sdk/overview
The Headless SDK is a set of framework-agnostic [`@walletconnect/pay-*`](https://www.npmjs.com/org/walletconnect) packages that own the WalletConnect Pay payment flow while you own the UI. It's the exact runtime that powers our own hosted Buyer Experience — extracted so you can build a fully branded, fully owned checkout on the same engine.
You get the payment state machine, Engine API client, and wallet orchestration; you bring your own UI, branding, routing, and infrastructure.
**Beta (v0.1.x)** — The Headless SDK is under active development. Public APIs may change between minor releases until 1.0. [Talk to us](https://share.hsforms.com/1XsMCkUxFT2Cte8SCeAh89wnxw6s) before going to production.
## Who is it for?
* **Payment Service Providers & Acquirers** who want crypto checkout inside their own product, with their own design system and domain — not a redirect to a third-party page.
* **Platforms & marketplaces** embedding pay-with-crypto directly into an existing checkout flow.
* **Teams that need control** over every step of the UX: wallet selection, network/token choice, compliance prompts, success and error states.
If you just want to accept payments with the least effort, use the [hosted gateway](/payments/overview) or the [Ecommerce integration](/payments/ecommerce/overview) instead. Reach for the Headless SDK when you need to **own the experience**.
## What you get
The full payment state machine (load → connect → quote → sign → confirm → settle) as a framework-agnostic engine. No UI imposed.
A zero-dependency client for the WalletConnect Pay Engine, with the secret API key safely held server-side.
A single `usePaymentSession` hook that projects the runtime into a clean, serializable snapshot plus named actions — zero state-machine internals leak into your components.
A ready-made adapter over [Reown AppKit](https://reown.com/appkit) for wallet connection across EVM and Solana — or bring your own.
## Supported networks & tokens
The Headless SDK supports the full WalletConnect Pay token and network coverage — USDC, USDT, EURC, PYUSD and more across Ethereum, Polygon, Base, Optimism, Arbitrum (and Solana, rolling out). See [Token & chain coverage](/payments/token-and-chain-coverage) for the live list.
## Full example
The fastest way to learn the SDK is to read the reference checkout it's extracted from. Every snippet in these docs comes from it.
A complete, branded Next.js checkout built on the four packages.
## Next steps
The payment lifecycle, the host + seams model, and how the API key stays server-side.
Build a complete checkout in React / Next.js, step by step.
# Packages Reference
Source: https://docs.walletconnect.com/payments/psps/headless-sdk/packages-reference
The runtime is split into layered packages. Each is independently consumable, and lower layers never depend on higher ones — so you can take only what you need.
Engine API client — contract types, CAIP utilities, the browser Transport seam (createHttpTransport), and the server-side createEngineClient that holds your API key. The foundation.
The headless runtime — the payment state machine, orchestration, the injectable seam contracts, the session factory, and the public PaymentSnapshot view-model. No React, no HTTP client, no wallet SDK.
Reown AppKit adapter — implements the WalletProvider seam over a Reown AppKit instance (plus the Solana web3 loader). A /react subpath ships the wallet-connection hook.
pay-state
## `@walletconnect/pay-core`
The foundation: Engine contract types, CAIP utilities, and the `Transport` seam. Zero runtime dependencies. Two entry points — a browser-safe main entry and a server-only `/server` entry that holds the key.
### Browser entry — `@walletconnect/pay-core`
```typescript theme={null}
import { createHttpTransport, type Transport, type HttpTransportConfig } from '@walletconnect/pay-core'
interface HttpTransportConfig {
baseUrl?: string // default '/api/wcp'
fetch?: typeof fetch
timeoutMs?: number // default 30_000
}
function createHttpTransport(config?: HttpTransportConfig): Transport
```
The `Transport` contract (the seam the runtime depends on) — five methods, each resolving to an `EngineResponse` envelope (never throwing):
```typescript theme={null}
interface Transport {
getPayment(paymentId): Promise>
getPaymentOptions(paymentId, request): Promise>
fetchOptionActions(paymentId, request): Promise>
confirmPayment(paymentId, request): Promise>
getPaymentStatus(paymentId): Promise>
}
```
Also exported: all Engine contract types (`GetPaymentResponse`, `PaymentOptionExtended`, `Amount`, `CollectData`, `PaymentStatus`, …), CAIP utilities (`parseCaip2`, `parseCaip10`, …), and transport error helpers (`TRANSPORT_ERROR_CODES`, `isAbortError`, `DEFAULT_TIMEOUT_MS`).
### Server entry — `@walletconnect/pay-core/server`
```typescript theme={null}
import { createEngineClient, type EngineClient, type EngineClientConfig, DEFAULT_VERSION } from '@walletconnect/pay-core/server'
interface EngineClientConfig {
apiUrl: string // e.g. https://staging.api.pay.walletconnect.org (no trailing slash)
apiKey: string // your Gateway Api-Key — keep secret, server-side only
version?: string // WCP API version; defaults to DEFAULT_VERSION
fetch?: typeof fetch
timeoutMs?: number // default 30_000
}
function createEngineClient(config: EngineClientConfig): EngineClient
```
`EngineClient` exposes the same five methods as `Transport`, but attaches the secret `Api-Key` and `Wcp-Version` headers and calls the Engine directly. Use it only on the server.
## `@walletconnect/pay-state`
The headless runtime: the payment state machine, the injectable seam contracts, the framework-agnostic `PaymentController`, the signing strategies, and the public `PaymentSnapshot` view-model.
### `createPaymentController`
The framework-agnostic binding (the React hook wraps this). Use it directly in a non-React host.
```typescript theme={null}
import { createPaymentController, type PaymentController, type PaymentControllerOptions } from '@walletconnect/pay-state'
interface PaymentControllerOptions {
paymentId: string
seams: PaymentSessionSeams // { transport, clock, signer?, telemetry? }
wallet: WalletProvider
initialPayment?: GetPaymentResponse
signingTimeoutMs?: number
onMachineEvent?: MachineEventObserver // read-only analytics observer
}
function createPaymentController(options: PaymentControllerOptions): PaymentController
```
```typescript theme={null}
interface PaymentController {
getSnapshot(): PaymentSnapshot
subscribe(listener: () => void): () => void
start(): void
destroy(): void
// domain actions (see the React hook below for the full list)
connectWallet(wallet, namespace?, options?): void
// …
}
```
### Seams
The runtime reaches the outside world only through these contracts:
```typescript theme={null}
interface PaymentSessionSeams {
transport: Transport // Engine calls (from pay-core)
clock: Clock // intervals + page visibility (for status polling)
signer?: Signer // signing capability — required for an end-to-end payment
telemetry?: Telemetry // optional analytics breadcrumbs
}
interface WalletProvider {
getAccounts(): Partial>
getProvider(network: Caip2 & { namespace: T }): ProviderForNamespace | null
connect(wallet: WalletRef, namespace: Namespace, options?: WalletConnectOptions): Promise
disconnect(namespace?: Namespace): Promise
switchNetwork(caipNetworkId: string): Promise
subscribe(listener: () => void): () => void
}
interface Signer {
signActions(option: PaymentOptionExtended, range?: ActionRange): Promise
}
```
Browser defaults are provided so you only have to inject `transport`, `wallet`, and `signer`:
```typescript theme={null}
import { browserClock, noopTelemetry, browserDefaults } from '@walletconnect/pay-state'
```
### Signing strategies
AppKit hosts don't need these directly — `@walletconnect/pay-appkit` exports a zero-config `createAppKitSigner(wallet)` that wraps them with the bundled Solana codec. These are the low-level primitives, for a custom wallet integration.
```typescript theme={null}
import { EvmSigningStrategy, SolanaSigningStrategy, signOptionActions } from '@walletconnect/pay-state'
// Dispatch each of an option's actions to the matching strategy:
signOptionActions(option: PaymentOptionExtended, strategies: SigningStrategy[], range?: ActionRange): Promise
```
### `PaymentSnapshot`
The public, serializable view-model. `state` is one of:
| Group | States |
| ---------- | ----------------------------------------------------------------------------------------------- |
| Loading | `Initializing` |
| Wallet | `ReadyForWallet`, `ConnectingWallet` |
| Options | `LoadingOptions`, `OptionsReady`, `NoOptions`, `OptionSelected` |
| Compliance | `InformationCapture` |
| Approval | `RequiresApproval`, `AwaitingWalletApproval`, `WaitingForConfirmation` |
| Terminal | `Succeeded`, `Failed`, `PaymentExpired`, `PaymentCancelled`, `InvalidPayment`, `SanctionedUser` |
```typescript theme={null}
interface PaymentSnapshot {
state: PaymentState
payment?: GetPaymentResponse
options: PaymentOptionExtended[]
selectedOption?: PaymentOptionExtended
collectData?: CollectData | null
wallet: { isConnected: boolean; accounts: string[] }
requiresApproval: boolean
signingError?: { code: string; message?: string }
isQuoteExpired: boolean
profileId?: string
profileNotFound: boolean
// …diagnostic fields
}
```
## `@walletconnect/pay-react`
A single hook — `usePaymentSession` — a `useSyncExternalStore`-based binding over the controller. SSR-safe, tear-free, zero XState leak.
```typescript theme={null}
import { usePaymentSession, type UsePaymentSessionOptions, type PaymentSessionApi } from '@walletconnect/pay-react'
function usePaymentSession(options: UsePaymentSessionOptions): PaymentSessionApi
```
`UsePaymentSessionOptions` matches `PaymentControllerOptions` (`paymentId`, `seams`, `wallet`, `initialPayment?`, `signingTimeoutMs?`, `onMachineEvent?`). The return is `{ snapshot }` plus the named actions:
| Action | Drives |
| --------------------------------------------- | --------------------------------- |
| `connectWallet(wallet, namespace?, options?)` | Begin connecting a wallet |
| `disconnectWallet(namespace?)` | Disconnect one namespace, or all |
| `selectOption(option, rank)` | Pick a payment option |
| `confirmSelection()` | Confirm and move toward signing |
| `unselectOption()` | Return to the option list |
| `submitInfoCapture(data)` | Submit collected KYC/contact data |
| `navigateBack()` | Step back |
Plus a **host-orchestration channel** for signals the runtime can't observe itself — `refreshOptions`, `notifyQuoteExpired`, `acknowledgeQuoteExpiry`, `markUserSanctioned`, `setProfileLookup`, `notifyPaymentExpired`, `failWalletConnection`. Most gateways won't need these to start.
## `@walletconnect/pay-appkit`
The Reown AppKit adapter — it owns the entire AppKit setup so a host stays `@reown/*`-free. It constructs the instance, implements the `WalletProvider` seam over it, provides a zero-config `Signer`, and ships a headless wallet-picker controller. The main entry is framework-neutral; the React provider + hooks live on `/react`.
### Main entry — `@walletconnect/pay-appkit`
```typescript theme={null}
import {
createPayAppKit, // ({ projectId, metadata, themeVariables? }) => PayAppKit (one-call setup)
createAppKitSigner, // (wallet) => Signer (zero-config, bundles the Solana codec)
createAppKitWalletList, // (appKit, options?) => AppKitWalletList (framework-neutral picker)
createAppKitWalletProvider, // (appKit, options?) => WalletProvider
SUPPORTED_NETWORKS, EVM_NETWORKS, // the WC-owned network set (baked in, not host config)
loadSolanaWeb3, // lazy @solana/web3.js codec loader for the Solana signing strategy
applyPlacements, resolvePlacements, // wallet-ordering helpers
type AppKit, // re-exported AppKit instance type — don't import @reown/appkit directly
type PayAppKit, type CreatePayAppKitOptions, type PayAppKitMetadata,
type WalletListItem, type ConnectedWallet, type WalletListState, type WalletListOptions
} from '@walletconnect/pay-appkit'
```
`createPayAppKit` builds the instance with the WC-owned networks + adapters in headless mode. It's **client-only and async** — `await payAppKit.whenReady()`, then read `getInstance()` / `getWagmiConfig()` / `getHooks()` / `getError()` / `isInitialized()`.
`createAppKitWalletList` returns a controller — `wallet` (the seam), `getState()`, `subscribe()`, `fetchWallets()`, `search()`, `loadMore()`, `getWcUri()` — for non-React hosts.
### React entry — `@walletconnect/pay-appkit/react`
The zero-config provider that owns AppKit's construction + the Wagmi/Query tree + an SSR-safe context, plus the state hook and the imperative instance accessor:
```typescript theme={null}
import {
PayAppKitProvider, //
usePayAppKit, // SSR-safe hook: isReady / account / network / walletConnection / connect / disconnect / …
getPayAppKitInstance, // module-level accessor to the same AppKit instance (for non-hook consumers)
useAppKitWalletProvider,
type AppKitWalletProviderHandle,
type UseAppKitWalletProviderOptions
} from '@walletconnect/pay-appkit/react'
function useAppKitWalletProvider(appKit: AppKit | undefined, options?: UseAppKitWalletProviderOptions): AppKitWalletProviderHandle
```
`useAppKitWalletProvider`'s handle extends `WalletListState` and adds `wallet` (the seam to hand `usePaymentSession`), `searchQuery`/`setSearchQuery` (debounced), `fetchWallets`, `loadMore`, and `getWcUri` for the pairing QR.
## Next steps
The step-by-step React / Next.js walkthrough.
The Gateway and Payments endpoints behind the SDK.
# WalletConnect Pay for PSPs
Source: https://docs.walletconnect.com/payments/psps/overview
WalletConnect Pay lets Payment Service Providers, acquirers, and platforms accept crypto payments on the same engine that powers our hosted gateway. You choose how much of the experience you own — from a redirect to a hosted page, all the way to a fully branded checkout built inside your own product.
## Two ways to integrate
Send buyers to `pay.walletconnect.com` and let WalletConnect Pay handle the whole experience — wallet selection, quoting, signing, and settlement. The fastest path to accepting payments, with no UI to build.
Build a **fully branded, fully owned checkout** inside your own product. You get the payment runtime, Engine client, and wallet orchestration as framework-agnostic packages; you bring the UI, branding, and domain.
## Which one is for me?
Reach for the **hosted gateway** if you want to accept payments with the least effort and are happy to send buyers to a WalletConnect-hosted page. See the [hosted gateway overview](/payments/overview) or the [Ecommerce integration](/payments/ecommerce/overview).
Reach for the **Headless SDK** when you need to **own the experience** end to end — wallet selection, network and token choice, compliance prompts, success and error states — all inside your own design system and domain, with no redirect to a third-party page.
Both paths run on the same WalletConnect Pay Engine and share the same token and network coverage. You can start with the hosted gateway and move to the Headless SDK later without re-doing your backend integration.
## Who is it for?
* **Payment Service Providers & Acquirers** offering crypto checkout inside their own product, with their own design system and domain.
* **Platforms & marketplaces** embedding pay-with-crypto directly into an existing checkout flow.
* **Teams that need control** over every step of the UX.
## Next steps
Own the full checkout experience with the `@walletconnect/pay-*` packages.
The Gateway and Payments endpoints behind WalletConnect Pay.
Are you a PSP or acquirer? Tell us what you're building and we'll help you get set up.
# Token and Chain Coverage
Source: https://docs.walletconnect.com/payments/token-and-chain-coverage
Tokens and blockchains supported in General Availability within WalletConnect Pay, including acceptance and settlement options.
This page outlines the tokens and blockchains currently supported in General Availability within WalletConnect Pay. It provides a clear view of:
* **Acceptance** — whether a token on a given chain can be accepted from the buyer.
* **Settlement** — what the merchant or customer receives after acceptance, either direct crypto or fiat via an offramp, depending on the customer configuration.
This list will be updated over time as assets move from beta into General Availability and as settlement coverage expands.
## Supported tokens and chains
When creating crypto settlements, use the CAIP-19 token identifier below when specifying the settlement token contract. See the [Create crypto settlements](/api-reference/2026-02-18/post-v1-merchants-merchantid-settlements-crypto) endpoint for details.
| Token | Chain | CAIP-19 | Acceptance | Settlement |
| ----- | -------- | -------------------------------------------------------------------------------------------- | :--------: | -------------------------------- |
| USDC | Arbitrum | `eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| USDC | Base | `eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| USDC | Polygon | `eip155:137/erc20:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| USDC | Ethereum | `eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| USDC | Optimism | `eip155:10/erc20:0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85` | ✅ | Direct Crypto |
| USDC | BSC | `eip155:56/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d` | ✅ | Direct Crypto |
| USDC | Celo | `eip155:42220/erc20:0xcebA9300f2b948710d2653dD7B07f33A8B32118C` | ✅ | Direct Crypto |
| USDC | Monad | `eip155:143/erc20:0x754704Bc059F8C67012fEd69BC8A327a5aafb603` | ✅ | Direct Crypto |
| USDC | Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| EURC | Base | `eip155:8453/erc20:0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| EURC | Ethereum | `eip155:1/erc20:0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| EURC | Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr` | ✅ | Direct Crypto |
| EURe | Polygon | `eip155:137/erc20:0xE0aEa583266584DafBB3f9C3211d5588c73fEa8d` | ✅ | Direct Crypto |
| EURAU | Ethereum | `eip155:1/erc20:0x4933A85b5b5466Fbaf179F72D3DE273c287EC2c2` | ✅ | Direct Crypto |
| EURAU | Polygon | `eip155:137/erc20:0x4933A85b5b5466Fbaf179F72D3DE273c287EC2c2` | ✅ | Direct Crypto |
| EURAU | Base | `eip155:8453/erc20:0x4933A85b5b5466Fbaf179F72D3DE273c287EC2c2` | ✅ | Direct Crypto |
| EURAU | Arbitrum | `eip155:42161/erc20:0x4933A85b5b5466Fbaf179F72D3DE273c287EC2c2` | ✅ | Direct Crypto |
| EURAU | Optimism | `eip155:10/erc20:0x4933A85b5b5466Fbaf179F72D3DE273c287EC2c2` | ✅ | Direct Crypto |
| AUSD | Ethereum | `eip155:1/erc20:0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a` | ✅ | Direct Crypto |
| AUSD | Arbitrum | `eip155:42161/erc20:0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a` | ✅ | Direct Crypto |
| AUSD | Base | `eip155:8453/erc20:0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a` | ✅ | Direct Crypto |
| AUSD | BSC | `eip155:56/erc20:0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a` | ✅ | Direct Crypto |
| USDG | Ethereum | `eip155:1/erc20:0xe343167631d89B6Ffc58B88d6b7fB0228795491D` | ✅ | Direct Crypto |
| PYUSD | Ethereum | `eip155:1/erc20:0x6c3ea9036406852006290770bedfcaba0e23a0e8` | ✅ | Direct Crypto |
| PYUSD | Arbitrum | `eip155:42161/erc20:0x46850ad61c2b7d64d08c9c754f45254596696984` | ✅ | Direct Crypto |
| USDT | Ethereum | `eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| USDT | Polygon | `eip155:137/erc20:0xc2132D05D31c914a87C6611C10748AEb04B58e8F` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| USDT | Arbitrum | `eip155:42161/erc20:0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9` | ✅ | Direct Crypto |
| USDT | BSC | `eip155:56/erc20:0x55d398326f99059fF775485246999027B3197955` | ✅ | Direct Crypto |
| USDT | Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB` | ✅ | Direct Crypto or Fiat (USD, EUR) |
| WETH | Ethereum | `eip155:1/erc20:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | ✅ | Direct Crypto |
| WETH | Optimism | `eip155:10/erc20:0x4200000000000000000000000000000000000006` | ✅ | Direct Crypto |
| WETH | Base | `eip155:8453/erc20:0x4200000000000000000000000000000000000006` | ✅ | Direct Crypto |
| WETH | Arbitrum | `eip155:42161/erc20:0x82aF49447D8a07e3bd95BD0d56f35241523fBab1` | ✅ | Direct Crypto |
| WPOL | Polygon | `eip155:137/erc20:0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270` | ✅ | Direct Crypto |
| SOL | Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501` | ✅ | Direct Crypto |
## Coming soon
The following token and chain combinations are planned for General Availability and are not yet supported for acceptance or settlement.
| Token | Chain |
| ----- | -------- |
| ETH | Ethereum |
| ETH | Optimism |
| ETH | Arbitrum |
| ETH | Base |
| POL | Polygon |
| BNB | BSC |
| MON | Monad |
## Definitions
### Acceptance
WalletConnect Pay accepts the token on the specified chain from the buyer as part of the payment flow.
### Settlement
After acceptance, WalletConnect Pay delivers funds to the merchant or customer in one of the following ways:
* **Direct Crypto** — the payment is settled in crypto to the merchant's or customer's wallet. Settlement may involve bridging or swapping before delivery.
* **Fiat (USD, EUR)** — the accepted token can be converted (offramp) into fiat currency (USD or EUR) via partners, with fiat then delivered to the merchant.
Fiat settlement availability depends on offramp partner support per token and chain. Over time, fiat coverage will expand as WalletConnect Pay introduces internal swaps, allowing a broader set of tokens to be converted into supported offramp assets before payout.
# Wallet Coverage
Source: https://docs.walletconnect.com/payments/wallet-coverage
WalletConnect Pay works with **every WalletConnect-compatible wallet**. Any wallet that has integrated WalletConnect — over 700 today — can be used by a buyer to pay a WCP merchant.
For the full, always-current directory, point your customers at [WalletGuide](https://walletguide.walletconnect.network/).
## Top wallets by usage and volume
The wallets below cover the majority of WCP buyer activity today across EVM, Solana, and Bitcoin ecosystems.
* [MetaMask](https://metamask.io/)
* [Trust Wallet](https://trustwallet.com/)
* [Binance Web3 Wallet](https://www.binance.com/en/web3wallet)
* [Kraken Wallet](https://www.kraken.com/wallet)
* [Ledger](https://www.ledger.com/)
* [SafePal](https://www.safepal.com/)
* [Rainbow](https://rainbow.me/)
* [TokenPocket](https://www.tokenpocket.pro/)
* [Timeless X](https://timelesswallet.xyz/)
* [Safe](https://safe.global/)
* [Zerion](https://zerion.io/)
* [1inch Wallet](https://1inch.io/wallet/)
* [Crypto.com DeFi Wallet](https://crypto.com/defi-wallet)
* [Ronin Wallet](https://roninchain.com/)
* [OKX Wallet](https://www.okx.com/web3)
* [Bitget Wallet](https://web3.bitget.com/)
* [Uniswap Wallet](https://wallet.uniswap.org/)
* [Bybit Wallet](https://www.bybit.com/web3)
* [imToken](https://token.im/)
* [Fireblocks](https://www.fireblocks.com/)
* [CTRL](https://ctrl.xyz/)
* [Blockchain.com Wallet](https://www.blockchain.com/wallet)
* [Rakuten Wallet](https://wallet.rakuten.co.jp/)
* [BitPay](https://bitpay.com/wallet/)
* [xPortal](https://xportal.com/)
* [Bitcoin.com Wallet](https://wallet.bitcoin.com/)
* [Bifrost](https://bifrostwallet.com/)
* [Best Wallet](https://bestwallet.com/)
* [Phantom](https://phantom.com/)
* [Solflare](https://www.solflare.com/)
## Wallet support is chain-dependent
Wallet support is chain-dependent. Even when a wallet is listed on WalletGuide, it only works on the chains the wallet itself supports. When picking a wallet for a specific buyer cohort, always check the wallet's chain coverage against the chains your merchant accepts. See [Token and Chain Coverage](/payments/token-and-chain-coverage) for the chains WCP accepts today.
## Find a wallet
Browse 700+ WalletConnect-compatible wallets and filter by chain or device.
# API-first integration (Non-SDK wallets)
Source: https://docs.walletconnect.com/payments/wallets/api-first
Integrate WalletConnect Pay in a wallet without an SDK, using the Gateway API.
If you're integrating WalletConnect Pay into a wallet **without using the Wallet Pay SDK**, you can use an API-first approach via the **Gateway API**.
This flow is centered around **three Gateway calls**:
* **Get payment options**: list options the user can complete with their wallet/accounts
* **Fetch an action**: resolve "build" actions into wallet RPC actions when needed
* **Confirm a payment**: submit the selected option and the executed action results
## Prerequisites
* **API key**: request access from WalletConnect
* You can do this by filling out [**this form**](https://share.hsforms.com/19Dpp4ayYR9uriB3xNAh0JAnxw6s) and getting in touch with our team.
* **Required headers** on each request:
* `Api-Key` — your API key
## Payment flow
The payment flow mirrors the SDK flow, but you call the Gateway API directly:
**Get Options → (Collect Data) → (Fetch Actions) → Execute Wallet RPC → Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant Gateway as WalletConnect Pay (Gateway API)
participant Chain as Wallet RPC / Chain
User->>Wallet: Scan QR / Open payment link
Wallet->>Gateway: POST /v1/gateway/payment/{id}/options (accounts)
Gateway-->>Wallet: options[] (+ optional info/collectData)
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Selected option requires data collection
Wallet->>User: Load selectedOption.collectData.url in WebView
User->>Wallet: Fill form & accept T&C (IC_COMPLETE)
end
alt Option includes a build action
Wallet->>Gateway: POST /v1/gateway/payment/{id}/fetch (optionId, data)
Gateway-->>Wallet: actions[] (walletRpc)
end
Wallet->>User: Request signature(s) / transaction(s)
User->>Wallet: Approve
Wallet->>Chain: Execute wallet RPC actions (in order)
Chain-->>Wallet: Results (e.g., signature(s) / tx hash(es))
Wallet->>Gateway: POST /v1/gateway/payment/{id}/confirm (optionId, results, collectedData?)
Gateway-->>Wallet: status + isFinal (+ pollInMs)
Wallet->>User: Show result / status
```
## Understanding `actions`
Payment options can include an `actions[]` array. Each action tells your wallet what needs to happen next.
* **`walletRpc` actions**
* The Gateway gives you a wallet RPC payload (`chain_id`, `method`, `params`)
* Your wallet should execute it (sign / send), then record the output to submit in `results[]`
* **`build` actions**
* The action is not directly executable by the wallet
* Call **Fetch an action** to convert the `build` payload into one or more **`walletRpc`** actions
Maintain action ordering. The `results[]` you submit in **Confirm a payment** must correspond to the actions you executed, in order.
## High-level steps
### 1) Get payment options
Call **Get payment options** for the given `paymentId`, passing a list of accounts (CAIP-10 / chain namespace format as provided by your wallet).
* Use this response to render:
* payment details (optionally, if `includePaymentInfo=true`)
* available `options[]` the user can pick from
* required `actions[]` for the chosen option
### 2) Fetch an action (only if required)
If an option contains an action of type **`build`**, call **Fetch an action** with that `optionId` and the `data` payload to resolve it into one or more executable actions (typically `walletRpc`).
### 3) Confirm a payment
After your wallet executes the required `walletRpc` actions (sign/submit), call **Confirm a payment** with:
* the selected `optionId`
* `results[]`: the output from each executed action (in the same order you performed them)
* optional `collectedData` if the options response requested additional user info
If you used the **WebView-based data collection** flow (i.e., displayed `collectData.url` in a WebView), there is no need to send `collectedData` in the confirm request — the WebView submits user data directly to the backend.
If `isFinal` is `false`, the response may include `pollInMs`. Use it to decide when to check again (see the API Reference for status/polling behavior).
The WalletConnect Pay SDKs support **WebView-based data collection**. Each payment option may have its own `collectData.url`. When present on a selected option, wallets can display this URL in a WebView instead of building native forms. The WebView handles form rendering, validation, and T\&C acceptance, and submits data directly to the backend. See the [platform-specific SDK documentation](/payments/wallets/overview) for implementation details.
## Integration guidelines
These guidelines reflect the patterns used internally by the WalletConnect Pay SDK. Following them ensures a smooth, reliable UX.
### Payment link detection
Payment links can arrive in several formats. Your wallet should detect and extract the `paymentId` from:
| Format | Example |
| --------------------------- | --------------------------------------------------------------------- |
| WC Pay URL (path) | `https://pay.walletconnect.com/pay_123` |
| WC Pay URL (query) | `https://pay.walletconnect.com/?pid=pay_123` |
| `wc:` URI with `pay=` param | `wc:abc@2?pay=https%3A%2F%2Fpay.walletconnect.com%2F%3Fpid%3Dpay_123` |
| Bare payment ID | `pay_123` |
Only trust `pay.walletconnect.com` and `*.pay.walletconnect.com` as valid WC Pay hosts. Always validate the domain before extracting a payment ID.
Check for payment links **before** handling generic URLs or WalletConnect pairing URIs. Payment links are HTTPS URLs that would otherwise open in a browser.
### Providing accounts
Pass all of the user's accounts in **CAIP-10 format** (`eip155:{chainId}:{address}`) when calling **Get payment options**. Include accounts for every supported chain to maximize the number of payment options returned.
### Resolving `build` actions
When an option's `actions[]` contains a `build` action, it cannot be executed directly. Call **Fetch an action** (`POST /v1/gateway/payment/{id}/fetch`) with the `optionId` and the build action's `data` string. The response will contain one or more `walletRpc` actions that your wallet can execute.
A single `build` action may resolve into multiple `walletRpc` actions. Always iterate over the full response.
### Executing wallet RPC actions
Each `walletRpc` action contains:
* `chain_id` — the chain to execute on (CAIP-2 format, e.g., `eip155:8453`)
* `method` — the RPC method (e.g., `eth_signTypedData_v4`, `eth_sendTransaction`, `personal_sign`)
* `params` — JSON-encoded parameters
Execute each action in order using your wallet's signing or transaction implementation. For signing methods (e.g., `eth_signTypedData_v4`), collect the signature hex string. For transaction methods (e.g., `eth_sendTransaction`), collect the transaction hash. Store each result for submission.
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient token allowance for Permit2 returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check each action's `method` field and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Actions are not limited to the `eip155` (EVM) namespace. **Solana** actions arrive as `walletRpc` actions with `chain_id` in the `solana:` namespace and `method: solana_signTransaction`. Route each action by the namespace in its `chain_id`, sign with the matching wallet, and return the **base64-encoded signed transaction** (not a detached signature) in `results[]`. Do not broadcast — the backend submits the transaction. For full implementation guidance, see [Solana support](/payments/wallets/token-chain-support/solana-support).
### Submitting results
When calling **Confirm a payment**, wrap each signature as a `walletRpc` result:
```json theme={null}
{
"optionId": "opt_123",
"results": [
{ "type": "walletRpc", "data": ["0x"] },
{ "type": "walletRpc", "data": ["0x"] }
]
}
```
The `results[]` array **must** match the `actions[]` array in both length and order. Misalignment causes payment failures.
### Polling for final status
After **Confirm a payment** returns, check the `isFinal` field:
* **`isFinal: true`** — the payment has reached a terminal state (`succeeded`, `failed`, or `expired`). No further action needed.
* **`isFinal: false`** — the payment is still processing. Use the `pollInMs` value from the response as the delay before your next confirm call.
Use the `maxPollMs` query parameter on the confirm request to enable **server-side long-polling** — the server will hold the connection and return as soon as the status changes or the timeout expires, reducing the number of round-trips.
```
POST /v1/gateway/payment/{id}/confirm?maxPollMs=30000
```
### Retry strategy
Implement retries with **exponential backoff and jitter** for resilience:
* **Retry only on** server errors (5xx) and network failures (connection refused, timeout)
* **Do not retry** client errors (4xx) — these indicate invalid input and won't succeed on retry
* **Recommended**: 3 retries with 100ms initial backoff, doubling each attempt, plus random jitter
### Data collection
Data collection is per-option — each payment option may independently have a `collectData` object. When the user selects an option, check `option.collectData`:
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
If the selected option's `collectData` has a `url` field, display it in a WebView before confirming. The URL is already scoped to that option's account. The WebView handles form rendering, validation, and T\&C acceptance. When the WebView signals completion (`IC_COMPLETE` via JS bridge), proceed to confirm — no need to include `collectedData` in the request.
If you choose not to use the WebView and instead build your own form, use the `collectData.schema` JSON schema to determine the required fields, collect the values, and pass them as `collectedData` in the confirm request.
### Expiration handling
Payments have an expiration timestamp (`expiresAt` in the payment info). Display a countdown or warning to the user when time is running low, and prevent submission after expiry.
If a payment or route expires mid-flow, the API returns a `410` (payment expired) or `409` (route expired) error. Handle these gracefully by informing the user and offering to start over if a new payment link is available.
## API Reference
For request/response schemas and examples for each Gateway endpoint, see the **[API Reference](/api-reference)**.
# WalletConnect Pay - Wallet Integration
Source: https://docs.walletconnect.com/payments/wallets/overview
Your users hold crypto and stablecoins. WalletConnect Pay gives them somewhere to spend it.
One integration connects your wallet to a global network of PSPs and merchants — online and in-store. You control the experience. WalletConnect Pay handles the rest: payment execution, compliance, settlement, and gas.
What's included
* Consistent, compliant payment flows across ecommerce and point of sale
* Interchange revenue for your wallet on every eligible transaction, settled in \$WCT
* Cashback in \$WCT for your users on every purchase
* Check [Token and Chain coverage](/payments/token-and-chain-coverage) for supported tokens and chains
WalletConnect provides three integration approaches for enabling WalletConnect Pay in your wallet:
## Integration Options
### Integrate using the Wallet SDK (Recommended)
If your wallet already uses the WalletConnect Wallet SDK (WalletKit), WalletConnect Pay is automatically available when you initialize WalletKit. This is the recommended approach as it provides a unified API and automatic configuration.
WalletKit integration for Android wallets.
WalletKit integration for iOS wallets.
WalletKit integration for React Native wallets.
WalletKit integration for Flutter wallets.
WalletKit integration for Web/Node.js wallets.
### Standalone Integration
For wallets that don't currently use the WalletConnect Wallet SDK, you can integrate the standalone Pay SDK directly. This approach requires configuring the SDK with your WalletConnect Project ID.
Standalone Pay SDK for Android wallets.
Standalone Pay SDK for iOS wallets.
Standalone Pay SDK for React Native wallets.
Standalone Pay SDK for Flutter wallets.
Standalone Pay SDK for Web/Node.js wallets.
### API-First Integration
For wallets that prefer direct API integration without using an SDK, you can use the Gateway API directly.
Integrate WalletConnect Pay using the Gateway API.
## Getting Started
To get started with WalletConnect Pay integration:
1. **Get a Project ID**: Create a wallet project at the [WalletConnect Dashboard](https://dashboard.walletconnect.com) to obtain your Project ID.
2. **Choose your integration approach**: Select either the WalletKit integration (if you already use WalletKit) or the standalone Pay SDK integration.
3. **Follow the platform-specific guide**: Use the documentation for your platform to implement the payment flow.
## Payment Flow Overview
Regardless of which integration approach you choose, the payment flow follows the same pattern:
1. **Detect Payment Link**: Identify when a user scans or opens a WalletConnect Pay link
2. **Get Payment Options**: Fetch available payment options based on user's accounts
3. **Collect User Data** (if required for selected option): If the selected payment option requires compliance information, display a WebView for the user to provide it. This must happen **before** fetching the required actions — the WebView submits the data directly to the backend
4. **Get Required Actions**: Retrieve the signing actions for the selected payment option
5. **Sign Actions**: Sign the required permits/transactions using your wallet's signing infrastructure
6. **Confirm Payment**: Submit signatures and complete the payment
## Recommended Implementation
For best user experience, follow the recommended implementation below.
## Testing
Test your WalletConnect Pay integration with the Point-of-Sale app in the WalletConnect Dashboard. To enable the app, configure a mock-merchant recipient address. Payments will arrive to this address and you won't lose any testing funds.
## Earn Revenue
Wallets can earn interchange-like revenue on eligible WalletConnect Pay payments. To activate, make sure to complete a successful payment with your integration, then submit the Form found in the WalletConnect Dashboard. We will review the submission and confirm the results to the submitted contact email.
## User Information FAQs
No. The process is optimised so users don't have to re-enter information for each transaction. Once collected, the information will be reused.
Wallets that already have verified user PII (e.g. a neobank, a card issuing wallet) can prefill the WebView form by appending a `?prefill=` query parameter to the WebView URL. The `required` list from the `collectDataAction.schema` tells you which fields the form expects (e.g., `fullName`, `dob`, `pobAddress`). The user will still see the form but with pre-populated fields, reducing friction.
When processing a payment:
1. Call `getPaymentOptions` and display all available options to the user
2. When the user selects an option, check `selectedOption.collectData` — if it has a `url` field, data collection is required for that specific option
3. Display the URL in a WebView — the hosted form handles rendering, validation, and T\&C acceptance
4. The WebView communicates completion via JavaScript bridge messages (`IC_COMPLETE` / `IC_ERROR`)
5. When the WebView completes, proceed to confirm the payment — no `collectedData` needs to be passed since the WebView submits data directly
WalletConnect Pay complies with Travel Rule regulations to ensure the payment flow is scalable and future-proof across all regions. This requires capturing some user information like name and date of birth.
The WebView-based data collection form handles Terms & Conditions and Privacy Policy acceptance as part of the form flow. The user must accept these before the form can be submitted.
SDKs support a WebView-based approach for collecting user information. When `selectedOption.collectData.url` is present on a payment option, wallets display this URL in a WebView instead of building native forms. The hosted form handles:
* Form rendering and field validation
* Terms & Conditions and Privacy Policy acceptance
* Data submission directly to the backend
The WebView communicates with the wallet via JavaScript bridge messages (`IC_COMPLETE` when done, `IC_ERROR` on failure). See the platform-specific documentation for implementation details.
We strongly recommend using the WebView-based flow over building custom native UI. Data collection requirements are driven by regulation and can evolve. The hosted WebView form is maintained and updated centrally so that wallets can automatically pick up changes.
No. Information capture (IC) is per-option — some payment options may require it while others don't. Each payment option has its own `collectData` field. When `option.collectData` is present, the user must complete data collection before confirming that option. When it's `null`, no IC is needed for that option.
Wallets should display all options upfront and show a visual indicator (e.g., "Info required" badge) on options that require data collection. Only open the WebView when the user selects an option with `collectData` present.
Yes, with a caveat.
Wallets that choose to implement custom UI assume responsibility for keeping their forms in sync with these changes. When changes are rolled out, wallets must implement them immediately. If the provided data is not what is requested, users in the relevant jurisdiction(s) will be unable to pay until the custom integration is updated. The WebView approach eliminates this overhead and ensures a consistent, up-to-date experience with minimal integration effort.
Wallets also must ensure the user has accepted:
* [WalletConnect Terms and Conditions](https://walletconnect.com/terms)
* [WalletConnect Privacy Policy](https://walletconnect.com/privacy)
The required user information can be sent to WalletConnect using schema in the `collect_data` object.
We strongly recommend using the WebView-based flow over building custom native UI. Data collection requirements are driven by regulation and can evolve. The hosted WebView form is maintained and updated centrally so that wallets can automatically pick up changes.
Yes.
Wallets can pre-fill user information and it will be shown in the WebView form.
If wallets choose to skip the WebView form, they must ensure the user has accepted:
* [WalletConnect Terms and Conditions](https://walletconnect.com/terms)
* [WalletConnect Privacy Policy](https://walletconnect.com/privacy)
Yes. The hosted form accepts two optional appearance parameters on its URL:
* `theme=light` or `theme=dark` — sets the form's base color mode. Match it to your wallet's active mode.
* `themeVariables=` — overrides design tokens (font, font size, some colors, button border radius, and input border radius). Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com) and append it to the form URL verbatim.
Both are optional and independent — when omitted, the form uses its default styling. See your platform's integration guide for the URL-building code.
# WalletConnect Pay SDK - Flutter
Source: https://docs.walletconnect.com/payments/wallets/standalone/flutter
Integrate WalletConnect Pay into your Flutter wallet to enable seamless crypto payments for your users.
The WalletConnect Pay SDK allows wallet users to pay merchants using their crypto assets. The SDK handles payment option discovery, permit signing coordination, and payment confirmation while leveraging your wallet's existing signing infrastructure.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference Flutter wallet app demonstrating WalletConnect Pay integration.
## Requirements
* Flutter 3.0+
* iOS 13.0+
* Android API 23+
## Installation
Add `walletconnect_pay` package to your `pubspec.yaml` or simply run:
```bash theme={null}
flutter pub add walletconnect_pay
```
## Initialization
Initialize the `WalletConnectPay` client with your WCP ID and client ID or API key:
```dart theme={null}
import 'package:walletconnect_pay/walletconnect_pay.dart';
// Initialize WalletConnect Pay. Either apiKey or appId must be passed
final payClient = WalletConnectPay(
apiKey: 'YOUR_API_KEY', // Optional
appId: 'YOUR_WCP_ID', // Optional
clientId: 'OPTIONAL_CLIENT_ID', // Optional
baseUrl: 'https://api.pay.walletconnect.com', // Optional
);
// Initialize the SDK
try {
await payClient.init();
} on PayInitializeError catch (e) {
// Handle initialization error
}
```
**Configuration Parameters**
| Parameter | Type | Required | Description |
| ---------- | --------- | -------- | --------------------------------------------- |
| `apiKey` | `String?` | No\* | WalletConnect Pay API key |
| `appId` | `String?` | No\* | WCP ID |
| `clientId` | `String?` | No | Client identifier |
| `baseUrl` | `String?` | No | Base URL for the API (defaults to production) |
Either `apiKey` or `appId` must be provided for authentication.
Don't have a project ID? Create one at the [WalletConnect Dashboard](https://dashboard.walletconnect.com) by signing up and creating a new project.
## Supported Networks & Tokens
WalletConnect Pay currently supports the following tokens and networks:
| Token | Network | Chain ID | CAIP-10 Format |
| ----- | -------- | -------- | ------------------------ |
| USDC | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDC | Base | 8453 | `eip155:8453:{address}` |
| USDC | Polygon | 137 | `eip155:137:{address}` |
| USDC | Ethereum | 1 | `eip155:1:{address}` |
| USDC | Optimism | 10 | `eip155:10:{address}` |
| USDC | Monad | 143 | `eip155:143:{address}` |
| USDC | Celo | 42220 | `eip155:42220:{address}` |
| USDC | BSC | 56 | `eip155:56:{address}` |
| EURC | Ethereum | 1 | `eip155:1:{address}` |
| EURC | Base | 8453 | `eip155:8453:{address}` |
| USDT0 | Arbitrum | 42161 | `eip155:42161:{address}` |
| PYUSD | Ethereum | 1 | `eip155:1:{address}` |
| PYUSD | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDG | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Polygon | 137 | `eip155:137:{address}` |
| USDT | BSC | 56 | `eip155:56:{address}` |
Include accounts for all supported networks to maximize payment options for your users.
## Payment Flow
The payment flow consists of five main steps:
**Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant PaySDK as Pay SDK
participant Backend as WalletConnect Pay
participant WebView
User->>Wallet: Scan QR / Open payment link
Wallet->>PaySDK: getPaymentOptions(link, accounts)
PaySDK->>Backend: Fetch payment options
Backend-->>PaySDK: Payment options + merchant info
PaySDK-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Data collection required
Wallet->>WebView: Load collectDataAction.url in WebView
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>PaySDK: getRequiredPaymentActions(paymentId, optionId)
PaySDK->>Backend: Get signing actions
Backend-->>PaySDK: Required wallet RPC actions
PaySDK-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>PaySDK: confirmPayment(signatures)
PaySDK->>Backend: Submit payment
Backend-->>PaySDK: Payment status
PaySDK-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
Retrieve available payment options for a payment link:
```dart theme={null}
final request = GetPaymentOptionsRequest(
paymentLink: 'https://pay.walletconnect.com/pay_123',
accounts: ['eip155:1:0x...', 'eip155:137:0x...'], // User's wallet CAIP-10 accounts
includePaymentInfo: true, // Include payment details in response
);
final response = await payClient.getPaymentOptions(request: request);
// Access payment information
print('Payment ID: ${response.paymentId}');
print('Options available: ${response.options.length}');
if (response.info != null) {
print('Amount: ${response.info!.amount.formatAmount()}');
print('Status: ${response.info!.status}');
print('Merchant: ${response.info!.merchant.name}');
}
// Check which options require data collection (per-option)
for (final option in response.options) {
if (option.collectData != null) {
print('Option ${option.id} requires info capture');
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```dart theme={null}
if (selectedOption.collectData?.url != null) {
// Use the "required" list from selectedOption.collectData.schema to determine which fields to prefill
final prefillData = {
'fullName': 'John Doe',
'dob': '1990-01-15',
'pobAddress': '123 Main St, New York, NY 10001',
};
// Encode prefill as base64url
final prefillBase64 = base64Url.encode(utf8.encode(jsonEncode(prefillData))).replaceAll('=', '');
final uri = Uri.parse(selectedOption.collectData!.url);
final webViewUrl = uri.replace(
queryParameters: {
...uri.queryParameters,
'prefill': prefillBase64,
// Optional appearance params (see "Form URL parameters"):
'theme': 'dark', // "light" | "dark"
// themeVariables is a base64url string exported from the Pay Dashboard:
// 'themeVariables': themeVariables,
},
).toString();
// Show WebView — see Data Collection Implementation section below
showDataCollectionWebView(webViewUrl);
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
Get the required wallet actions (e.g., transactions to sign) for a selected payment option:
```dart theme={null}
final actionsRequest = GetRequiredPaymentActionsRequest(
optionId: response.options.first.id, // Or whatever other option chosen by the user
paymentId: response.paymentId,
);
final actions = await payClient.getRequiredPaymentActions(
request: actionsRequest,
);
// Process each action (e.g., sign transactions)
for (final action in actions) {
final walletRpc = action.walletRpc;
print('Chain ID: ${walletRpc.chainId}');
print('Method: ${walletRpc.method}');
print('Params: ${walletRpc.params}');
// Dispatch based on walletRpc.method — see Sign Actions below
}
```
Sign each action using your wallet's signing implementation, dispatching on the RPC method:
```dart theme={null}
// Sign each action based on its RPC method
final signatures = [];
for (final action in actions) {
final rpc = action.walletRpc;
switch (rpc.method) {
case 'eth_signTypedData_v4':
signatures.add(await signTypedData(rpc.chainId, rpc.params));
break;
case 'eth_sendTransaction':
signatures.add(await sendTransaction(rpc.chainId, rpc.params));
break;
case 'personal_sign':
signatures.add(await personalSign(rpc.chainId, rpc.params));
break;
default:
throw UnimplementedError('Unsupported RPC method: ${rpc.method}');
}
}
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Confirm a payment with the collected signatures:
```dart theme={null}
final confirmRequest = ConfirmPaymentRequest(
paymentId: response.paymentId,
optionId: response.options.first.id,
signatures: ['0x...', '0x...'], // Signatures from wallet actions
maxPollMs: 60000, // Optional: max polling time in milliseconds
);
final confirmResponse = await payClient.confirmPayment(request: confirmRequest);
print('Payment Status: ${confirmResponse.status}');
print('Is Final status: ${confirmResponse.isFinal}');
if (!confirmResponse.isFinal && confirmResponse.pollInMs != null) {
// Poll again after the specified interval
await Future.delayed(Duration(milliseconds: confirmResponse.pollInMs!));
// Re-confirm or check status
}
```
When using the WebView data-collection approach, you do **not** pass `collectedData` to `confirmPayment`. The WebView submits the collected data directly to the backend during the earlier Collect User Data step.
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a WebView using the `webview_flutter` package (v4.10.0+). Add it to your `pubspec.yaml`:
```yaml theme={null}
dependencies:
webview_flutter: ^4.10.0
url_launcher: ^6.1.0
```
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```dart theme={null}
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
class PayDataCollectionWebView extends StatefulWidget {
final String url;
final VoidCallback onComplete;
final ValueChanged onError;
const PayDataCollectionWebView({
super.key,
required this.url,
required this.onComplete,
required this.onError,
});
@override
State createState() =>
_PayDataCollectionWebViewState();
}
class _PayDataCollectionWebViewState extends State {
late final WebViewController _controller;
bool _isLoading = true;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onPageFinished: (_) => setState(() => _isLoading = false),
onNavigationRequest: (request) {
if (!request.url.contains('pay.walletconnect.com')) {
launchUrl(Uri.parse(request.url),
mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
))
..addJavaScriptChannel(
'ReactNativeWebView',
onMessageReceived: (message) {
try {
final data = jsonDecode(message.message) as Map;
switch (data['type']) {
case 'IC_COMPLETE':
widget.onComplete();
break;
case 'IC_ERROR':
widget.onError(data['error'] ?? 'Unknown error');
break;
}
} catch (_) {
// Ignore non-JSON messages
}
},
)
..loadRequest(Uri.parse(widget.url));
// Inject JS bridge for compatibility
_controller.runJavaScript('''
window.ReactNativeWebView = {
postMessage: function(data) {
ReactNativeWebView.postMessage(data);
}
};
''');
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading)
const Center(child: CircularProgressIndicator()),
],
);
}
}
String buildFormUrl(
String baseUrl, {
Map 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();
}
```
## Complete Example
Here's a complete implementation example:
```dart theme={null}
import 'package:walletconnect_pay/walletconnect_pay.dart';
class PaymentService {
late final WalletConnectPay _payClient;
Future initialize() async {
_payClient = WalletConnectPay(
appId: 'YOUR_WCP_ID',
);
await _payClient.init();
}
Future processPayment(
String paymentLink,
List accounts,
) async {
// Step 1: Get payment options
final optionsResponse = await _payClient.getPaymentOptions(
request: GetPaymentOptionsRequest(
paymentLink: paymentLink,
accounts: accounts,
includePaymentInfo: true,
),
);
if (optionsResponse.options.isEmpty) {
throw Exception('No payment options available');
}
// Step 2: Select payment option (simplified - use first option)
final selectedOption = optionsResponse.options.first;
// Step 3: Collect data via WebView if required.
// This must happen BEFORE fetching the required payment actions —
// the backend rejects the actions request with 400 "IC data required"
// for options needing Information Capture if data wasn't collected first.
// The WebView submits the data directly to the backend, so it is NOT
// passed to confirmPayment later.
if (selectedOption.collectData?.url != null) {
// Show WebView and wait for IC_COMPLETE
await showDataCollectionWebView(selectedOption.collectData!.url);
}
// Step 4: Get required payment actions
final actions = await _payClient.getRequiredPaymentActions(
request: GetRequiredPaymentActionsRequest(
optionId: selectedOption.id,
paymentId: optionsResponse.paymentId,
),
);
// Step 5: Sign all actions
final signatures = [];
for (final action in actions) {
final signature = await signAction(action.walletRpc);
signatures.add(signature);
}
// Step 6: Confirm payment
final confirmResponse = await _payClient.confirmPayment(
request: ConfirmPaymentRequest(
paymentId: optionsResponse.paymentId,
optionId: selectedOption.id,
signatures: signatures,
maxPollMs: 60000,
),
);
// Step 7: Poll until final status (if needed)
var response = confirmResponse;
while (!response.isFinal && response.pollInMs != null) {
await Future.delayed(Duration(milliseconds: response.pollInMs!));
response = await _payClient.confirmPayment(
request: ConfirmPaymentRequest(
paymentId: optionsResponse.paymentId,
optionId: selectedOption.id,
signatures: signatures,
maxPollMs: 60000,
),
);
}
return response;
}
Future signAction(WalletRpcAction walletRpc) async {
switch (walletRpc.method) {
case 'eth_signTypedData_v4':
return await signTypedData(walletRpc.chainId, walletRpc.params);
case 'eth_sendTransaction':
return await sendTransaction(walletRpc.chainId, walletRpc.params);
case 'personal_sign':
return await personalSign(walletRpc.chainId, walletRpc.params);
default:
throw UnimplementedError('Unsupported RPC method: ${walletRpc.method}');
}
}
}
```
## API Reference
**WalletConnectPay**
The main class for interacting with the WalletConnect Pay SDK.
**Constructor**
```dart theme={null}
WalletConnectPay({
String? apiKey,
String? appId,
String? clientId,
String? baseUrl,
})
```
**Methods**
| Method | Description |
| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `Future init()` | Initializes the SDK. Returns `true` on success or throw `PayInitializeError` on error |
| `Future getPaymentOptions({required GetPaymentOptionsRequest request})` | Retrieves available payment options |
| `Future> getRequiredPaymentActions({required GetRequiredPaymentActionsRequest request})` | Gets the required wallet actions for a selected option (to be called if the selected option does not have actions included) |
| `Future confirmPayment({required ConfirmPaymentRequest request})` | Confirms a payment |
## Data Models
**GetPaymentOptionsRequest**
```dart theme={null}
GetPaymentOptionsRequest({
required String paymentLink,
required List accounts,
@Default(false) bool includePaymentInfo,
})
```
**PaymentOptionsResponse**
```dart theme={null}
PaymentOptionsResponse({
required String paymentId,
PaymentInfo? info,
required List options,
CollectDataAction? collectData,
PaymentResultInfo? resultInfo, // Transaction result details (present when payment already completed)
})
```
**PaymentResultInfo**
```dart theme={null}
class PaymentResultInfo {
final String txId; // Transaction ID
final PayAmount optionAmount; // Token amount details
}
```
**PaymentInfo**
```dart theme={null}
PaymentInfo({
required PaymentStatus status,
required PayAmount amount,
required int expiresAt,
required MerchantInfo merchant,
BuyerInfo? buyer,
})
```
**PaymentOption**
```dart theme={null}
PaymentOption({
required String id,
required String account,
required PayAmount amount,
@JsonKey(name: 'etaS') required int etaSeconds,
required List actions,
CollectDataAction? collectData, // Per-option data collection (null if not required)
})
```
**ConfirmPaymentRequest**
```dart theme={null}
ConfirmPaymentRequest({
required String paymentId,
required String optionId,
required List signatures,
List? collectedData,
int? maxPollMs,
})
```
**ConfirmPaymentResponse**
```dart theme={null}
ConfirmPaymentResponse({
required PaymentStatus status,
required bool isFinal,
int? pollInMs,
})
```
**PaymentStatus**
```dart theme={null}
enum PaymentStatus {
requires_action,
processing,
succeeded,
failed,
expired,
}
```
**Action & WalletRpcAction**
```dart theme={null}
class Action {
final WalletRpcAction walletRpc;
}
class WalletRpcAction {
final String chainId; // CAIP-2 chain ID (e.g., "eip155:8453")
final String method; // RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
final String params; // JSON-encoded parameters
}
```
**CollectDataAction**
```dart theme={null}
class CollectDataAction {
final String url; // WebView URL for data collection
final String? schema; // JSON schema describing required fields
}
```
## Error Handling
The SDK throws specific exception types for different error scenarios. All errors extend the abstract `PayError` class, which itself extends `PlatformException`:
```dart theme={null}
abstract class PayError extends PlatformException {
PayError({
required super.code,
required super.message,
required super.details,
required super.stacktrace,
});
}
```
| Exception | Description |
| ------------------------- | ------------------------------------ |
| `PayInitializeError` | Initialization failures |
| `GetPaymentOptionsError` | Errors when fetching payment options |
| `GetRequiredActionsError` | Errors when getting required actions |
| `ConfirmPaymentError` | Errors when confirming payment |
All errors include:
* `code`: Error code
* `message`: Error message
* `details`: Additional error details
* `stacktrace`: Stack trace
**Example Error Handling**
```dart theme={null}
try {
await payClient.init();
} on PayInitializeError catch (e) {
print('Initialization failed: ${e.code} - ${e.message}');
}
try {
final response = await payClient.getPaymentOptions(request: request);
} on GetPaymentOptionsError catch (e) {
print('Error code: ${e.code}');
print('Error message: ${e.message}');
} on PayError catch (e) {
// Catch any Pay-related error
print('Pay error: ${e.message}');
} catch (e) {
print('Unexpected error: $e');
}
```
## Best Practices
1. **Initialize once**: Call `init()` only once, typically during app startup
2. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
3. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
4. **Signature Order**: Maintain the same order of signatures as the actions array
5. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
6. **Loading States**: Show loading indicators during API calls and signing operations
7. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
8. **User Data**: Only collect data when `collectData` is present in the response and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
9. **WebView Data Collection**: When `selectedOption.collectData.url` is present, display the URL in a WebView using `webview_flutter` rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
# WalletConnect Pay SDK - Kotlin
Source: https://docs.walletconnect.com/payments/wallets/standalone/kotlin
Integrate WalletConnect Pay into your Android wallet to enable seamless crypto payments for your users.
The WalletConnect Pay SDK allows wallet users to pay merchants using their crypto assets. The SDK handles payment option discovery, permit signing coordination, and payment confirmation while leveraging your wallet's existing signing infrastructure.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference Android wallet app demonstrating WalletConnect Pay integration.
## Requirements
* **Min SDK**: 23 (Android 6.0)
* **Target SDK**: 36
* **JVM Target**: 11
## Installation
Add the WalletConnect Pay SDK to your project's `build.gradle.kts` file:
```kotlin theme={null}
dependencies {
implementation("com.walletconnect:pay:1.0.0")
}
```
The version shown above may not be the latest. Check the [GitHub releases](https://github.com/reown-com/reown-kotlin/releases) for the most recent version.
**JNA Dependency Configuration**
If you encounter JNA-related errors (e.g., `UnsatisfiedLinkError` or class loading issues), explicitly configure the JNA dependency:
```kotlin theme={null}
implementation("com.walletconnect:pay:1.0.0") {
exclude(group = "net.java.dev.jna", module = "jna")
}
implementation("net.java.dev.jna:jna:5.17.0@aar")
```
## Initialization
Initialize the SDK in your `Application` class or before any payment operations:
```kotlin theme={null}
import com.walletconnect.pay.Pay
import com.walletconnect.pay.WalletConnectPay
WalletConnectPay.initialize(
Pay.SdkConfig(
apiKey = "your-api-key", // Your WalletConnect Pay API key (optional)
appId = "your-wcp-id", // Your WCP ID (optional)
packageName = "com.your.app" // Your app's package name
)
)
```
**Configuration Parameters**
| Parameter | Type | Required | Description |
| ------------- | -------- | -------- | ------------------------------- |
| `apiKey` | `String` | No\* | Your WalletConnect Pay API key |
| `appId` | `String` | No\* | Your WCP ID |
| `packageName` | `String` | Yes | Your application's package name |
\*Either `apiKey` or `appId` is required for authentication.
Don't have a project ID? Create one at the [WalletConnect Dashboard](https://dashboard.walletconnect.com) by signing up and creating a new project.
The SDK will throw `IllegalStateException` if already initialized. Call `initialize()` only once.
## Supported Networks & Tokens
WalletConnect Pay currently supports the following tokens and networks:
| Token | Network | Chain ID | CAIP-2 Format |
| ----- | -------- | -------- | -------------- |
| USDC | Arbitrum | 42161 | `eip155:42161` |
| USDC | Base | 8453 | `eip155:8453` |
| USDC | Polygon | 137 | `eip155:137` |
| USDC | Ethereum | 1 | `eip155:1` |
| USDC | Optimism | 10 | `eip155:10` |
| USDC | Monad | 143 | `eip155:143` |
| USDC | Celo | 42220 | `eip155:42220` |
| USDC | BSC | 56 | `eip155:56` |
| EURC | Ethereum | 1 | `eip155:1` |
| EURC | Base | 8453 | `eip155:8453` |
| USDT0 | Arbitrum | 42161 | `eip155:42161` |
| PYUSD | Ethereum | 1 | `eip155:1` |
| PYUSD | Arbitrum | 42161 | `eip155:42161` |
| USDG | Ethereum | 1 | `eip155:1` |
| USDT | Ethereum | 1 | `eip155:1` |
| USDT | Polygon | 137 | `eip155:137` |
| USDT | BSC | 56 | `eip155:56` |
All account addresses must be provided in CAIP-10 format: `eip155::`
Include accounts for all supported networks to maximize payment options for your users.
## Payment Flow
The payment flow consists of five main steps:
**Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant PaySDK as Pay SDK
participant Backend as WalletConnect Pay
participant WebView
User->>Wallet: Scan QR / Open payment link
Wallet->>PaySDK: getPaymentOptions(link, accounts)
PaySDK->>Backend: Fetch payment options
Backend-->>PaySDK: Payment options + merchant info
PaySDK-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Selected option requires data collection
Wallet->>WebView: Load selectedOption.collectData.url
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>PaySDK: getRequiredPaymentActions(paymentId, optionId)
PaySDK->>Backend: Get signing actions
Backend-->>PaySDK: Required wallet RPC actions
PaySDK-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>PaySDK: confirmPayment(signatures)
PaySDK->>Backend: Submit payment
Backend-->>PaySDK: Payment status
PaySDK-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
```kotlin theme={null}
import com.walletconnect.pay.Pay
import com.walletconnect.pay.WalletConnectPay
// In your Application class or before payment operations
WalletConnectPay.initialize(
Pay.SdkConfig(
appId = "your-wcp-id", // Your WCP ID
packageName = "com.your.app"
)
)
// Check if initialized
if (WalletConnectPay.isInitialized) {
// SDK ready for use
}
```
When a user scans a payment QR code or opens a payment link, fetch available payment options:
```kotlin theme={null}
val result = WalletConnectPay.getPaymentOptions(
paymentLink = "https://pay.walletconnect.com/pay_xxx",
accounts = listOf(
"eip155:1:0xYourAddress", // Ethereum
"eip155:8453:0xYourAddress", // Base
"eip155:10:0xYourAddress" // Optimism
)
)
result.onSuccess { response ->
// Payment metadata
val paymentId = response.paymentId
val paymentInfo = response.info // Merchant info, amount, expiry
// Available payment options
val options = response.options
options.forEach { option ->
println("Option: ${option.id}")
println("Amount: ${option.amount.value} ${option.amount.unit}")
println("Account: ${option.account}")
println("Estimated transactions: ${option.estimatedTxs}")
println("Requires IC: ${option.collectData != null}")
}
}.onFailure { error ->
handleError(error)
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```kotlin theme={null}
// Check per-option data collection requirement after user selects an option
selectedOption.collectData?.let { collectAction ->
val url = collectAction.url
if (url != null) {
// Build prefill URL with known user data
// Use the "required" list from collectAction.schema to determine which fields to prefill
val prefillJson = JSONObject().apply {
put("fullName", "John Doe")
put("dob", "1990-01-15")
put("pobAddress", "123 Main St, New York, NY 10001")
}.toString()
// Encode prefill as base64url (URL-safe, no padding)
val prefillBase64 = Base64.encodeToString(
prefillJson.toByteArray(),
Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING
)
val webViewUrl = Uri.parse(url).buildUpon()
.appendQueryParameter("prefill", prefillBase64)
// Optional appearance params (see "Form URL parameters"):
.appendQueryParameter("theme", "dark") // "light" | "dark"
// themeVariables is a base64url string exported from the Pay Dashboard:
// .appendQueryParameter("themeVariables", themeVariables)
.build().toString()
// Show WebView for this specific option — see Data Collection Implementation section below
showWebView(webViewUrl)
}
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
After the user selects a payment option (and any required data collection has completed), get the wallet RPC actions needed to complete the payment:
```kotlin theme={null}
val actionsResult = WalletConnectPay.getRequiredPaymentActions(
paymentId = paymentId,
optionId = selectedOption.id
)
actionsResult.onSuccess { actions ->
actions.forEach { action ->
when (action) {
is Pay.RequiredAction.WalletRpc -> {
val rpcAction = action.action
// rpcAction.chainId - e.g., "eip155:8453"
// rpcAction.method - e.g., "eth_signTypedData_v4" or "personal_sign"
// rpcAction.params - JSON string with signing parameters
}
}
}
}.onFailure { error ->
handleError(error)
}
```
Sign each action using your wallet's signing implementation:
```kotlin theme={null}
val signatures = actions.map { action ->
when (action) {
is Pay.RequiredAction.WalletRpc -> {
val rpc = action.action
when (rpc.method) {
"eth_signTypedData_v4" -> wallet.signTypedData(rpc.chainId, rpc.params)
"personal_sign" -> wallet.personalSign(rpc.chainId, rpc.params)
"eth_sendTransaction" -> wallet.sendTransaction(rpc.chainId, rpc.params)
else -> throw UnsupportedOperationException("Unsupported method: ${rpc.method}")
}
}
}
}
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `rpc.method` (or `action.action.method`) and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Submit the signatures to complete the payment:
```kotlin theme={null}
val confirmResult = WalletConnectPay.confirmPayment(
paymentId = paymentId,
optionId = selectedOption.id,
signatures = signatures
)
confirmResult.onSuccess { response ->
when (response.status) {
Pay.PaymentStatus.SUCCEEDED -> {
// Payment completed successfully
}
Pay.PaymentStatus.PROCESSING -> {
// Payment is being processed
// The SDK automatically polls until final status
}
Pay.PaymentStatus.FAILED -> {
// Payment failed
}
Pay.PaymentStatus.EXPIRED -> {
// Payment expired
}
Pay.PaymentStatus.REQUIRES_ACTION -> {
// Additional action required
}
Pay.PaymentStatus.CANCELLED -> {
// Payment cancelled by user
}
}
}.onFailure { error ->
handleError(error)
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a WebView. The WebView handles form rendering, validation, and T\&C acceptance.
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```kotlin theme={null}
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.WebResourceRequest
import android.content.Intent
import android.net.Uri
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
@Composable
fun PayDataCollectionWebView(
url: String,
onComplete: () -> Unit,
onError: (String) -> Unit
) {
AndroidView(factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowFileAccess = false
addJavascriptInterface(
object {
@JavascriptInterface
fun onDataCollectionComplete(json: String) {
val message = JSONObject(json)
when (message.optString("type")) {
"IC_COMPLETE" -> onComplete()
"IC_ERROR" -> onError(
message.optString("error", "Unknown error")
)
}
}
},
"AndroidWallet"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val requestUrl = request?.url?.toString() ?: return false
// Open external links (T&C, Privacy Policy) in system browser
if (!requestUrl.contains("pay.walletconnect.com")) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(requestUrl)))
return true
}
return false
}
}
loadUrl(url)
}
})
}
fun buildFormUrl(
baseUrl: String,
prefillData: Map = emptyMap(),
theme: String? = null, // "light" or "dark"
themeVariables: String? = null // base64url string exported from the Pay Dashboard
): String {
val builder = Uri.parse(baseUrl).buildUpon()
if (prefillData.isNotEmpty()) {
val prefill = Base64.encodeToString(
JSONObject(prefillData).toString().toByteArray(),
Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING
)
builder.appendQueryParameter("prefill", prefill)
}
theme?.let { builder.appendQueryParameter("theme", it) }
themeVariables?.let { builder.appendQueryParameter("themeVariables", it) }
return builder.build().toString()
}
```
## Complete Example
Here's a complete implementation example using a ViewModel:
```kotlin theme={null}
import com.walletconnect.pay.Pay
import com.walletconnect.pay.WalletConnectPay
import kotlinx.coroutines.launch
class PaymentViewModel : ViewModel() {
fun initializeSdk() {
WalletConnectPay.initialize(
Pay.SdkConfig(
appId = "your-wcp-id", // Your WCP ID
packageName = "com.your.app"
)
)
}
fun processPayment(paymentLink: String, walletAddress: String) {
viewModelScope.launch {
// Step 1: Get payment options
val optionsResult = WalletConnectPay.getPaymentOptions(
paymentLink = paymentLink,
accounts = listOf(
"eip155:1:$walletAddress",
"eip155:8453:$walletAddress",
"eip155:10:$walletAddress"
)
)
optionsResult.onSuccess { response ->
val paymentId = response.paymentId
val selectedOption = response.options.first()
// Step 2: Collect data if required for selected option (via WebView).
// IC must happen BEFORE fetching the required actions — the WebView
// submits the data directly to the backend.
selectedOption.collectData?.url?.let { webViewUrl ->
// Show WebView and wait for IC_COMPLETE
showDataCollectionWebView(webViewUrl)
return@launch // Resume after WebView completes
}
// Step 3: Get required actions
val actionsResult = WalletConnectPay.getRequiredPaymentActions(
paymentId = paymentId,
optionId = selectedOption.id
)
actionsResult.onSuccess { actions ->
// Step 4: Sign actions
val signatures = signActions(actions)
// Step 5: Confirm payment
val confirmResult = WalletConnectPay.confirmPayment(
paymentId = paymentId,
optionId = selectedOption.id,
signatures = signatures
)
confirmResult.onSuccess { confirmation ->
handlePaymentStatus(confirmation.status)
}.onFailure { error ->
handleError(error)
}
}.onFailure { error ->
handleError(error)
}
}.onFailure { error ->
handleError(error)
}
}
}
private suspend fun signActions(actions: List): List {
return actions.map { action ->
when (action) {
is Pay.RequiredAction.WalletRpc -> {
// Implement signing logic using your wallet
signWithWallet(action.action)
}
}
}
}
private fun handlePaymentStatus(status: Pay.PaymentStatus) {
when (status) {
Pay.PaymentStatus.SUCCEEDED -> showSuccess()
Pay.PaymentStatus.PROCESSING -> showProcessing()
Pay.PaymentStatus.FAILED -> showFailure()
Pay.PaymentStatus.EXPIRED -> showExpired()
Pay.PaymentStatus.REQUIRES_ACTION -> { /* Handle additional actions */ }
Pay.PaymentStatus.CANCELLED -> showCancelled()
}
}
}
```
## API Reference
**WalletConnectPay**
Main entry point for the Pay SDK (singleton object).
**Properties**
| Property | Type | Description |
| --------------- | --------- | ------------------------------------ |
| `isInitialized` | `Boolean` | Whether the SDK has been initialized |
**Methods**
| Method | Description |
| ------------------------------------------------- | -------------------------------- |
| `initialize(config: Pay.SdkConfig)` | Initialize the SDK |
| `getPaymentOptions(paymentLink, accounts)` | Get available payment options |
| `getRequiredPaymentActions(paymentId, optionId)` | Get actions requiring signatures |
| `confirmPayment(paymentId, optionId, signatures)` | Confirm and finalize payment |
## Data Models
**Pay.PaymentOptionsResponse**
```kotlin theme={null}
data class PaymentOptionsResponse(
val info: PaymentInfo?, // Payment metadata
val options: List, // Available payment options
val paymentId: String, // Unique payment identifier
val collectDataAction: CollectDataAction?, // Data collection requirements
val resultInfo: PaymentResultInfo? // Transaction result details (present when payment already completed)
)
data class PaymentResultInfo(
val txId: String, // Transaction ID
val optionAmount: Amount // Token amount details
)
```
**Pay.PaymentInfo**
```kotlin theme={null}
data class PaymentInfo(
val status: PaymentStatus,
val amount: Amount,
val expiresAt: Long, // Unix timestamp
val merchant: MerchantInfo
)
data class MerchantInfo(
val name: String,
val iconUrl: String?
)
```
**Pay.PaymentOption**
```kotlin theme={null}
data class PaymentOption(
val id: String,
val amount: Amount,
val account: String, // CAIP-10 account that can pay
val estimatedTxs: Int?, // Estimated number of transactions
val collectData: CollectDataAction? // Per-option data collection (null if not required)
)
```
**Pay.Amount**
```kotlin theme={null}
data class Amount(
val value: String,
val unit: String,
val display: AmountDisplay?
)
data class AmountDisplay(
val assetSymbol: String,
val assetName: String,
val decimals: Int,
val iconUrl: String?,
val networkName: String?,
val networkIconUrl: String?
)
```
**Pay.WalletRpcAction**
```kotlin theme={null}
data class WalletRpcAction(
val chainId: String, // CAIP-2 chain ID (e.g., "eip155:8453")
val method: String, // RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
val params: String // JSON-encoded parameters
)
```
**Pay.RequiredAction**
```kotlin theme={null}
sealed class RequiredAction {
data class WalletRpc(val action: WalletRpcAction) : RequiredAction()
}
```
**Pay.CollectDataAction**
```kotlin theme={null}
data class CollectDataAction(
val url: String, // WebView URL for data collection
val schema: String? // JSON schema describing required fields
)
```
**Pay.ConfirmPaymentResponse**
```kotlin theme={null}
data class ConfirmPaymentResponse(
val status: PaymentStatus,
val isFinal: Boolean,
val pollInMs: Long?,
val info: PaymentResultInfo? // Transaction result details (present on success)
)
```
**Pay.PaymentStatus**
| Status | Description |
| ----------------- | ------------------------- |
| `REQUIRES_ACTION` | Additional action needed |
| `PROCESSING` | Payment in progress |
| `SUCCEEDED` | Payment completed |
| `FAILED` | Payment failed |
| `EXPIRED` | Payment expired |
| `CANCELLED` | Payment cancelled by user |
## Error Handling
The SDK provides typed errors for different failure scenarios:
**GetPaymentOptionsError**
| Error | Description |
| -------------------- | --------------------------- |
| `InvalidPaymentLink` | Invalid payment link format |
| `PaymentExpired` | Payment has expired |
| `PaymentNotFound` | Payment ID doesn't exist |
| `InvalidRequest` | Invalid request parameters |
| `InvalidAccount` | Invalid account format |
| `ComplianceFailed` | Compliance check failed |
| `Http` | Network error |
| `InternalError` | Server error |
**GetPaymentRequestError**
| Error | Description |
| ----------------- | ----------------------------- |
| `OptionNotFound` | Selected option doesn't exist |
| `PaymentNotFound` | Payment ID doesn't exist |
| `InvalidAccount` | Invalid account format |
| `Http` | Network error |
**ConfirmPaymentError**
| Error | Description |
| ------------------ | ----------------------------- |
| `PaymentNotFound` | Payment ID doesn't exist |
| `PaymentExpired` | Payment has expired |
| `InvalidOption` | Invalid option ID |
| `InvalidSignature` | Signature verification failed |
| `RouteExpired` | Payment route expired |
| `Http` | Network error |
**Example Error Handling**
```kotlin theme={null}
val result = WalletConnectPay.getPaymentOptions(paymentLink, accounts)
result.onFailure { error ->
when (error) {
is Pay.GetPaymentOptionsError.InvalidPaymentLink -> {
showError("Invalid payment link")
}
is Pay.GetPaymentOptionsError.PaymentExpired -> {
showError("Payment has expired")
}
is Pay.GetPaymentOptionsError.PaymentNotFound -> {
showError("Payment not found")
}
is Pay.GetPaymentOptionsError.InvalidAccount -> {
showError("Invalid account address")
}
is Pay.GetPaymentOptionsError.ComplianceFailed -> {
showError("Compliance check failed")
}
is Pay.GetPaymentOptionsError.Http -> {
showError("Network error: ${error.message}")
}
else -> {
showError("An error occurred: ${error.message}")
}
}
}
```
## Best Practices
1. **Initialize once**: Call `initialize()` only once, typically in `Application.onCreate()`
2. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
3. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
4. **Signature Order**: Maintain the same order of signatures as the actions array
5. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
6. **Thread Safety**: Events are delivered on IO dispatcher; update UI on main thread
7. **WebView Data Collection**: When `selectedOption.collectData?.url` is present, display the URL in a WebView rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
8. **Per-Option Data Collection**: When displaying payment options, check each option's `collectData` field. Show a visual indicator (e.g., "Info required" badge) on options that require data collection. Only open the WebView when the user selects an option with `collectData` present — use the option's `collectData.url` which is already scoped to that option's account.
# WalletConnect Pay SDK - React Native
Source: https://docs.walletconnect.com/payments/wallets/standalone/react-native
Integrate WalletConnect Pay into your React Native wallet to enable seamless crypto payments for your users.
The WalletConnect Pay SDK allows wallet users to pay merchants using their crypto assets. The SDK handles payment option discovery, permit signing coordination, and payment confirmation while leveraging your wallet's existing signing infrastructure.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference React Native wallet app demonstrating WalletConnect Pay integration.
## Requirements
* React Native 0.70+
* `@walletconnect/react-native-compat` installed and linked
## Installation
Install the WalletConnect Pay SDK using npm or yarn:
```bash theme={null}
npm install @walletconnect/pay
```
```bash theme={null}
yarn add @walletconnect/pay
```
**React Native Setup**
This SDK requires the WalletConnect React Native native module. Make sure you have `@walletconnect/react-native-compat` installed and linked in your React Native project:
```bash theme={null}
npm install @walletconnect/react-native-compat
```
## Architecture
The SDK uses a provider abstraction that allows different implementations:
* **NativeProvider**: Uses React Native uniffi module (current)
* **WasmProvider**: Uses WebAssembly module (coming soon for web browsers)
The SDK auto-detects the best available provider for your environment.
## Initialization
Initialize the WalletConnect Pay client with your credentials:
```typescript theme={null}
import { WalletConnectPay } from "@walletconnect/pay";
const client = new WalletConnectPay({
appId: "your-wcp-id",
// OR use apiKey instead:
// apiKey: "your-api-key",
});
```
**Configuration Parameters**
| Parameter | Type | Required | Description |
| ---------- | -------- | -------- | ------------------------------- |
| `appId` | `string` | No\* | WCP ID for authentication |
| `apiKey` | `string` | No\* | API key for authentication |
| `clientId` | `string` | No | Client ID for tracking |
| `baseUrl` | `string` | No | Custom API base URL |
| `logger` | `Logger` | No | Custom logger instance or level |
Either `appId` or `apiKey` must be provided for authentication.
Don't have a project ID? Create one at the [WalletConnect Dashboard](https://dashboard.walletconnect.com) by signing up and creating a new project.
## Supported Networks & Tokens
WalletConnect Pay currently supports the following tokens and networks:
| Token | Network | Chain ID | CAIP-10 Format |
| ----- | -------- | -------- | ------------------------ |
| USDC | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDC | Base | 8453 | `eip155:8453:{address}` |
| USDC | Polygon | 137 | `eip155:137:{address}` |
| USDC | Ethereum | 1 | `eip155:1:{address}` |
| USDC | Optimism | 10 | `eip155:10:{address}` |
| USDC | Monad | 143 | `eip155:143:{address}` |
| USDC | Celo | 42220 | `eip155:42220:{address}` |
| USDC | BSC | 56 | `eip155:56:{address}` |
| EURC | Ethereum | 1 | `eip155:1:{address}` |
| EURC | Base | 8453 | `eip155:8453:{address}` |
| USDT0 | Arbitrum | 42161 | `eip155:42161:{address}` |
| PYUSD | Ethereum | 1 | `eip155:1:{address}` |
| PYUSD | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDG | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Polygon | 137 | `eip155:137:{address}` |
| USDT | BSC | 56 | `eip155:56:{address}` |
Include accounts for all supported networks to maximize payment options for your users.
## Payment Flow
The payment flow consists of five main steps:
**Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant PaySDK as Pay SDK
participant Backend as WalletConnect Pay
participant WebView
User->>Wallet: Scan QR / Open payment link
Wallet->>PaySDK: getPaymentOptions(link, accounts)
PaySDK->>Backend: Fetch payment options
Backend-->>PaySDK: Payment options + merchant info
PaySDK-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Selected option requires data collection
Wallet->>WebView: Load selectedOption.collectData.url
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>PaySDK: getRequiredPaymentActions(paymentId, optionId)
PaySDK->>Backend: Get signing actions
Backend-->>PaySDK: Required wallet RPC actions
PaySDK-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>PaySDK: confirmPayment(signatures)
PaySDK->>Backend: Submit payment
Backend-->>PaySDK: Payment status
PaySDK-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
When a user scans a payment QR code or opens a payment link, fetch available payment options:
```typescript theme={null}
const options = await client.getPaymentOptions({
paymentLink: "https://pay.walletconnect.com/pay_123",
accounts: [
`eip155:1:${walletAddress}`, // Ethereum Mainnet
`eip155:8453:${walletAddress}`, // Base
],
includePaymentInfo: true,
});
console.log("Payment ID:", options.paymentId);
console.log("Options:", options.options);
// Display merchant info
if (options.info) {
console.log("Merchant:", options.info.merchant.name);
console.log("Amount:", options.info.amount.display.assetSymbol, options.info.amount.value);
}
// Check which options require data collection
for (const option of options.options) {
if (option.collectData) {
console.log(`Option ${option.id} requires info capture`);
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```typescript theme={null}
// Check per-option data collection requirement after user selects an option
if (selectedOption.collectData?.url) {
// Use the "required" list from selectedOption.collectData.schema to determine which fields to prefill
const prefillData = {
fullName: "John Doe",
dob: "1990-01-15",
pobAddress: "123 Main St, New York, NY 10001",
};
// Encode prefill as base64url (URL-safe, no padding)
const prefill = btoa(JSON.stringify(prefillData))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// Optional appearance params (see "Form URL parameters"):
// theme=light|dark — base color mode
// themeVariables= — exported from the WalletConnect Pay Dashboard
const themeVariables = "";
const query = `prefill=${prefill}&theme=dark&themeVariables=${themeVariables}`;
const separator = selectedOption.collectData.url.includes("?") ? "&" : "?";
const webViewUrl = `${selectedOption.collectData.url}${separator}${query}`;
// Show WebView for this specific option — see Data Collection Implementation section below
showDataCollectionWebView(webViewUrl);
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
After the user selects a payment option, get the wallet RPC actions required to complete the payment:
```typescript theme={null}
const actions = await client.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: options.options[0].id,
});
// Each action contains wallet RPC data to sign
for (const action of actions) {
console.log("Chain:", action.walletRpc.chainId);
console.log("Method:", action.walletRpc.method);
console.log("Params:", action.walletRpc.params);
}
```
Sign each action with your wallet's signing implementation:
```typescript theme={null}
// Sign each action based on its RPC method
const signatures = await Promise.all(
actions.map(async (action) => {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
})
);
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Submit the signatures and collected data to complete the payment:
```typescript theme={null}
const result = await client.confirmPayment({
paymentId: options.paymentId,
optionId: options.options[0].id,
signatures,
collectedData, // Include if collectData was present
});
if (result.status === "succeeded") {
console.log("Payment successful!");
} else if (result.status === "processing") {
console.log("Payment is processing...");
} else if (result.status === "failed") {
console.log("Payment failed");
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a WebView using `react-native-webview`. Install the dependency:
```bash theme={null}
npm install react-native-webview@13.16.0
```
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```tsx theme={null}
import React, { useCallback } from "react";
import { WebView, WebViewMessageEvent } from "react-native-webview";
import { Linking, View, ActivityIndicator } from "react-native";
interface PayDataCollectionWebViewProps {
url: string;
onComplete: () => void;
onError: (error: string) => void;
}
function PayDataCollectionWebView({
url,
onComplete,
onError,
}: PayDataCollectionWebViewProps) {
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
try {
const data = JSON.parse(event.nativeEvent.data);
switch (data.type) {
case "IC_COMPLETE":
onComplete();
break;
case "IC_ERROR":
onError(data.error || "Unknown error");
break;
}
} catch {
// Ignore non-JSON messages
}
},
[onComplete, onError]
);
const handleNavigationRequest = useCallback(
(request: { url: string }) => {
// Open external links (T&C, Privacy Policy) in system browser
if (!request.url.includes("pay.walletconnect.com")) {
Linking.openURL(request.url);
return false;
}
return true;
},
[]
);
return (
(
)}
/>
);
}
function buildFormUrl(
baseUrl: string,
options: {
prefill?: Record;
theme?: "light" | "dark";
themeVariables?: string; // base64url string exported from the Pay Dashboard
} = {}
): string {
const params: string[] = [];
if (options.prefill && Object.keys(options.prefill).length > 0) {
// base64url: URL-safe, no padding
const prefill = btoa(JSON.stringify(options.prefill))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
params.push(`prefill=${prefill}`);
}
if (options.theme) params.push(`theme=${options.theme}`);
if (options.themeVariables) params.push(`themeVariables=${options.themeVariables}`);
if (params.length === 0) return baseUrl;
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}${params.join("&")}`;
}
```
## Complete Example
Here's a complete implementation example:
```typescript theme={null}
import { WalletConnectPay, CollectDataFieldResult } from "@walletconnect/pay";
class PaymentManager {
private client: WalletConnectPay;
constructor() {
this.client = new WalletConnectPay({
appId: "your-wcp-id",
});
}
async processPayment(paymentLink: string, walletAddress: string) {
try {
// Step 1: Get payment options
const options = await this.client.getPaymentOptions({
paymentLink,
accounts: [
`eip155:1:${walletAddress}`,
`eip155:137:${walletAddress}`,
`eip155:8453:${walletAddress}`,
],
includePaymentInfo: true,
});
if (options.options.length === 0) {
throw new Error("No payment options available");
}
// Step 2: Let user select an option (simplified - use first option)
const selectedOption = options.options[0];
// Step 3: Collect data via WebView if required for selected option —
// must happen BEFORE fetching the required actions, otherwise the
// backend rejects the request
if (selectedOption.collectData?.url) {
// Show WebView and wait for IC_COMPLETE
await this.showDataCollectionWebView(selectedOption.collectData.url);
}
// Step 4: Get required actions
const actions = await this.client.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: selectedOption.id,
});
// Step 5: Sign all actions
const signatures = await Promise.all(
actions.map((action) =>
this.signAction(action, walletAddress)
)
);
// Step 6: Confirm payment
const result = await this.client.confirmPayment({
paymentId: options.paymentId,
optionId: selectedOption.id,
signatures,
});
return result;
} catch (error) {
console.error("Payment failed:", error);
throw error;
}
}
private async signAction(action: Action, walletAddress: string): Promise {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
}
}
```
## Provider Utilities
The SDK provides utilities for checking provider availability:
```typescript theme={null}
import {
isProviderAvailable,
detectProviderType,
isNativeProviderAvailable,
setNativeModule,
} from "@walletconnect/pay";
// Check if any provider is available
if (isProviderAvailable()) {
// SDK can be used
}
// Detect which provider type is available
const providerType = detectProviderType(); // 'native' | 'wasm' | null
// Check specifically for native provider
if (isNativeProviderAvailable()) {
// React Native native module is available
}
// Manually inject native module (if auto-discovery fails)
import { NativeModules } from "react-native";
setNativeModule(NativeModules.RNWalletConnectPay);
```
## API Reference
**WalletConnectPay**
Main client for payment operations.
**Constructor**
```typescript theme={null}
new WalletConnectPay(options: WalletConnectPayOptions)
```
**Methods**
| Method | Description |
| ----------------------------------- | ---------------------------------------- |
| `getPaymentOptions(params)` | Fetch available payment options |
| `getRequiredPaymentActions(params)` | Get signing actions for a payment option |
| `confirmPayment(params)` | Confirm and execute the payment |
| `static isAvailable()` | Check if a provider is available |
**Data Types**
**PaymentStatus**
```typescript theme={null}
type PaymentStatus =
| "requires_action"
| "processing"
| "succeeded"
| "failed"
| "expired";
```
**PayProviderType**
```typescript theme={null}
type PayProviderType = "native" | "wasm";
```
**CollectDataFieldType**
```typescript theme={null}
type CollectDataFieldType = "text" | "date";
```
**Method Parameters**
```typescript theme={null}
interface GetPaymentOptionsParams {
/** Payment link or ID */
paymentLink: string;
/** List of CAIP-10 accounts */
accounts: string[];
/** Whether to include payment info in response */
includePaymentInfo?: boolean;
}
interface GetRequiredPaymentActionsParams {
/** Payment ID */
paymentId: string;
/** Option ID */
optionId: string;
}
interface ConfirmPaymentParams {
/** Payment ID */
paymentId: string;
/** Option ID */
optionId: string;
/** Signatures from wallet RPC calls */
signatures: string[];
}
```
**Response Types**
```typescript theme={null}
interface PaymentOptionsResponse {
/** Payment ID extracted from the payment link */
paymentId: string;
/** Payment information (if includePaymentInfo was true) */
info?: PaymentInfo;
/** Available payment options */
options: PaymentOption[];
/** Data collection requirements (if any) */
collectData?: CollectDataAction;
/** Transaction result details (present when payment already completed) */
resultInfo?: PaymentResultInfo;
}
interface PaymentResultInfo {
/** Transaction ID */
txId: string;
/** Token amount details */
optionAmount: PayAmount;
}
interface ConfirmPaymentResponse {
/** Payment status */
status: PaymentStatus;
/** True if the payment is in a final state */
isFinal: boolean;
/** Time to poll for payment status, in milliseconds */
pollInMs?: number;
}
```
**PaymentOption**
```typescript theme={null}
interface PaymentOption {
/** ID of the option */
id: string;
/** The option's token and amount */
amount: PayAmount;
/** Estimated time to complete the option, in seconds */
etaS: number;
/** Actions required to complete the option */
actions: Action[];
/** Per-option data collection requirements */
collectData?: CollectDataAction;
}
```
**Action**
```typescript theme={null}
interface Action {
walletRpc: WalletRpcAction;
}
interface WalletRpcAction {
/** Chain ID in CAIP-2 format (e.g., "eip155:8453") */
chainId: string;
/** RPC method name (e.g., "eth_signTypedData_v4", "eth_sendTransaction") */
method: string;
/** JSON-encoded params array */
params: string;
}
```
**Amount Types**
```typescript theme={null}
interface PayAmount {
/** Currency unit, prefixed with either "iso4217/" or "caip19/" */
unit: string;
/** Amount value, in the currency unit's minor units */
value: string;
/** Display information for the amount */
display: AmountDisplay;
}
interface AmountDisplay {
/** Ticker/symbol of the asset */
assetSymbol: string;
/** Full name of the asset */
assetName: string;
/** Number of minor decimals of the asset */
decimals: number;
/** URL of the icon of the asset (if token) */
iconUrl?: string;
/** Name of the network of the asset (if token) */
networkName?: string;
}
```
**Payment Info Types**
```typescript theme={null}
interface PaymentInfo {
/** Payment status */
status: PaymentStatus;
/** Amount to be paid */
amount: PayAmount;
/** Payment expiration timestamp, in seconds since epoch */
expiresAt: number;
/** Merchant information */
merchant: MerchantInfo;
/** Buyer information (present if payment has been submitted) */
buyer?: BuyerInfo;
}
interface MerchantInfo {
/** Merchant name */
name: string;
/** Merchant icon URL */
iconUrl?: string;
}
interface BuyerInfo {
/** Account CAIP-10 */
accountCaip10: string;
/** Account provider name */
accountProviderName: string;
/** Account provider icon URL */
accountProviderIcon?: string;
}
```
**Collect Data Types**
```typescript theme={null}
interface CollectDataAction {
/** URL for data collection (displayed in WebView) */
url: string;
/** JSON schema describing required fields */
schema?: string;
}
```
## Error Handling
The SDK throws typed errors for different failure scenarios:
```typescript theme={null}
import {
PayError,
PaymentOptionsError,
PaymentActionsError,
ConfirmPaymentError,
NativeModuleNotFoundError
} from "@walletconnect/pay";
try {
const options = await client.getPaymentOptions({
paymentLink: link,
accounts,
});
} catch (error) {
if (error instanceof PaymentOptionsError) {
console.error("Failed to get options:", error.originalMessage);
} else if (error instanceof PayError) {
console.error("Pay error:", error.code, error.message);
}
}
```
**Error Types**
| Error Class | Description |
| --------------------------- | -------------------------------------------- |
| `PayError` | Base error class for all Pay SDK errors |
| `PaymentOptionsError` | Error when fetching payment options |
| `PaymentActionsError` | Error when fetching required payment actions |
| `ConfirmPaymentError` | Error when confirming payment |
| `NativeModuleNotFoundError` | Error when native module is not available |
**Error Codes**
The `PayError` class includes a `code` property with one of the following values:
```typescript theme={null}
type PayErrorCode =
| "JSON_PARSE"
| "JSON_SERIALIZE"
| "PAYMENT_OPTIONS"
| "PAYMENT_REQUEST"
| "CONFIRM_PAYMENT"
| "NATIVE_MODULE_NOT_FOUND"
| "INITIALIZATION_ERROR"
| "UNKNOWN";
```
## Best Practices
1. **Check Provider Availability**: Always check if a provider is available before using the SDK
2. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
3. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
4. **Signature Order**: Maintain the same order of signatures as the actions array
5. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
6. **Loading States**: Show loading indicators during API calls and signing operations
7. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
8. **User Data**: Only collect data when `collectData` is present on the selected payment option and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
9. **WebView Data Collection**: When `selectedOption.collectData?.url` is present, display the URL in a WebView using `react-native-webview` rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
10. **Per-Option Data Collection**: When displaying payment options, check each option's `collectData` field. Show a visual indicator (e.g., "Info required" badge) on options that require data collection. Only open the WebView when the user selects an option with `collectData` present — use the option's `collectData.url` which is already scoped to that option's account.
# WalletConnect Pay SDK - Swift
Source: https://docs.walletconnect.com/payments/wallets/standalone/swift
Integrate WalletConnect Pay into your iOS wallet to enable seamless crypto payments for your users.
The WalletConnect Pay SDK allows wallet users to pay merchants using their crypto assets. The SDK handles payment option discovery, permit signing coordination, and payment confirmation while leveraging your wallet's existing signing infrastructure.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference iOS wallet app demonstrating WalletConnect Pay integration.
## Requirements
* iOS 13.0+
* Swift 5.7+
* Xcode 14.0+
## Installation
**Swift Package Manager**
Add WalletConnectPay to your `Package.swift`:
```swift theme={null}
dependencies: [
.package(url: "https://github.com/reown-com/reown-swift", from: "1.0.0")
]
```
Then add `WalletConnectPay` to your target dependencies:
```swift theme={null}
.target(
name: "YourApp",
dependencies: ["WalletConnectPay"]
)
```
The version shown above may not be the latest. Check the [GitHub releases](https://github.com/reown-com/reown-swift/releases) for the most recent version.
## Initialization
Configure the Pay client during app initialization, typically in your `AppDelegate` or `SceneDelegate`:
```swift theme={null}
import WalletConnectPay
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) {
// Option 1: With appId (recommended for wallets)
WalletConnectPay.configure(
appId: "your-wcp-id",
logging: true
)
// Option 2: With API key
WalletConnectPay.configure(
apiKey: "your-pay-api-key"
)
}
```
**Configuration Parameters**
| Parameter | Type | Required | Default | Description |
| --------- | --------- | -------- | -------------- | ------------------------------ |
| `apiKey` | `String?` | No\* | `nil` | Your WalletConnect Pay API key |
| `appId` | `String?` | No\* | `nil` | Your WCP ID |
| `baseUrl` | `String` | No | Production URL | Custom API URL |
| `logging` | `Bool` | No | `false` | Enable debug logging |
At least one of `apiKey` or `appId` must be provided.
Don't have a project ID? Create one at the [WalletConnect Dashboard](https://dashboard.walletconnect.com) by signing up and creating a new project.
## Supported Networks & Tokens
WalletConnect Pay currently supports the following tokens and networks:
| Token | Network | Chain ID | CAIP-10 Format |
| ----- | -------- | -------- | ------------------------ |
| USDC | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDC | Base | 8453 | `eip155:8453:{address}` |
| USDC | Polygon | 137 | `eip155:137:{address}` |
| USDC | Ethereum | 1 | `eip155:1:{address}` |
| USDC | Optimism | 10 | `eip155:10:{address}` |
| USDC | Monad | 143 | `eip155:143:{address}` |
| USDC | Celo | 42220 | `eip155:42220:{address}` |
| USDC | BSC | 56 | `eip155:56:{address}` |
| EURC | Ethereum | 1 | `eip155:1:{address}` |
| EURC | Base | 8453 | `eip155:8453:{address}` |
| USDT0 | Arbitrum | 42161 | `eip155:42161:{address}` |
| PYUSD | Ethereum | 1 | `eip155:1:{address}` |
| PYUSD | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDG | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Polygon | 137 | `eip155:137:{address}` |
| USDT | BSC | 56 | `eip155:56:{address}` |
Include accounts for all supported networks to maximize payment options for your users.
## Payment Link Detection
The `isPaymentLink` utility method detects WalletConnect Pay links by checking for:
* `pay.` hosts (e.g., pay.walletconnect.com)
* `pay=` parameter in WalletConnect URIs
* `pay_` prefix in bare payment IDs
```swift theme={null}
func isPaymentLink(_ string: String) -> Bool {
let lower = string.lowercased()
return lower.contains("pay.") ||
lower.contains("pay=") ||
lower.contains("pay_")
}
```
Call it wherever your wallet receives a link — from a deep link or a scanned QR code:
```swift theme={null}
// Deep link opened from outside your app (SceneDelegate or AppDelegate)
func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
guard let url = URLContexts.first?.url else { return }
if isPaymentLink(url.absoluteString) {
startPaymentFlow(paymentLink: url.absoluteString)
}
}
// QR code payload
func handleScannedQR(_ content: String) {
if isPaymentLink(content) {
startPaymentFlow(paymentLink: content)
}
}
```
## Payment Flow
The payment flow consists of five main steps:
**Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant PaySDK as Pay SDK
participant Backend as WalletConnect Pay
participant WebView
User->>Wallet: Scan QR / Open payment link
Wallet->>PaySDK: getPaymentOptions(link, accounts)
PaySDK->>Backend: Fetch payment options
Backend-->>PaySDK: Payment options + merchant info
PaySDK-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Selected option requires data collection
Wallet->>WebView: Load selectedOption.collectData.url
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>PaySDK: getRequiredPaymentActions(paymentId, optionId)
PaySDK->>Backend: Get signing actions
Backend-->>PaySDK: Required wallet RPC actions
PaySDK-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>PaySDK: confirmPayment(signatures)
PaySDK->>Backend: Submit payment
Backend-->>PaySDK: Payment status
PaySDK-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
When a user scans a payment QR code or opens a payment link, fetch available payment options:
```swift theme={null}
let paymentLink = "https://pay.walletconnect.com/?pid=pay_abc123..."
// Provide all user's EVM accounts in CAIP-10 format
let accounts = [
"eip155:1:\(walletAddress)", // Ethereum Mainnet
"eip155:137:\(walletAddress)", // Polygon
"eip155:8453:\(walletAddress)", // Base
"eip155:42161:\(walletAddress)" // Arbitrum
]
do {
let response = try await WalletConnectPay.instance.getPaymentOptions(
paymentLink: paymentLink,
accounts: accounts
)
// Display merchant info
if let info = response.info {
print("Merchant: \(info.merchant.name)")
print("Amount: \(info.amount.display.assetSymbol) \(info.amount.value)")
}
// Show available payment options to user
for option in response.options {
print("Pay with \(option.amount.display.assetSymbol) on \(option.amount.display.networkName ?? "Unknown")")
}
// Check which options require data collection
for option in response.options {
if option.collectData != nil {
print("Option \(option.id) requires info capture")
}
}
} catch {
print("Failed to get payment options: \(error)")
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```swift theme={null}
// Check per-option data collection requirement after user selects an option
if let collectData = selectedOption.collectData, let url = collectData.url {
// Build prefill URL with known user data
// Use the "required" list from collectData.schema to determine which fields to prefill
let prefillData: [String: String] = [
"fullName": "John Doe",
"dob": "1990-01-15",
"pobAddress": "123 Main St, New York, NY 10001"
]
let jsonData = try JSONSerialization.data(withJSONObject: prefillData)
// Encode prefill as base64url (URL-safe, no padding)
let prefillBase64 = jsonData.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
var components = URLComponents(string: url)!
var queryItems = components.queryItems ?? []
queryItems.append(URLQueryItem(name: "prefill", value: prefillBase64))
// Optional appearance params (see "Form URL parameters"):
queryItems.append(URLQueryItem(name: "theme", value: "dark")) // "light" | "dark"
// themeVariables is a base64url string exported from the WalletConnect Pay Dashboard:
// queryItems.append(URLQueryItem(name: "themeVariables", value: themeVariables))
components.queryItems = queryItems
let webViewUrl = components.string!
// Show WebView for this specific option — see Data Collection Implementation section below
showWebView(url: webViewUrl)
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
After the user selects a payment option, get the signing actions:
```swift theme={null}
let actions = try await WalletConnectPay.instance.getRequiredPaymentActions(
paymentId: response.paymentId,
optionId: selectedOption.id
)
```
Each action contains a `walletRpc` describing the RPC call to execute. Your wallet must check the `method` field and dispatch accordingly — for example, a payment option may require an `eth_sendTransaction` to approve a token allowance followed by an `eth_signTypedData_v4` to sign Permit2 typed data.
```swift theme={null}
var signatures: [String] = []
for action in actions {
let rpc = action.walletRpc
// rpc.chainId - The chain to execute on (e.g., "eip155:8453")
// rpc.method - The RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
// rpc.params - JSON string with method-specific parameters
let result: String
switch rpc.method {
case "eth_signTypedData_v4":
let paramsData = rpc.params.data(using: .utf8)!
let params = try JSONSerialization.jsonObject(with: paramsData) as! [Any]
let typedDataJson = params[1] as! String
result = try await yourWallet.signTypedData(
typedData: typedDataJson,
address: walletAddress,
chainId: rpc.chainId
)
case "eth_sendTransaction":
result = try await yourWallet.sendTransaction(
params: rpc.params,
chainId: rpc.chainId
)
case "personal_sign":
result = try await yourWallet.personalSign(
params: rpc.params,
chainId: rpc.chainId
)
default:
throw PaymentError.unsupportedMethod(rpc.method)
}
signatures.append(result)
}
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Submit the signatures to complete the payment:
When using the WebView approach for data collection, the WebView submits the captured data directly to the backend, so you do **not** pass `collectedData` to `confirmPayment`.
```swift theme={null}
let result = try await WalletConnectPay.instance.confirmPayment(
paymentId: response.paymentId,
optionId: selectedOption.id,
signatures: signatures,
maxPollMs: 60000 // Wait up to 60 seconds for confirmation
)
switch result.status {
case .succeeded:
print("Payment successful!")
case .processing:
print("Payment is being processed...")
case .failed:
print("Payment failed")
case .expired:
print("Payment expired")
case .requiresAction:
print("Additional action required")
case .cancelled:
print("Payment cancelled")
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a `WKWebView`. The WebView handles form rendering, validation, and T\&C acceptance.
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```swift theme={null}
import WebKit
import SwiftUI
struct PayDataCollectionWebView: UIViewRepresentable {
let url: URL
let onComplete: () -> Void
let onError: (String) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onComplete: onComplete, onError: onError)
}
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.userContentController.add(
context.coordinator,
name: "payDataCollectionComplete"
)
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let onComplete: () -> Void
let onError: (String) -> Void
init(onComplete: @escaping () -> Void, onError: @escaping (String) -> Void) {
self.onComplete = onComplete
self.onError = onError
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let body = message.body as? String,
let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return }
DispatchQueue.main.async {
switch type {
case "IC_COMPLETE":
self.onComplete()
case "IC_ERROR":
let error = json["error"] as? String ?? "Unknown error"
self.onError(error)
default:
break
}
}
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
// Open external links (T&C, Privacy Policy) in Safari
if let host = url.host, !host.contains("pay.walletconnect.com") {
UIApplication.shared.open(url)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
}
}
func buildFormUrl(
baseUrl: String,
prefillData: [String: String] = [:],
theme: String? = nil, // "light" or "dark"
themeVariables: String? = nil // base64url string exported from the Pay Dashboard
) -> String {
guard var components = URLComponents(string: baseUrl) else { return baseUrl }
var queryItems = components.queryItems ?? []
if !prefillData.isEmpty,
let jsonData = try? JSONSerialization.data(withJSONObject: prefillData) {
// base64url: URL-safe, no padding
let prefill = jsonData.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
queryItems.append(URLQueryItem(name: "prefill", value: prefill))
}
if let theme = theme {
queryItems.append(URLQueryItem(name: "theme", value: theme))
}
if let themeVariables = themeVariables {
queryItems.append(URLQueryItem(name: "themeVariables", value: themeVariables))
}
components.queryItems = queryItems
return components.string ?? baseUrl
}
```
## Complete Example
Here's a complete implementation example:
```swift theme={null}
import WalletConnectPay
class PaymentManager {
func processPayment(
paymentLink: String,
walletAddress: String,
signer: YourSignerProtocol
) async throws {
// 1. Get payment options
let accounts = [
"eip155:1:\(walletAddress)",
"eip155:137:\(walletAddress)",
"eip155:8453:\(walletAddress)"
]
let optionsResponse = try await WalletConnectPay.instance.getPaymentOptions(
paymentLink: paymentLink,
accounts: accounts
)
guard !optionsResponse.options.isEmpty else {
throw PaymentError.noOptionsAvailable
}
// 2. Let user select an option (simplified - use first option)
let selectedOption = optionsResponse.options[0]
// 3. Collect data via WebView if required (before fetching actions)
if let collectData = selectedOption.collectData, let url = collectData.url {
// Show WebView and wait for IC_COMPLETE message
try await showDataCollectionWebView(url: url)
}
// 4. Get required actions
let actions = try await WalletConnectPay.instance.getRequiredPaymentActions(
paymentId: optionsResponse.paymentId,
optionId: selectedOption.id
)
// 5. Sign all actions
var signatures: [String] = []
for action in actions {
let signature = try await signAction(
action: action,
walletAddress: walletAddress,
signer: signer
)
signatures.append(signature)
}
// 6. Confirm payment
let result = try await WalletConnectPay.instance.confirmPayment(
paymentId: optionsResponse.paymentId,
optionId: selectedOption.id,
signatures: signatures
)
switch result.status {
case .succeeded:
break // Success
case .cancelled:
throw PaymentError.paymentCancelled
default:
throw PaymentError.paymentFailed(result.status)
}
}
private func signAction(
action: Action,
walletAddress: String,
signer: YourSignerProtocol
) async throws -> String {
let rpc = action.walletRpc
switch rpc.method {
case "eth_signTypedData_v4":
guard let paramsData = rpc.params.data(using: .utf8),
let params = try JSONSerialization.jsonObject(with: paramsData) as? [Any],
params.count >= 2,
let typedDataJson = params[1] as? String else {
throw PaymentError.invalidParams
}
return try await signer.signTypedData(
data: typedDataJson,
address: walletAddress
)
case "eth_sendTransaction":
return try await signer.sendTransaction(
params: rpc.params,
chainId: rpc.chainId
)
case "personal_sign":
return try await signer.personalSign(
params: rpc.params,
address: walletAddress
)
default:
throw PaymentError.unsupportedMethod(rpc.method)
}
}
}
```
## API Reference
**WalletConnectPay**
Static configuration class for the Pay SDK.
| Method | Description |
| ------------------------------------------ | ---------------------------------------- |
| `configure(apiKey:appId:baseUrl:logging:)` | Initialize the SDK with your credentials |
| `instance` | Access the shared `PayClient` instance |
**PayClient**
Main client for payment operations.
| Method | Description |
| ------------------------------------------------------------- | ---------------------------------------- |
| `getPaymentOptions(paymentLink:accounts:includePaymentInfo:)` | Fetch available payment options |
| `getRequiredPaymentActions(paymentId:optionId:)` | Get signing actions for a payment option |
| `confirmPayment(paymentId:optionId:signatures:maxPollMs:)` | Confirm and execute the payment |
**Data Types**
**PaymentOptionsResponse**
```swift theme={null}
struct PaymentOptionsResponse {
let paymentId: String // Unique payment identifier
let info: PaymentInfo? // Merchant and amount details
let options: [PaymentOption] // Available payment methods
let collectData: CollectDataAction? // Required user data fields (travel rule)
let resultInfo: PaymentResultInfo? // Transaction result details (present when payment already completed)
}
struct PaymentResultInfo {
let txId: String // Transaction ID
let optionAmount: PayAmount // Token amount details
}
```
**PaymentInfo**
```swift theme={null}
struct PaymentInfo {
let status: PaymentStatus // Current payment status
let amount: PayAmount // Requested payment amount
let expiresAt: Int64 // Expiration timestamp
let merchant: MerchantInfo // Merchant details
let buyer: BuyerInfo? // Buyer info if available
}
```
**PaymentOption**
```swift theme={null}
struct PaymentOption {
let id: String // Option identifier
let amount: PayAmount // Amount in this asset
let etaS: Int64 // Estimated time to complete (seconds)
let actions: [Action] // Required signing actions
let collectData: CollectDataAction? // Per-option data collection (nil if not required)
}
```
**PayAmount**
```swift theme={null}
struct PayAmount {
let unit: String // Asset unit (e.g., "USDC")
let value: String // Raw value in smallest unit
let display: AmountDisplay // Human-readable display info
}
struct AmountDisplay {
let assetSymbol: String // Token symbol (e.g., "USDC")
let assetName: String // Token name (e.g., "USD Coin")
let decimals: Int64 // Token decimals
let iconUrl: String? // Token icon URL
let networkName: String? // Network name (e.g., "Base")
}
```
**Action & WalletRpcAction**
```swift theme={null}
struct Action {
let walletRpc: WalletRpcAction // RPC call to sign
}
struct WalletRpcAction {
let chainId: String // Chain ID (e.g., "eip155:8453")
let method: String // RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
let params: String // JSON-encoded parameters
}
```
**CollectDataAction & CollectDataField**
```swift theme={null}
struct CollectDataAction {
let url: String // WebView URL for data collection
let schema: String? // JSON schema describing required fields
}
```
**ConfirmPaymentResultResponse**
```swift theme={null}
struct ConfirmPaymentResultResponse {
let status: PaymentStatus // Final payment status
let isFinal: Bool // Whether status is final
let pollInMs: Int64? // Suggested poll interval
let info: PaymentResultInfo? // Transaction result details (present on success)
}
enum PaymentStatus {
case requiresAction // Additional action needed
case processing // Payment in progress
case succeeded // Payment completed
case failed // Payment failed
case expired // Payment expired
case cancelled // Payment cancelled by user
}
```
## Error Handling
The SDK throws specific error types for different failure scenarios:
**GetPaymentOptionsError**
| Error | Description |
| ------------------- | -------------------------- |
| `.paymentNotFound` | Payment ID doesn't exist |
| `.paymentExpired` | Payment has expired |
| `.invalidRequest` | Invalid request parameters |
| `.invalidAccount` | Invalid account format |
| `.complianceFailed` | Compliance check failed |
| `.http` | Network error |
| `.internalError` | Server error |
**GetPaymentRequestError**
| Error | Description |
| ------------------ | ----------------------------- |
| `.optionNotFound` | Selected option doesn't exist |
| `.paymentNotFound` | Payment ID doesn't exist |
| `.invalidAccount` | Invalid account format |
| `.http` | Network error |
**ConfirmPaymentError**
| Error | Description |
| ------------------- | ----------------------------- |
| `.paymentNotFound` | Payment ID doesn't exist |
| `.paymentExpired` | Payment has expired |
| `.invalidOption` | Invalid option ID |
| `.invalidSignature` | Signature verification failed |
| `.routeExpired` | Payment route expired |
| `.http` | Network error |
## Best Practices
1. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
2. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
3. **Signature Order**: Maintain the same order of signatures as the actions array
4. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
5. **Loading States**: Show loading indicators during API calls and signing operations
6. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
7. **User Data**: Only collect data when `collectData` is present on the selected payment option and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
8. **WebView Data Collection**: When `selectedOption.collectData?.url` is present, display the URL in a WKWebView rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
9. **Per-Option Data Collection**: When displaying payment options, check each option's `collectData` field. Show a visual indicator (e.g., "Info required" badge) on options that require data collection. Only open the WebView when the user selects an option with `collectData` present — use the option's `collectData.url` which is already scoped to that option's account.
# WalletConnect Pay SDK - Web/Node.js
Source: https://docs.walletconnect.com/payments/wallets/standalone/web
Integrate WalletConnect Pay into your Web/Node.js wallet to enable seamless crypto payments for your users.
The WalletConnect Pay SDK allows wallet users to pay merchants using their crypto assets. The SDK handles payment option discovery, permit signing coordination, and payment confirmation while leveraging your wallet's existing signing infrastructure.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference web wallet app demonstrating WalletConnect Pay integration.
## Requirements
* Node.js 16+
## Installation
Install the WalletConnect Pay SDK using npm or yarn:
```bash theme={null}
npm install @walletconnect/pay
```
```bash theme={null}
yarn add @walletconnect/pay
```
## Architecture
The SDK uses a provider abstraction that allows different implementations:
* **WasmProvider**: Uses WebAssembly module for web browsers
* **NativeProvider**: Uses platform-specific uniffi module (for React Native environments)
The SDK auto-detects the best available provider for your environment.
## Initialization
Initialize the WalletConnect Pay client with your credentials:
```typescript theme={null}
import { WalletConnectPay } from "@walletconnect/pay";
const client = new WalletConnectPay({
appId: "your-wcp-id",
// OR use apiKey instead:
// apiKey: "your-api-key",
});
```
**Configuration Parameters**
| Parameter | Type | Required | Description |
| ---------- | -------- | -------- | ------------------------------- |
| `appId` | `string` | No\* | WCP ID for authentication |
| `apiKey` | `string` | No\* | API key for authentication |
| `clientId` | `string` | No | Client ID for tracking |
| `baseUrl` | `string` | No | Custom API base URL |
| `logger` | `Logger` | No | Custom logger instance or level |
Either `appId` or `apiKey` must be provided for authentication.
Don't have a project ID? Create one at the [WalletConnect Dashboard](https://dashboard.walletconnect.com) by signing up and creating a new project.
## Supported Networks & Tokens
WalletConnect Pay currently supports the following tokens and networks:
| Token | Network | Chain ID | CAIP-10 Format |
| ----- | -------- | -------- | ------------------------ |
| USDC | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDC | Base | 8453 | `eip155:8453:{address}` |
| USDC | Polygon | 137 | `eip155:137:{address}` |
| USDC | Ethereum | 1 | `eip155:1:{address}` |
| USDC | Optimism | 10 | `eip155:10:{address}` |
| USDC | Monad | 143 | `eip155:143:{address}` |
| USDC | Celo | 42220 | `eip155:42220:{address}` |
| USDC | BSC | 56 | `eip155:56:{address}` |
| EURC | Ethereum | 1 | `eip155:1:{address}` |
| EURC | Base | 8453 | `eip155:8453:{address}` |
| USDT0 | Arbitrum | 42161 | `eip155:42161:{address}` |
| PYUSD | Ethereum | 1 | `eip155:1:{address}` |
| PYUSD | Arbitrum | 42161 | `eip155:42161:{address}` |
| USDG | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Ethereum | 1 | `eip155:1:{address}` |
| USDT | Polygon | 137 | `eip155:137:{address}` |
| USDT | BSC | 56 | `eip155:56:{address}` |
Include accounts for all supported networks to maximize payment options for your users.
## Payment Flow
The payment flow consists of five main steps:
**Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant PaySDK as Pay SDK
participant Backend as WalletConnect Pay
participant Iframe
User->>Wallet: Scan QR / Open payment link
Wallet->>PaySDK: getPaymentOptions(link, accounts)
PaySDK->>Backend: Fetch payment options
Backend-->>PaySDK: Payment options + merchant info
PaySDK-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Selected option requires data collection
Wallet->>Iframe: Load selectedOption.collectData.url in iframe
Iframe->>User: Display data collection form
User->>Iframe: Fill form & accept T&C
Iframe-->>Wallet: IC_COMPLETE message via postMessage
end
Wallet->>PaySDK: getRequiredPaymentActions(paymentId, optionId)
PaySDK->>Backend: Get signing actions
Backend-->>PaySDK: Required wallet RPC actions
PaySDK-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>PaySDK: confirmPayment(signatures)
PaySDK->>Backend: Submit payment
Backend-->>PaySDK: Payment status
PaySDK-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
When a user scans a payment QR code or opens a payment link, fetch available payment options:
```typescript theme={null}
const options = await client.getPaymentOptions({
paymentLink: "https://pay.walletconnect.com/pay_123",
accounts: [
`eip155:1:${walletAddress}`, // Ethereum Mainnet
`eip155:8453:${walletAddress}`, // Base
],
includePaymentInfo: true,
});
console.log("Payment ID:", options.paymentId);
console.log("Options:", options.options);
// Display merchant info
if (options.info) {
console.log("Merchant:", options.info.merchant.name);
console.log("Amount:", options.info.amount.display.assetSymbol, options.info.amount.value);
}
// Check which options require data collection
for (const option of options.options) {
if (option.collectData) {
console.log(`Option ${option.id} requires info capture`);
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```typescript theme={null}
// Check per-option data collection requirement after user selects an option
if (selectedOption.collectData?.url) {
// Use the "required" list from selectedOption.collectData.schema to determine which fields to prefill
const prefillData = {
fullName: "John Doe",
dob: "1990-01-15",
pobAddress: "123 Main St, New York, NY 10001",
};
// Encode prefill as base64url (URL-safe, no padding)
const prefill = btoa(JSON.stringify(prefillData))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// Optional appearance params (see "Form URL parameters"):
// theme=light|dark — base color mode
// themeVariables= — exported from the WalletConnect Pay Dashboard
const themeVariables = "";
const query = `prefill=${prefill}&theme=dark&themeVariables=${themeVariables}`;
const separator = selectedOption.collectData.url.includes("?") ? "&" : "?";
const iframeUrl = `${selectedOption.collectData.url}${separator}${query}`;
// Show data collection in an iframe or popup — see Data Collection Implementation section below
showDataCollectionView(iframeUrl);
}
```
### Iframe Message Types
The iframe communicates with your wallet through `postMessage` events. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
After the user selects a payment option, get the wallet RPC actions required to complete the payment:
```typescript theme={null}
const actions = await client.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: options.options[0].id,
});
// Each action contains wallet RPC data to sign
for (const action of actions) {
console.log("Chain:", action.walletRpc.chainId);
console.log("Method:", action.walletRpc.method);
console.log("Params:", action.walletRpc.params);
}
```
Sign each action with your wallet's signing implementation:
```typescript theme={null}
// Sign each action based on its RPC method
const signatures = await Promise.all(
actions.map(async (action) => {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
})
);
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Submit the signatures and collected data to complete the payment:
```typescript theme={null}
const result = await client.confirmPayment({
paymentId: options.paymentId,
optionId: options.options[0].id,
signatures,
collectedData, // Include if collectData was present
});
if (result.status === "succeeded") {
console.log("Payment successful!");
} else if (result.status === "processing") {
console.log("Payment is processing...");
} else if (result.status === "failed") {
console.log("Payment failed");
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in an iframe or modal. Listen for `postMessage` events to handle completion:
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```typescript theme={null}
function showDataCollectionView(url: string): Promise {
// Create a modal/overlay container
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;";
const iframe = document.createElement("iframe");
iframe.src = url;
iframe.style.cssText = "width:450px;height:650px;border:none;border-radius:12px;";
overlay.appendChild(iframe);
document.body.appendChild(overlay);
return new Promise((resolve, reject) => {
function handleMessage(event: MessageEvent) {
try {
const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
if (data.type === "IC_COMPLETE") {
cleanup();
resolve();
} else if (data.type === "IC_ERROR") {
cleanup();
reject(new Error(data.error || "Unknown error"));
}
} catch {
// Ignore non-JSON messages
}
}
function cleanup() {
window.removeEventListener("message", handleMessage);
document.body.removeChild(overlay);
}
window.addEventListener("message", handleMessage);
});
}
function buildFormUrl(
baseUrl: string,
options: {
prefill?: Record;
theme?: "light" | "dark";
themeVariables?: string; // base64url string exported from the Pay Dashboard
} = {}
): string {
const params: string[] = [];
if (options.prefill && Object.keys(options.prefill).length > 0) {
// base64url: URL-safe, no padding
const prefill = btoa(JSON.stringify(options.prefill))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
params.push(`prefill=${prefill}`);
}
if (options.theme) params.push(`theme=${options.theme}`);
if (options.themeVariables) params.push(`themeVariables=${options.themeVariables}`);
if (params.length === 0) return baseUrl;
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}${params.join("&")}`;
}
```
## Complete Example
Here's a complete implementation example:
```typescript theme={null}
import { WalletConnectPay, CollectDataFieldResult } from "@walletconnect/pay";
class PaymentManager {
private client: WalletConnectPay;
constructor() {
this.client = new WalletConnectPay({
appId: "your-wcp-id",
});
}
async processPayment(paymentLink: string, walletAddress: string) {
try {
// Step 1: Get payment options
const options = await this.client.getPaymentOptions({
paymentLink,
accounts: [
`eip155:1:${walletAddress}`,
`eip155:137:${walletAddress}`,
`eip155:8453:${walletAddress}`,
],
includePaymentInfo: true,
});
if (options.options.length === 0) {
throw new Error("No payment options available");
}
// Step 2: Let user select an option (simplified - use first option)
const selectedOption = options.options[0];
// Step 3: Collect data via iframe if required for selected option.
// This must happen BEFORE fetching required actions, otherwise the
// backend rejects the fetch for options that require info capture.
if (selectedOption.collectData?.url) {
// Show iframe and wait for IC_COMPLETE
await this.showDataCollectionView(selectedOption.collectData.url);
}
// Step 4: Get required actions
const actions = await this.client.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: selectedOption.id,
});
// Step 5: Sign all actions
const signatures = await Promise.all(
actions.map((action) =>
this.signAction(action, walletAddress)
)
);
// Step 6: Confirm payment
const result = await this.client.confirmPayment({
paymentId: options.paymentId,
optionId: selectedOption.id,
signatures,
});
return result;
} catch (error) {
console.error("Payment failed:", error);
throw error;
}
}
private async signAction(action: Action, walletAddress: string): Promise {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
}
}
```
## Provider Utilities
The SDK provides utilities for checking provider availability:
```typescript theme={null}
import {
isProviderAvailable,
detectProviderType,
} from "@walletconnect/pay";
// Check if any provider is available
if (isProviderAvailable()) {
// SDK can be used
}
// Detect which provider type is available
const providerType = detectProviderType(); // 'native' | 'wasm' | null
```
## API Reference
**WalletConnectPay**
Main client for payment operations.
**Constructor**
```typescript theme={null}
new WalletConnectPay(options: WalletConnectPayOptions)
```
**Methods**
| Method | Description |
| ----------------------------------- | ---------------------------------------- |
| `getPaymentOptions(params)` | Fetch available payment options |
| `getRequiredPaymentActions(params)` | Get signing actions for a payment option |
| `confirmPayment(params)` | Confirm and execute the payment |
| `static isAvailable()` | Check if a provider is available |
**Data Types**
**PaymentStatus**
```typescript theme={null}
type PaymentStatus =
| "requires_action"
| "processing"
| "succeeded"
| "failed"
| "expired";
```
**PayProviderType**
```typescript theme={null}
type PayProviderType = "native" | "wasm";
```
**CollectDataFieldType**
```typescript theme={null}
type CollectDataFieldType = "text" | "date";
```
**Method Parameters**
```typescript theme={null}
interface GetPaymentOptionsParams {
/** Payment link or ID */
paymentLink: string;
/** List of CAIP-10 accounts */
accounts: string[];
/** Whether to include payment info in response */
includePaymentInfo?: boolean;
}
interface GetRequiredPaymentActionsParams {
/** Payment ID */
paymentId: string;
/** Option ID */
optionId: string;
}
interface ConfirmPaymentParams {
/** Payment ID */
paymentId: string;
/** Option ID */
optionId: string;
/** Signatures from wallet RPC calls */
signatures: string[];
}
```
**Response Types**
```typescript theme={null}
interface PaymentOptionsResponse {
/** Payment ID extracted from the payment link */
paymentId: string;
/** Payment information (if includePaymentInfo was true) */
info?: PaymentInfo;
/** Available payment options */
options: PaymentOption[];
/** Data collection requirements (if any) */
collectData?: CollectDataAction;
/** Transaction result details (present when payment already completed) */
resultInfo?: PaymentResultInfo;
}
interface PaymentResultInfo {
/** Transaction ID */
txId: string;
/** Token amount details */
optionAmount: PayAmount;
}
interface ConfirmPaymentResponse {
/** Payment status */
status: PaymentStatus;
/** True if the payment is in a final state */
isFinal: boolean;
/** Time to poll for payment status, in milliseconds */
pollInMs?: number;
}
```
**PaymentOption**
```typescript theme={null}
interface PaymentOption {
/** ID of the option */
id: string;
/** The option's token and amount */
amount: PayAmount;
/** Estimated time to complete the option, in seconds */
etaS: number;
/** Actions required to complete the option */
actions: Action[];
/** Per-option data collection requirements */
collectData?: CollectDataAction;
}
```
**Action**
```typescript theme={null}
interface Action {
walletRpc: WalletRpcAction;
}
interface WalletRpcAction {
/** Chain ID in CAIP-2 format (e.g., "eip155:8453") */
chainId: string;
/** RPC method name (e.g., "eth_signTypedData_v4", "eth_sendTransaction") */
method: string;
/** JSON-encoded params array */
params: string;
}
```
**Amount Types**
```typescript theme={null}
interface PayAmount {
/** Currency unit, prefixed with either "iso4217/" or "caip19/" */
unit: string;
/** Amount value, in the currency unit's minor units */
value: string;
/** Display information for the amount */
display: AmountDisplay;
}
interface AmountDisplay {
/** Ticker/symbol of the asset */
assetSymbol: string;
/** Full name of the asset */
assetName: string;
/** Number of minor decimals of the asset */
decimals: number;
/** URL of the icon of the asset (if token) */
iconUrl?: string;
/** Name of the network of the asset (if token) */
networkName?: string;
}
```
**Payment Info Types**
```typescript theme={null}
interface PaymentInfo {
/** Payment status */
status: PaymentStatus;
/** Amount to be paid */
amount: PayAmount;
/** Payment expiration timestamp, in seconds since epoch */
expiresAt: number;
/** Merchant information */
merchant: MerchantInfo;
/** Buyer information (present if payment has been submitted) */
buyer?: BuyerInfo;
}
interface MerchantInfo {
/** Merchant name */
name: string;
/** Merchant icon URL */
iconUrl?: string;
}
interface BuyerInfo {
/** Account CAIP-10 */
accountCaip10: string;
/** Account provider name */
accountProviderName: string;
/** Account provider icon URL */
accountProviderIcon?: string;
}
```
**Collect Data Types**
```typescript theme={null}
interface CollectDataAction {
/** URL for data collection (displayed in iframe) */
url: string;
/** JSON schema describing required fields */
schema?: string;
}
```
## Error Handling
The SDK throws typed errors for different failure scenarios:
```typescript theme={null}
import {
PayError,
PaymentOptionsError,
PaymentActionsError,
ConfirmPaymentError,
} from "@walletconnect/pay";
try {
const options = await client.getPaymentOptions({
paymentLink: link,
accounts,
});
} catch (error) {
if (error instanceof PaymentOptionsError) {
console.error("Failed to get options:", error.originalMessage);
} else if (error instanceof PayError) {
console.error("Pay error:", error.code, error.message);
}
}
```
**Error Types**
| Error Class | Description |
| --------------------- | -------------------------------------------- |
| `PayError` | Base error class for all Pay SDK errors |
| `PaymentOptionsError` | Error when fetching payment options |
| `PaymentActionsError` | Error when fetching required payment actions |
| `ConfirmPaymentError` | Error when confirming payment |
**Error Codes**
The `PayError` class includes a `code` property with one of the following values:
```typescript theme={null}
type PayErrorCode =
| "JSON_PARSE"
| "JSON_SERIALIZE"
| "PAYMENT_OPTIONS"
| "PAYMENT_REQUEST"
| "CONFIRM_PAYMENT"
| "INITIALIZATION_ERROR"
| "UNKNOWN";
```
## Best Practices
1. **Check Provider Availability**: Always check if a provider is available before using the SDK
2. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
3. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
4. **Signature Order**: Maintain the same order of signatures as the actions array
5. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
6. **Loading States**: Show loading indicators during API calls and signing operations
7. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
8. **User Data**: Only collect data when `collectData` is present on the selected payment option and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
9. **Data Collection**: When `selectedOption.collectData?.url` is present, display the URL in an iframe or modal rather than building custom forms. The hosted form handles rendering, validation, and T\&C acceptance.
10. **Per-Option Data Collection**: When displaying payment options, check each option's `collectData` field. Show a visual indicator (e.g., "Info required" badge) on options that require data collection. Only open the iframe when the user selects an option with `collectData` present — use the option's `collectData.url` which is already scoped to that option's account.
# Tap to Pay - Wallet Integration Guide
Source: https://docs.walletconnect.com/payments/wallets/tap-to-pay
Integrate Tap to Pay into your wallet app to enable contactless payments at POS terminals using WalletConnect Pay.
Tap to Pay is an experimental feature. If you're interested in implementing and testing it in your wallet, reach out to our team before getting started.
This guide covers how to integrate Tap to Pay into a third-party wallet app on Android and iOS.
## Android
### How It Works
When a user taps their phone on a POS terminal, the terminal emits an NDEF tag with a single URI record pointing to `https://pay.walletconnect.com`. Android dispatches this in one of three ways:
* **One wallet installed with a verified App Link** — Android opens the wallet's `NfcPaymentActivity` directly.
* **Multiple wallets installed** — Android shows the "Open with..." chooser; user selects a wallet.
* **No wallet installed** — Android opens the URL in the browser, which shows a wallet chooser page with App Store / Play Store links.
When the wallet app is already in the foreground, foreground dispatch takes priority over manifest filters and delivers the intent directly.
#### Flow: Tap from Home Screen (Phone Unlocked, No App in Foreground)
```mermaid theme={null}
sequenceDiagram
participant POS as POS Terminal
participant NFC as Android NFC Stack
participant OS as Android OS
participant W as Wallet App
participant B as Browser
POS->>NFC: NDEF tag emulated (single URI record)
NFC->>OS: NDEF_DISCOVERED intent (matches URI: https://pay.walletconnect.com)
alt 1 wallet with verified App Link
OS->>W: App Link opens NfcPaymentActivity
W->>W: Extract payment URL from intent data
W->>W: Start payment flow
else Multiple wallets installed
OS->>OS: Show Activity Chooser ("Open with...")
OS->>W: User selects wallet
W->>W: Extract payment URL from intent data
W->>W: Start payment flow
else No wallet installed
OS->>B: Open https://pay.walletconnect.com/?pid=pay_xxx
B->>B: Wallet chooser web page (App Store / Play Store links)
end
```
#### Flow: Tap from Wallet App (Foreground)
```mermaid theme={null}
sequenceDiagram
participant POS as POS Terminal
participant NFC as Android NFC
participant W as Wallet App (Foreground)
Note over W: App is open, NFC foreground dispatch active
POS->>NFC: NDEF tag emulated (URI record)
NFC->>W: NDEF_DISCOVERED dispatched to foreground Activity
W->>W: Parse URI record
W->>W: Extract payment URL
W->>W: Start payment flow immediately
```
### Integration Steps
Android App Links require `pay.walletconnect.com` to list your app in its `/.well-known/assetlinks.json` file. Without this, `autoVerify` intent filters won't pass verification.
Provide the following to WalletConnect:
* Package name
* SHA-256 certificate fingerprint(s)
Add NFC permissions:
```xml AndroidManifest.xml theme={null}
```
Register a translucent activity with three intent filters — each covers a different Android dispatch path for the same NFC tap:
```xml AndroidManifest.xml theme={null}
```
Create `res/xml/nfc_tech_filter.xml`:
```xml nfc_tech_filter.xml theme={null}
android.nfc.tech.IsoDepandroid.nfc.tech.Ndefandroid.nfc.tech.Ndefandroid.nfc.tech.NfcAandroid.nfc.tech.NfcB
```
The activity handles multiple intent paths to extract the payment URL:
```kotlin NfcPaymentActivity.kt theme={null}
class NfcPaymentActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val paymentUrl = extractPaymentUrl(intent)
if (paymentUrl != null) openPaymentFlow(paymentUrl)
finish()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val paymentUrl = extractPaymentUrl(intent) ?: return
openPaymentFlow(paymentUrl)
finish()
}
private fun openPaymentFlow(paymentUrl: String) {
startActivity(
Intent(this, YourMainWalletActivity::class.java).apply {
action = ACTION_NFC_PAYMENT
putExtra(EXTRA_PAYMENT_URL, paymentUrl)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
}
private fun extractPaymentUrl(intent: Intent?): String? {
if (intent == null) return null
return when (intent.action) {
// App Links — URL is in intent.data
Intent.ACTION_VIEW ->
intent.data?.let { unwrapPaymentUrl(it.toString()) }
// NDEF tag — URL is in NDEF extras
NfcAdapter.ACTION_NDEF_DISCOVERED ->
extractFromNdefExtras(intent)
// TECH fallback — read NDEF from Tag object
NfcAdapter.ACTION_TECH_DISCOVERED ->
extractFromTag(intent) ?: extractFromNdefExtras(intent)
else -> null
}
}
}
```
When the wallet activity is in the foreground, manifest intent filters may not fire. Use foreground dispatch to claim NFC priority:
```kotlin NfcPaymentReader.kt theme={null}
class NfcPaymentReader(
private val activity: Activity,
private val onPaymentUrl: (String) -> Unit,
) {
private val nfcAdapter = NfcAdapter.getDefaultAdapter(activity)
private val pendingIntent by lazy {
PendingIntent.getActivity(
activity, 0,
Intent(activity, activity.javaClass)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_MUTABLE
)
}
fun enable() {
nfcAdapter?.enableForegroundDispatch(
activity, pendingIntent,
arrayOf(IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)),
arrayOf(
arrayOf(Ndef::class.java.name),
arrayOf(IsoDep::class.java.name)
)
)
}
fun disable() {
nfcAdapter?.disableForegroundDispatch(activity)
}
/** Call from Activity.onNewIntent(). Returns true if handled. */
fun handleIntent(intent: Intent): Boolean {
// Same NDEF/Tag extraction logic as NfcPaymentActivity.
// If a pay.walletconnect.com URL is found, call onPaymentUrl(url) and return true.
// Otherwise return false.
}
}
```
Wire it into your main activity:
```kotlin MainActivity.kt theme={null}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nfcReader = NfcPaymentReader(this) { url -> processPayment(url) }
}
override fun onResume() { super.onResume(); nfcReader.enable() }
override fun onPause() { super.onPause(); nfcReader.disable() }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (nfcReader.handleIntent(intent)) return
// ...other intent handling
}
```
```kotlin PaymentFlow.kt theme={null}
// 1. Fetch payment options
val accounts = listOf("eip155:1:0xABC...", "eip155:137:0xABC...")
val options = WalletKit.Pay.getPaymentOptions(paymentUrl, accounts).getOrThrow()
// 2. Display options to user; user selects one
// 3. Get required signing actions
val actions = WalletKit.Pay.getRequiredPaymentActions(paymentId, optionId).getOrThrow()
// 4. Sign each action
val signatures = actions
.filterIsInstance()
.map { rpc -> yourSigner.sign(rpc.action.method, rpc.action.params) }
// 5. Confirm payment
WalletKit.Pay.confirmPayment(
Wallet.Params.ConfirmPayment(paymentId, optionId, signatures, collectedData)
)
```
## iOS
### How It Works
When a user taps their phone on a POS terminal, iOS reads the NDEF tag automatically (Background Tag Reading, iOS 13+) and resolves the URL as a Universal Link. Depending on what's installed:
* **One wallet registered in AASA** — iOS shows a notification banner "Open in \[Wallet]"; user taps to open.
* **Multiple wallets in AASA** — AASA is evaluated top-to-bottom; first installed match wins (no disambiguation UI). On iOS 26, a system wallet chooser modal is shown instead.
* **No wallet installed** — iOS opens the URL in Safari, which shows a wallet chooser page with App Store links.
When the wallet app is in the foreground, the app can trigger a manual NFC scan via `NFCNDEFReaderSession`, which takes priority over Background Tag Reading.
#### Flow: Tap from Home Screen (Phone Unlocked, No App in Foreground)
```mermaid theme={null}
sequenceDiagram
participant POS as POS Terminal
participant NFC as iOS Background Tag Reading
participant OS as iOS
participant W as Wallet App
participant S as Safari
POS->>NFC: NDEF tag detected (screen on)
NFC->>OS: Parse NDEF records Find URI record with Universal Link
alt 1 wallet registered in AASA
OS->>OS: Show notification banner: "Open in [Wallet]"
OS->>W: User taps notification
W->>W: SceneDelegate receives Universal Link
W->>W: Start payment flow (autoPayMode)
else Multiple wallets in AASA
OS->>OS: AASA evaluated top-to-bottom
OS->>W: First installed match wins (no disambiguation UI)
W->>W: SceneDelegate receives Universal Link
W->>W: Start payment flow (autoPayMode)
else Multiple wallets in AASA (iOS 26)
OS->>OS: Show system wallet chooser modal
OS->>W: User picks a wallet
W->>W: SceneDelegate receives Universal Link
W->>W: Start payment flow (autoPayMode)
else No wallet installed
OS->>S: Open payment URL in Safari
S->>S: Wallet chooser web page (App Store links)
end
```
#### Flow: Tap from Wallet App (Foreground)
```mermaid theme={null}
sequenceDiagram
participant POS as POS Terminal
participant W as Wallet App (Foreground)
participant NFC as NFCNDEFReaderSession
participant Pay as Payment Flow
W->>W: Press NFC scanner button
W->>NFC: onAppear() -> NFCPaymentReader.scan()
NFC->>NFC: Show "Ready to Pay" NFC sheet
Note over NFC: Active reader session takes priority over Background Tag Reading
POS->>NFC: NDEF tag detected
NFC->>NFC: invalidateAfterFirstRead: true
NFC->>W: didDetectNDEFs callback
W->>W: Extract URI from NDEF record
NFC->>NFC: Auto-close NFC sheet
W->>Pay: Present PayModule (full screen)
Pay->>Pay: Payment confirmation + signing
Note over Pay: Payment completes
Pay->>W: Dismiss PayModule
W->>W: Return to Balances screen
W->>NFC: onAppear() restarts NFC session
NFC->>NFC: Show "Ready to Pay" again
```
### Integration Steps
Universal Links require `pay.walletconnect.com` to host an Apple App Site Association (AASA) file listing your app. WalletConnect will add your app to `pay.walletconnect.com/.well-known/apple-app-site-association`.
Provide:
* Apple Team ID
* Bundle ID
Add two entitlements in your `.entitlements` file.
**Associated Domains** (for Universal Links / Background Tag Reading):
```xml Entitlements.plist theme={null}
com.apple.developer.associated-domainsapplinks:pay.walletconnect.com
```
**NFC Tag Reading** (for manual CoreNFC scans):
```xml Entitlements.plist theme={null}
com.apple.developer.nfc.readersession.formatsTAG
```
Also enable these capabilities in **Xcode → Target → Signing & Capabilities**:
* Associated Domains
* Near Field Communication Tag Reading
Add the NFC usage description (required by Apple for CoreNFC):
```xml Info.plist theme={null}
NFCReaderUsageDescriptionThis app reads NFC tags to receive payment links from POS terminals.
```
This handles the case where the user taps an NFC button inside the app to initiate a read:
```swift NFCPaymentReader.swift theme={null}
import CoreNFC
final class NFCPaymentReader: NSObject {
static let shared = NFCPaymentReader()
static var isAvailable: Bool { NFCNDEFReaderSession.readingAvailable }
private var session: NFCNDEFReaderSession?
private var completion: ((Result) -> Void)?
func scan(completion: @escaping (Result) -> Void) {
guard NFCNDEFReaderSession.readingAvailable else {
completion(.failure(NFCPaymentError.notAvailable))
return
}
self.completion = completion
session = NFCNDEFReaderSession(
delegate: self,
queue: .main,
invalidateAfterFirstRead: true // auto-dismiss after first tag
)
session?.alertMessage = "Ready to Pay"
session?.begin()
}
}
extension NFCPaymentReader: NFCNDEFReaderSessionDelegate {
func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
for message in messages {
for record in message.records {
if let url = record.wellKnownTypeURIPayload() {
session.alertMessage = "Payment link received!"
session.invalidate()
completion?(.success(url.absoluteString))
completion = nil
return
}
}
}
session.invalidate(errorMessage: "No payment link found on this NFC tag.")
completion?(.failure(NFCPaymentError.noPaymentLink))
completion = nil
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if let nfcError = error as? NFCReaderError,
nfcError.code == .readerSessionInvalidationErrorFirstNDEFTagRead ||
nfcError.code == .readerSessionInvalidationErrorUserCanceled {
if nfcError.code == .readerSessionInvalidationErrorUserCanceled {
completion?(.failure(NFCPaymentError.cancelled))
completion = nil
}
return
}
completion?(.failure(error))
completion = nil
}
}
enum NFCPaymentError: LocalizedError {
case notAvailable
case noPaymentLink
case cancelled
var errorDescription: String? {
switch self {
case .notAvailable: return "NFC is not available on this device."
case .noPaymentLink: return "No payment link found on the NFC tag."
case .cancelled: return "NFC scan cancelled."
}
}
}
```
Key points:
* `invalidateAfterFirstRead: true` — iOS dismisses the NFC sheet after one tag is read.
* `wellKnownTypeURIPayload()` — extracts the URI from a standard NDEF URI record (what the POS emits).
* `.readerSessionInvalidationErrorFirstNDEFTagRead` — normal completion, not an error.
This is the zero-interaction path. The user holds their phone near the POS terminal — even from the lock screen or home screen — and iOS reads the NDEF tag automatically, resolves the URL as a Universal Link, and opens your app.
The URL arrives via `NSUserActivity` in your `SceneDelegate`:
```swift SceneDelegate.swift theme={null}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard let url = userActivity.webpageURL else { return }
let urlString = url.absoluteString
if WalletKit.isPaymentLink(urlString) {
showPaymentFlow(paymentLink: urlString)
return
}
// ...handle other Universal Links
}
```
For **cold start** (app was not running), Universal Links arrive in `connectionOptions.userActivities`:
```swift SceneDelegate.swift theme={null}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
// ...window setup...
// Check for payment Universal Link on cold start
if let url = connectionOptions.userActivities
.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb })?
.webpageURL,
WalletKit.isPaymentLink(url.absoluteString) {
// Delay slightly to let the UI finish loading
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.showPaymentFlow(paymentLink: url.absoluteString)
}
}
}
```
Show an NFC scan button only on devices that support it:
```swift NFCScanView.swift theme={null}
// SwiftUI
if NFCPaymentReader.isAvailable {
Button(action: { startNfcScan() }) {
Image(systemName: "wave.3.right")
}
}
func startNfcScan() {
NFCPaymentReader.shared.scan { result in
switch result {
case .success(let urlString):
if WalletKit.isPaymentLink(urlString) {
showPaymentFlow(paymentLink: urlString)
}
case .failure(let error):
if case NFCPaymentError.cancelled = error { return }
showError(error)
}
}
}
```
```swift PaymentFlow.swift theme={null}
// 1. Fetch payment options
let accounts = ["eip155:1:0xABC...", "eip155:137:0xABC..."]
let options = try await WalletKit.instance.Pay.getPaymentOptions(
paymentLink: paymentUrl,
accounts: accounts
)
// 2. Show options UI — user selects one
// 3. Get required signing actions
let actions = try await WalletKit.instance.Pay.getRequiredPaymentActions(
paymentId: options.paymentId,
optionId: selectedOption.id
)
// 4. Sign each action (typically eth_signTypedData_v4)
let signatures = try actions.map { action in
try yourSigner.sign(method: action.method, params: action.params)
}
// 5. Confirm payment
let result = try await WalletKit.instance.Pay.confirmPayment(
paymentId: options.paymentId,
optionId: selectedOption.id,
signatures: signatures
)
```
## Testing
You can test your implementation using our Demo POS App. An Android device is required — reach out to our team to request access.
# Token & Chain Support
Source: https://docs.walletconnect.com/payments/wallets/token-chain-support
Guides for implementing specific token and blockchain support in your WalletConnect Pay wallet integration.
This section covers implementation details for supporting specific tokens and blockchains in your WalletConnect Pay wallet integration.
## Available Guides
Learn how to implement the two-action Permit2 flow required for USDT and other ERC-20 tokens without native typed-data authorization.
Implement Solana payment capabilities including native SOL transactions and mixed-chain payment options.
# Solana Support
Source: https://docs.walletconnect.com/payments/wallets/token-chain-support/solana-support
Implement Solana payment capabilities in your wallet including native SOL transactions and mixed-chain payment options.
WalletConnect Pay supports Solana payments, enabling users to pay with native SOL and SPL tokens. This guide covers how to extend your wallet's WalletConnect Pay integration to support Solana payments end-to-end.
This guide applies whether you integrate with the **Wallet Pay SDK** or directly with the **Gateway API** ([API-first](/payments/wallets/api-first)). The signing requirements are identical — Solana actions arrive as `walletRpc` actions, and the signed transaction is returned in the results array. The code samples below use SDK method names; if you are integrating API-first, the equivalents are:
| SDK | Gateway API |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `getPaymentOptions({ accounts })` | `POST /v1/gateway/payment/{id}/options` with `accounts[]` |
| `option.actions` / `getRequiredPaymentActions` | the option's `actions[]`, resolved via `POST /v1/gateway/payment/{id}/fetch` when an action is type `build` |
| `confirmPayment(signatures)` | `POST /v1/gateway/payment/{id}/confirm` with `results[]` |
Everything else in this guide — advertising Solana accounts, routing by namespace, the `solana_signTransaction` payload, and returning the signed transaction blob — is the same in both integrations.
## Solana payment flow
A Solana payment follows the same three-call flow as any other WalletConnect Pay payment. The only Solana-specific parts are the signing method (`solana_signTransaction`) and that your wallet **signs but never broadcasts** — the WalletConnect Pay backend submits the signed transaction to the network.
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant Gateway as WalletConnect Pay (Gateway API / SDK)
participant Chain as Solana RPC / Chain
User->>Wallet: Scan QR / Open payment link
Wallet->>Gateway: Get payment options (incl. solana:… accounts)
Gateway-->>Wallet: options[] (some with solana_signTransaction actions)
Wallet->>User: Display payment options
User->>Wallet: Select Solana option
alt Option includes a build action
Wallet->>Gateway: Fetch action (optionId, data)
Gateway-->>Wallet: walletRpc actions (solana_signTransaction)
end
Wallet->>User: Request signature
User-->>Wallet: Approve
Wallet->>Wallet: Sign transaction (solana_signTransaction)
Note over Wallet: Return signed tx blob (base64) Do NOT broadcast
Wallet->>Gateway: Confirm payment (results: [signed tx blob])
Gateway->>Chain: Broadcast signed transaction
Chain-->>Gateway: Confirmation
Gateway-->>Wallet: status + isFinal
Wallet->>User: Show result / status
```
The `results[]` (or `signatures[]`) you submit on confirm must match the option's `actions[]` in **order and length** — including any EVM actions that precede a Solana action in a mixed-chain option.
## Key capabilities
Implementing Solana support enables three primary features:
1. **Native SOL transactions** — Users can initiate Solana payments through Pay links when merchants support the network
2. **Mixed-chain options** — Single payment links can present both EVM and Solana choices simultaneously
3. **Namespace-based routing** — Individual actions route to appropriate signers based on CAIP-2 namespace identifiers
## Implementation requirements
Follow these requirements to correctly implement Solana payment support in your wallet.
### Advertise Solana accounts
Include Solana accounts when calling `getPaymentOptions` using the CAIP-10 format:
```
solana::
```
For example, a Solana mainnet account would be formatted as:
```
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU
```
```typescript theme={null}
const evmAccounts = evmChains.map(
(chain) => `eip155:${chain.id}:${evmAddress}`
);
const solanaAccounts = solanaChains.map(
(chain) => `solana:${chain.reference}:${solanaPublicKey.toBase58()}`
);
const options = await pay.getPaymentOptions({
paymentId,
accounts: [...evmAccounts, ...solanaAccounts],
});
```
```kotlin theme={null}
val evmAccounts = evmChains.map { chain ->
"eip155:${chain.id}:${evmAddress}"
}
val solanaAccounts = solanaChains.map { chain ->
"solana:${chain.reference}:${solanaPublicKey}"
}
val options = pay.getPaymentOptions(
paymentId = paymentId,
accounts = evmAccounts + solanaAccounts
)
```
```swift theme={null}
let evmAccounts = evmChains.map { chain in
"eip155:\(chain.id):\(evmAddress)"
}
let solanaAccounts = solanaChains.map { chain in
"solana:\(chain.reference):\(solanaPublicKey)"
}
let options = try await pay.getPaymentOptions(
paymentId: paymentId,
accounts: evmAccounts + solanaAccounts
)
```
Only advertise Solana accounts if your wallet has Solana keys available. Wallets without Solana support should not include Solana accounts in the request.
### Route actions by namespace
Determine the signing wallet for each action using the namespace from the chain ID. Extract the namespace from `actions[i].walletRpc.chainId` rather than assuming a single wallet type for all actions.
```typescript theme={null}
function getNamespace(chainId: string): string {
return chainId.split(':')[0];
}
for (const action of actions) {
const namespace = getNamespace(action.walletRpc.chainId);
switch (namespace) {
case 'eip155':
// Route to EVM wallet
await handleEvmAction(action);
break;
case 'solana':
// Route to Solana wallet
await handleSolanaAction(action);
break;
default:
throw new Error(`Unsupported namespace: ${namespace}`);
}
}
```
```kotlin theme={null}
fun getNamespace(chainId: String): String = chainId.split(':')[0]
for (action in actions) {
val namespace = getNamespace(action.walletRpc.chainId)
when (namespace) {
"eip155" -> handleEvmAction(action)
"solana" -> handleSolanaAction(action)
else -> error("Unsupported namespace: $namespace")
}
}
```
```swift theme={null}
func getNamespace(_ chainId: String) -> String {
chainId.split(separator: ":").first.map(String.init) ?? ""
}
for action in actions {
let namespace = getNamespace(action.walletRpc.chainId)
switch namespace {
case "eip155":
try await handleEvmAction(action)
case "solana":
try await handleSolanaAction(action)
default:
throw PaymentError.unsupportedNamespace(namespace)
}
}
```
### Implement Solana signing
For Solana actions, implement the `solana_signTransaction` method. Sign the transaction and append the **signed transaction blob, base64-encoded** to the signatures array.
```typescript theme={null}
async function handleSolanaAction(action: Action): Promise {
const { method, params } = action.walletRpc;
if (method !== 'solana_signTransaction') {
throw new Error(`Unsupported Solana method: ${method}`);
}
// Params are array-wrapped: [{ transaction }]
const [{ transaction }] = JSON.parse(params);
// Decode the base64 transaction
const txBuffer = Buffer.from(transaction, 'base64');
const tx = Transaction.from(txBuffer);
// Sign the transaction
tx.sign(solanaKeypair);
// Return the signed transaction as base64
return tx.serialize().toString('base64');
}
```
```kotlin theme={null}
suspend fun handleSolanaAction(action: Action): String {
val method = action.walletRpc.method
require(method == "solana_signTransaction") {
"Unsupported Solana method: $method"
}
// Params are array-wrapped: [{ transaction }]
val params = JSONArray(action.walletRpc.params)
val txData = params.getJSONObject(0).getString("transaction")
// Decode and sign the transaction
val txBytes = Base64.decode(txData, Base64.DEFAULT)
val signedTx = solanaWallet.signTransaction(txBytes)
// Return the signed transaction as base64
return Base64.encodeToString(signedTx, Base64.NO_WRAP)
}
```
```swift theme={null}
func handleSolanaAction(_ action: Action) async throws -> String {
let method = action.walletRpc.method
guard method == "solana_signTransaction" else {
throw PaymentError.unsupportedMethod(method)
}
// Params are array-wrapped: [{ transaction }]
let params = try JSONDecoder().decode(
[[String: String]].self,
from: action.walletRpc.params.data(using: .utf8)!
)
let txData = params[0]["transaction"]!
// Decode and sign the transaction
let txBytes = Data(base64Encoded: txData)!
let signedTx = try solanaWallet.signTransaction(txBytes)
// Return the signed transaction as base64
return signedTx.base64EncodedString()
}
```
**Return the signed transaction, not the signature.** Solana signer libraries may return both a detached bs58 signature and the signed transaction blob. WalletConnect Pay expects the complete base64-encoded signed transaction, not the detached signature.
### Do not send transactions
Do not implement `solana_signAndSendTransaction` for Pay flows. The WalletConnect Pay backend handles broadcasting the signed transaction. Your wallet should only sign and return the signed transaction blob.
### Handle array-wrapped params
The Pay backend provides params in an array-wrapped structure: `[{ transaction }]`. Make sure to unwrap this structure when parsing the transaction data.
```typescript theme={null}
// Correct: unwrap the array
const [{ transaction }] = JSON.parse(params);
// Incorrect: accessing params directly
const { transaction } = JSON.parse(params); // This will fail
```
### Validate signers at runtime
Guard action handlers against missing signers at execution time. If your wallet doesn't have a Solana signer available when a Solana action is encountered, raise an error rather than silently skipping the action.
```typescript theme={null}
if (namespace === 'solana' && !solanaWallet) {
throw new Error('Solana wallet not available');
}
```
### Reject unknown methods
Reject unknown wallet-RPC methods instead of silently omitting them from result arrays. The signatures array must match the actions array in order and length.
```typescript theme={null}
default:
throw new Error(`Unknown method: ${method}`);
// Never silently skip or return undefined
```
## Complete action handler
Here's a complete example showing how to handle both EVM and Solana actions in a single payment flow:
```typescript theme={null}
async function approvePayment(actions: Action[]): Promise {
const signatures: string[] = [];
for (const action of actions) {
const { chainId, method, params } = action.walletRpc;
const namespace = chainId.split(':')[0];
let signature: string;
switch (namespace) {
case 'eip155':
signature = await handleEvmAction(method, chainId, params);
break;
case 'solana':
signature = await handleSolanaAction(method, params);
break;
default:
throw new Error(`Unsupported namespace: ${namespace}`);
}
signatures.push(signature);
}
return signatures;
}
async function handleEvmAction(
method: string,
chainId: string,
params: string
): Promise {
const parsed = JSON.parse(params);
switch (method) {
case 'eth_sendTransaction': {
const tx = await evmWallet.sendTransaction(chainId, parsed[0]);
await tx.wait();
return tx.hash;
}
case 'eth_signTypedData_v4':
return await evmWallet.signTypedData(chainId, parsed);
default:
throw new Error(`Unsupported EVM method: ${method}`);
}
}
async function handleSolanaAction(
method: string,
params: string
): Promise {
if (method !== 'solana_signTransaction') {
throw new Error(`Unsupported Solana method: ${method}`);
}
const [{ transaction }] = JSON.parse(params);
const txBuffer = Buffer.from(transaction, 'base64');
const tx = Transaction.from(txBuffer);
tx.sign(solanaKeypair);
return tx.serialize().toString('base64');
}
```
## Validate your integration
Before shipping your Solana support implementation, verify these scenarios work correctly:
Test a payment link that only offers Solana options. Verify that:
* The Solana option is presented to the user
* The transaction is signed correctly
* The base64-encoded signed transaction is submitted
* The payment completes successfully
Test a payment link that offers both EVM and Solana options. Verify that:
* Both option types are presented
* Users can select either network
* The correct signer is used for the selected option
* Payment completes regardless of which option is chosen
After adding Solana support, verify that existing EVM flows still work:
* USDC payments with EIP-3009
* USDT payments with Permit2
* Native token payments
Test behavior when Solana keys are not available:
* Solana options should not be advertised to users
* No errors should occur during option fetching
* EVM options should work normally
## Sample implementation
The React Native reference wallet demonstrates Solana support implementation:
`wallets/rn_cli_wallet` — see `PaymentStore.approvePayment` for namespace dispatch logic and `usePairing.handlePaymentLink` for account concatenation.
# USDT Support
Source: https://docs.walletconnect.com/payments/wallets/token-chain-support/usdt-support
Implement the two-action Permit2 flow your wallet needs to accept USDT and other ERC-20s without native typed-data authorization.
The simple WalletConnect Pay flow signs an EIP-3009 `transferWithAuthorization` typed-data payload — the gasless transfer used by USDC and similar stablecoins. USDT does not implement EIP-3009, so WalletConnect Pay routes it through the [Permit2](https://github.com/Uniswap/permit2) contract instead.
The first time a user pays a given token on a given chain, the wallet must approve Permit2 on-chain. Subsequent payments on the same token only require the typed-data signature.
## The two-action flow
When an option needs a Permit2 approval, `getRequiredPaymentActions` returns **two** actions — in the order they must execute:
1. `eth_sendTransaction` — ERC-20 `approve(Permit2, amount)` on the token contract
2. `eth_signTypedData_v4` — Permit2 typed-data payload authorizing the transfer
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant PaySDK as Pay SDK
participant Chain
User->>Wallet: Tap Pay
Wallet->>PaySDK: getRequiredPaymentActions
PaySDK-->>Wallet: [approveTx, permit2TypedData]
Wallet->>Chain: eth_sendTransaction (approve -> Permit2)
Chain-->>Wallet: txHash
Wallet->>Chain: wait for receipt
Chain-->>Wallet: receipt
Wallet->>User: Sign Permit2 typed data
User-->>Wallet: signature
Wallet->>PaySDK: confirmPayment(signatures: [txHash, signature])
PaySDK-->>Wallet: status
```
`signatures[]` passed to `confirmPayment` must match `actions[]` in **order and length** — the approve tx hash first, then the Permit2 signature.
## Detecting an approval-required option
Inspect the action list for an `eth_sendTransaction` entry. In standalone SDK flows, this means `option.actions`; in WalletKit flows, this means the actions returned by `getRequiredPaymentActions`. If one is present, the option is a Permit2 flow and your wallet should prepare for an on-chain step.
```kotlin theme={null}
// WalletKit: inspect the actions returned by getRequiredPaymentActions
fun requiresApproval(requiredActions: List?): Boolean =
requiredActions
?.filterIsInstance()
?.any { it.action.method == "eth_sendTransaction" }
?: false
```
```swift theme={null}
func requiresApproval(_ actions: [Action]?) -> Bool {
actions?.contains { $0.walletRpc.method == "eth_sendTransaction" } ?? false
}
```
```typescript theme={null}
function requiresApproval(actions: readonly Action[] | null): boolean {
return !!actions?.some(a => a.walletRpc?.method === "eth_sendTransaction");
}
```
```dart theme={null}
bool requiresApproval(List? actions) =>
actions?.any((a) => a.walletRpc.method == 'eth_sendTransaction') ?? false;
```
## Showing the approval gas estimate
The approve tx costs native gas (e.g. POL on Polygon, ETH on mainnet) — separate from the token amount the user is paying. Estimate it and show the user the expected fee before they confirm so they aren't surprised by a wallet prompt for a one-time on-chain cost.
This can live wherever your wallet collects user intent — a review screen, a confirmation sheet, or inline on the option row.
```kotlin theme={null}
suspend fun estimateApprovalFee(
chainId: String,
txParams: JSONObject,
): BigInteger? = runCatching {
val gasLimit = rpcEstimateGas(chainId, txParams)
val maxFeePerGas = fetchFeeData(chainId).maxFeePerGas
gasLimit.multiply(maxFeePerGas)
}.getOrNull()
```
```swift theme={null}
func estimateApprovalFee(
chainId: String,
txParams: [String: Any]
) async -> BigUInt? {
do {
let gasLimit = try await rpc.estimateGas(chainId: chainId, tx: txParams)
let maxFeePerGas = try await rpc.maxFeePerGas(chainId: chainId)
return gasLimit * maxFeePerGas
} catch {
return nil
}
}
```
```typescript theme={null}
async function estimateApprovalFee(
provider: providers.JsonRpcProvider,
tx: providers.TransactionRequest,
): Promise {
try {
const [gasLimit, feeData] = await Promise.all([
provider.estimateGas(tx),
provider.getFeeData(),
]);
return feeData.maxFeePerGas ? gasLimit.mul(feeData.maxFeePerGas) : null;
} catch {
return null;
}
}
```
```dart theme={null}
Future estimateApprovalFee(Map txParams) async {
try {
final gasLimit = await ethClient.estimateGas(/* tx params */);
final fees = await ethClient.getFeeData();
return gasLimit * fees.maxFeePerGas.getInWei;
} catch (_) {
return null;
}
}
```
If estimation fails (RPC error, missing fields, etc.), don't block the flow. Fall back to a generic message such as "Network fee set by wallet" — the wallet's signing prompt will still surface the actual cost when the user confirms.
## Executing the actions in order
Because the Permit2 typed data signs against the token allowance the approve tx grants, the **approve must be mined before you sign**.
1. Submit `eth_sendTransaction` (action 1) and wait for the receipt.
2. Parse the typed data from `eth_signTypedData_v4` (action 2) and sign it.
3. Push both results into `signatures[]` in the order the actions were returned.
4. Call `confirmPayment(signatures)`.
```kotlin theme={null}
val signatures = mutableListOf()
for (action in actions.filterIsInstance()) {
signatures += when (action.action.method) {
"eth_sendTransaction" -> {
val txHash = wallet.sendTransaction(action.action.chainId, action.action.params)
wallet.awaitReceipt(action.action.chainId, txHash)
txHash
}
"eth_signTypedData_v4" ->
wallet.signTypedData(action.action.chainId, action.action.params)
else -> error("Unsupported method: ${action.action.method}")
}
}
```
```swift theme={null}
var signatures: [String] = []
for action in actions {
let rpc = action.walletRpc
let result: String
switch rpc.method {
case "eth_sendTransaction":
let txHash = try await wallet.sendTransaction(chainId: rpc.chainId, params: rpc.params)
try await wallet.awaitReceipt(chainId: rpc.chainId, txHash: txHash)
result = txHash
case "eth_signTypedData_v4":
result = try await wallet.signTypedData(chainId: rpc.chainId, params: rpc.params)
default:
throw PaymentError.unsupportedMethod(rpc.method)
}
signatures.append(result)
}
```
```typescript theme={null}
const signatures: string[] = [];
for (const action of actions) {
const { chainId, method, params } = action.walletRpc;
const parsed = JSON.parse(params);
switch (method) {
case "eth_sendTransaction": {
const tx = await wallet.sendTransaction(chainId, parsed[0]);
await tx.wait();
signatures.push(tx.hash);
break;
}
case "eth_signTypedData_v4":
signatures.push(await wallet.signTypedData(chainId, parsed));
break;
default:
throw new Error(`Unsupported method: ${method}`);
}
}
```
```dart theme={null}
final signatures = [];
for (final action in actions) {
final rpc = action.walletRpc;
switch (rpc.method) {
case 'eth_sendTransaction':
// sendPayTransaction broadcasts and waits for the receipt
final params = (jsonDecode(rpc.params) as List).first as Map;
signatures.add(await service.sendPayTransaction(
Map.from(params),
));
break;
case 'eth_signTypedData_v4':
signatures.add(service.ethSignTypedDataV4(rpc.params));
break;
default:
throw UnimplementedError('Unsupported method: ${rpc.method}');
}
}
```
**EIP-712 library quirks.** EIP-712 signing libraries disagree on whether `types.EIP712Domain` must be present in the payload. The Permit2 typed data returned by WalletConnect Pay omits it.
* Libraries that **require** `EIP712Domain` (e.g. `eth_sig_util_plus` on Flutter, some Android libraries): synthesize an `EIP712Domain` entry in `types` from the fields actually present in `domain` before signing.
* Libraries that **reject** `EIP712Domain` (e.g. ethers v5 `_signTypedData`): strip it from `types` before signing.
* Yttrium's `signTypedData` is currently hardcoded to ERC-3009 (`from`/`to`/`value`/`validAfter`/`validBefore`/`nonce`) and rejects Permit2. Wallets using Yttrium need a generic EIP-712 hasher — see [`EIP712TypedData.swift`](https://github.com/reown-com/reown-swift/blob/develop/Example/Shared/Signer/EIP712TypedData.swift) for a reference implementation.
If your signing library throws on a Permit2 typed-data payload, normalize the payload before retrying.
## Loader UX
The two-action flow has two distinct user-visible steps that can each take several seconds:
1. The approve tx is broadcast and you wait for the receipt.
2. The user is prompted to sign the Permit2 typed data.
Show different loader copy for each step so the user understands what is happening — especially during the on-chain wait, which is much slower than a typed-data signature on its own. Pick whatever wording fits your wallet's voice.
## Recommended UX
Follow these patterns to provide the best user experience when implementing USDT and Permit2-based payments:
### Pre-load fee estimates
Preload per-option fee estimates before user selection. This allows users to make informed decisions by comparing realistic gas costs across different payment options.
### Display one-time setup messaging
When approval actions are present (first-time Permit2 setup), clearly communicate to users that this is a one-time on-chain approval. Subsequent payments with the same token will only require a signature.
### Separate gas cost display
Explain native-token gas costs separately from the token amounts being paid. Users should understand that:
* The token amount goes to the merchant
* The gas fee (in the chain's native token) goes to network validators
### Remember user preferences
Retain and auto-select the last-paid token when it's available in future payments. This reduces friction for repeat users who prefer specific payment methods.
### Guard against stale data
Implement safeguards against stale data from rapid option switching and expired payment sessions. Always validate that payment data is current before executing actions.
## Validate your integration
Before shipping your USDT support implementation, verify these scenarios work correctly:
Test a payment with a token requiring Permit2 approval for the first time. The flow should:
* Present the approval transaction first
* Wait for on-chain confirmation
* Then prompt for the Permit2 signature
* Complete successfully with both results in `signatures[]`
After completing the first-time flow, make another payment with the same token. This should:
* Skip the approval transaction entirely
* Only require a single Permit2 signature
* Complete faster than the first-time flow
Test a payment using the chain's native token (e.g., ETH on Base). Verify that:
* The transaction hash is correctly included in `signatures[]`
* The payment completes successfully
Quickly switch between payment options and verify:
* No stale data leaks between options
* Fee estimates update correctly for each option
* The correct actions execute for the final selected option
Test behavior when a payment session expires:
* Before any signatures are submitted
* The wallet should handle expiration gracefully
* Users should receive clear feedback about the expiration
## Sample wallets
The four reference wallets below all implement this two-action flow and are good starting points to copy from.
`wallets/rn_cli_wallet` — see `PaymentUtil.ts`, `PaymentTransactionUtil.ts`, `PaymentStore.ts`.
`sample/wallet` — see `PaymentUtil.kt`, `PaymentTransactionUtil.kt`, `PaymentViewModel.kt`.
`Example/WalletApp` — see `PaymentUtil.swift`, `PayTransactionService.swift`, `PayPresenter.swift`, `EIP712TypedData.swift`.
`packages/reown_walletkit/example` — see `evm_service.dart`, `wcp_payment_details.dart`, `wcp_confirming_payment.dart`.
# WalletConnect Pay via WalletKit - Flutter
Source: https://docs.walletconnect.com/payments/wallets/walletkit/flutter
Integrate WalletConnect Pay through WalletKit for a unified payment experience in your Flutter wallet.
This documentation covers integrating WalletConnect Pay through ReownWalletKit. This approach provides a unified API where Pay is automatically initialized alongside WalletKit, simplifying the integration for wallet developers.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference Flutter wallet app demonstrating WalletConnect Pay via WalletKit.
**Using AI for Integration?** If you're using an AI IDE or assistant to help with integration, you can provide it with our comprehensive [AI integration prompt](/payments/wallets/walletkit/ai-prompts/flutter) for better context and guidance.
## Requirements
* Flutter 3.0+
* iOS 13.0+
* Android API 23+
* ReownWalletKit
You also need a WCP ID for your project, obtained from the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
**How to obtain a WCP ID**
1. Navigate to the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
2. Select the project that is associated with your wallet (as in, the projectId that is being used for your wallet's WalletConnect integration).
3. Click on the "Get Started" button to get a WCP ID associated with your project.
4. The Dashboard will now show the WCP ID associated with your project.
5. Click on the three dots on the right of the WCP ID and select "Copy WCP ID". You will be using this for your wallet's WalletConnect Pay integration.
## Installation
Add `reown_walletkit` to your `pubspec.yaml`:
```yaml theme={null}
dependencies:
reown_walletkit: ^1.4.0
```
Then run:
```bash theme={null}
flutter pub get
```
WalletConnectPay is automatically included as a dependency of ReownWalletKit.
Check the [pub.dev page](https://pub.dev/packages/reown_walletkit) for the latest version.
## Initialization
The `WalletConnectPay` client is automatically initialized during `ReownWalletKit.init()`. No additional setup is required.
```dart theme={null}
import 'package:reown_walletkit/reown_walletkit.dart';
final walletKit = await ReownWalletKit.createInstance(
projectId: 'YOUR_PROJECT_ID',
metadata: PairingMetadata(
name: 'My Wallet',
description: 'My Wallet App',
url: 'https://mywallet.com',
icons: ['https://mywallet.com/icon.png'],
),
);
```
Access the `WalletConnectPay` client directly via `walletKit.pay`:
```dart theme={null}
final payClient = walletKit.pay;
```
## Payment Link Detection
Detect if a URI is a payment link before processing:
```dart theme={null}
if (walletKit.isPaymentLink(uri)) {
// Handle as payment. See [Get Payment Options] section
} else {
// Handle as regular WalletConnect pairing
await walletKit.pair(uri: Uri.parse(uri));
}
```
## Payment Flow
The payment flow consists of six main steps:
**Detect Payment Link -> Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant WebView
participant WalletKit as WalletKit.Pay
participant Backend as WalletConnect Pay
User->>Wallet: Scan QR / Open payment link
Wallet->>WalletKit: isPaymentLink(uri)
WalletKit-->>Wallet: true
Wallet->>WalletKit: getPaymentOptions(request)
WalletKit->>Backend: Fetch payment options
Backend-->>WalletKit: Payment options + merchant info
WalletKit-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Data collection required
Wallet->>WebView: Load collectDataAction.url in WebView
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>WalletKit: getRequiredPaymentActions(request)
WalletKit->>Backend: Get signing actions
Backend-->>WalletKit: Required wallet RPC actions
WalletKit-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>WalletKit: confirmPayment(request)
WalletKit->>Backend: Submit payment
Backend-->>WalletKit: Payment status
WalletKit-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
Retrieve available payment options for a payment link:
```dart theme={null}
final response = await walletKit.getPaymentOptions(
request: GetPaymentOptionsRequest(
paymentLink: 'https://pay.walletconnect.com/pay_123',
accounts: ['eip155:1:0x...', 'eip155:137:0x...'], // Wallet's CAIP-10 accounts
includePaymentInfo: true,
),
);
print('Payment ID: ${response.paymentId}');
print('Options available: ${response.options.length}');
if (response.info != null) {
print('Amount: ${response.info!.amount.formatAmount()}');
print('Merchant: ${response.info!.merchant.name}');
}
// Check which options require data collection (per-option)
for (final option in response.options) {
if (option.collectData != null) {
print('Option ${option.id} requires info capture');
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```dart theme={null}
if (selectedOption.collectData?.url != null) {
// Use the "required" list from selectedOption.collectData.schema to determine which fields to prefill
final prefillData = {
'fullName': 'John Doe',
'dob': '1990-01-15',
'pobAddress': '123 Main St, New York, NY 10001',
};
// Encode prefill as base64url (no padding, consistent with the other SDKs)
final prefillBase64 = base64Url.encode(utf8.encode(jsonEncode(prefillData))).replaceAll('=', '');
final uri = Uri.parse(selectedOption.collectData!.url);
final webViewUrl = uri.replace(
queryParameters: {
...uri.queryParameters,
'prefill': prefillBase64,
// Optional appearance params (see "Form URL parameters"):
'theme': 'dark', // "light" | "dark"
// themeVariables is a base64url string exported from the Pay Dashboard:
// 'themeVariables': themeVariables,
},
).toString();
// Show WebView and wait for IC_COMPLETE message
showDataCollectionWebView(webViewUrl);
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
Get the required wallet actions for a selected payment option:
```dart theme={null}
final actions = await walletKit.getRequiredPaymentActions(
request: GetRequiredPaymentActionsRequest(
optionId: 'option-id',
paymentId: 'payment-id',
),
);
// Process each action (e.g., sign transactions)
for (final action in actions) {
final walletRpc = action.walletRpc;
print('Chain ID: ${walletRpc.chainId}');
print('Method: ${walletRpc.method}');
// Dispatch based on walletRpc.method — see Sign Actions below
}
```
Sign each action using your wallet's signing implementation, dispatching on the RPC method:
```dart theme={null}
// Sign each action based on its RPC method
final signatures = [];
for (final action in actions) {
final rpc = action.walletRpc;
switch (rpc.method) {
case 'eth_signTypedData_v4':
signatures.add(await signTypedData(rpc.chainId, rpc.params));
break;
case 'eth_sendTransaction':
signatures.add(await sendTransaction(rpc.chainId, rpc.params));
break;
case 'personal_sign':
signatures.add(await personalSign(rpc.chainId, rpc.params));
break;
default:
throw UnimplementedError('Unsupported RPC method: ${rpc.method}');
}
}
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Confirm the payment with the collected signatures:
```dart theme={null}
final confirmResponse = await walletKit.confirmPayment(
request: ConfirmPaymentRequest(
paymentId: 'payment-id',
optionId: 'option-id',
signatures: ['0x...', '0x...'], // Signatures from wallet actions
maxPollMs: 60000, // Maximum polling time in milliseconds
),
);
print('Payment Status: ${confirmResponse.status}');
print('Is Final: ${confirmResponse.isFinal}');
```
When using the WebView data-collection approach, you do **not** pass `collectedData` to `confirmPayment`. The WebView submits the collected data directly to the backend during the earlier Collect User Data step.
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a WebView using `webview_flutter` (v4.10.0+). Add dependencies:
```yaml theme={null}
dependencies:
webview_flutter: ^4.10.0
url_launcher: ^6.1.0
```
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```dart theme={null}
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
class PayDataCollectionWebView extends StatefulWidget {
final String url;
final VoidCallback onComplete;
final ValueChanged onError;
const PayDataCollectionWebView({
super.key,
required this.url,
required this.onComplete,
required this.onError,
});
@override
State createState() =>
_PayDataCollectionWebViewState();
}
class _PayDataCollectionWebViewState extends State {
late final WebViewController _controller;
bool _isLoading = true;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onPageFinished: (_) => setState(() => _isLoading = false),
onNavigationRequest: (request) {
if (!request.url.contains('pay.walletconnect.com')) {
launchUrl(Uri.parse(request.url),
mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
))
..addJavaScriptChannel(
'ReactNativeWebView',
onMessageReceived: (message) {
try {
final data = jsonDecode(message.message) as Map;
switch (data['type']) {
case 'IC_COMPLETE':
widget.onComplete();
break;
case 'IC_ERROR':
widget.onError(data['error'] ?? 'Unknown error');
break;
}
} catch (_) {}
},
)
..loadRequest(Uri.parse(widget.url));
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading)
const Center(child: CircularProgressIndicator()),
],
);
}
}
```
## Complete Example
Here's a complete example of processing a payment:
```dart theme={null}
import 'package:reown_walletkit/reown_walletkit.dart';
class PaymentService {
final ReownWalletKit walletKit;
PaymentService(this.walletKit);
/// Process a payment from a payment link (e.g., after scanning QR code)
Future processPayment(String paymentLink) async {
try {
// Step 1: Get payment options
final accounts = await getWalletAccounts(); // Your wallet accounts
final optionsResponse = await walletKit.getPaymentOptions(
request: GetPaymentOptionsRequest(
paymentLink: paymentLink,
accounts: accounts,
includePaymentInfo: true,
),
);
if (optionsResponse.options.isEmpty) {
throw Exception('No payment options available');
}
// Step 2: Select payment option (or let user choose)
PaymentOption selectedOption = optionsResponse.options.first;
final paymentId = optionsResponse.paymentId;
final optionId = selectedOption.id;
// Step 3: Collect data via WebView if required for selected option.
// This must happen BEFORE fetching the required payment actions —
// the backend rejects the actions request with 400 "IC data required"
// for options needing Information Capture if data wasn't collected first.
// The WebView submits the data directly to the backend, so it is NOT
// passed to confirmPayment later.
if (selectedOption.collectData?.url != null) {
await showDataCollectionWebView(selectedOption.collectData!.url);
}
// Step 4: Get required payment actions (if not already in the option)
List actions = selectedOption.actions;
if (actions.isEmpty) {
actions = await walletKit.getRequiredPaymentActions(
request: GetRequiredPaymentActionsRequest(
optionId: optionId,
paymentId: paymentId,
),
);
}
// Step 5: Execute wallet actions and collect signatures
final signatures = [];
for (final action in actions) {
// Dispatch based on action.walletRpc.method:
// 'eth_signTypedData_v4' -> sign EIP-712 typed data
// 'eth_sendTransaction' -> send transaction (e.g., token approval)
// 'personal_sign' -> personal message signing
signatures.add(await signAction(action.walletRpc));
}
// Step 6: Confirm payment
ConfirmPaymentResponse confirmResponse = await walletKit.confirmPayment(
request: ConfirmPaymentRequest(
paymentId: paymentId,
optionId: optionId,
signatures: signatures,
maxPollMs: 60000, // Maximum polling time in milliseconds
),
);
// Step 7: Poll until final status (if needed)
while (!confirmResponse.isFinal && confirmResponse.pollInMs != null) {
await Future.delayed(Duration(milliseconds: confirmResponse.pollInMs!));
confirmResponse = await walletKit.confirmPayment(
request: ConfirmPaymentRequest(
paymentId: paymentId,
optionId: optionId,
signatures: signatures,
maxPollMs: 60000,
),
);
}
// Handle final payment status
switch (confirmResponse.status) {
case PaymentStatus.succeeded:
print('Payment succeeded!');
break;
case PaymentStatus.failed:
throw Exception('Payment failed');
case PaymentStatus.expired:
throw Exception('Payment expired');
case PaymentStatus.cancelled:
throw Exception('Payment cancelled');
case PaymentStatus.requires_action:
throw Exception('Payment requires additional action');
case PaymentStatus.processing:
// Should not happen if isFinal is true
break;
}
} catch (e) {
print('Payment error: $e');
rethrow;
}
}
Future> getWalletAccounts() async {
// Return your wallet's CAIP-10 formatted accounts
// Example: ['eip155:1:0x1234...', 'eip155:137:0x5678...']
return [];
}
}
```
## API Reference
**ReownWalletKit Pay Methods**
| Method | Description |
| -------------------------------------------------------------------------------- | ----------------------------------------------- |
| `isPaymentLink(String uri)` | Check if URI is a payment link |
| `getPaymentOptions({required GetPaymentOptionsRequest request})` | Get available payment options |
| `getRequiredPaymentActions({required GetRequiredPaymentActionsRequest request})` | Get actions requiring signatures |
| `confirmPayment({required ConfirmPaymentRequest request})` | Confirm and finalize payment |
| `pay` | Access the underlying WalletConnectPay instance |
**Models**
**GetPaymentOptionsRequest**
```dart theme={null}
GetPaymentOptionsRequest({
required String paymentLink,
required List accounts,
@Default(false) bool includePaymentInfo,
})
```
**PaymentOptionsResponse**
```dart theme={null}
PaymentOptionsResponse({
required String paymentId,
PaymentInfo? info,
required List options,
CollectDataAction? collectData,
PaymentResultInfo? resultInfo, // Transaction result details (present when payment already completed)
})
```
**PaymentResultInfo**
```dart theme={null}
class PaymentResultInfo {
final String txId; // Transaction ID
final PayAmount optionAmount; // Token amount details
}
```
**PaymentInfo**
```dart theme={null}
PaymentInfo({
required PaymentStatus status,
required PayAmount amount,
required int expiresAt,
required MerchantInfo merchant,
BuyerInfo? buyer,
})
```
**PaymentOption**
```dart theme={null}
PaymentOption({
required String id,
required String account,
required PayAmount amount,
@JsonKey(name: 'etaS') required int etaSeconds,
required List actions,
CollectDataAction? collectData, // Per-option data collection (null if not required)
})
```
**ConfirmPaymentRequest**
```dart theme={null}
ConfirmPaymentRequest({
required String paymentId,
required String optionId,
required List signatures,
int? maxPollMs,
})
```
**ConfirmPaymentResponse**
```dart theme={null}
ConfirmPaymentResponse({
required PaymentStatus status,
required bool isFinal,
int? pollInMs,
PaymentResultInfo? info, // Transaction result details (present on success)
})
```
**PaymentStatus**
```dart theme={null}
enum PaymentStatus {
requires_action,
processing,
succeeded,
failed,
expired,
cancelled,
}
```
**CollectDataAction**
```dart theme={null}
class CollectDataAction {
final String url; // WebView URL for data collection
final String? schema; // JSON schema describing required fields
}
```
## Error Handling
The SDK throws specific exception types for different error scenarios. All errors extend the abstract `PayError` class, which itself extends `PlatformException`:
```dart theme={null}
abstract class PayError extends PlatformException {
PayError({
required super.code,
required super.message,
required super.details,
required super.stacktrace,
});
}
```
| Exception | Description |
| ------------------------- | ------------------------------------ |
| `PayInitializeError` | Initialization failures |
| `GetPaymentOptionsError` | Errors when fetching payment options |
| `GetRequiredActionsError` | Errors when getting required actions |
| `ConfirmPaymentError` | Errors when confirming payment |
All errors include:
* `code`: Error code
* `message`: Error message
* `details`: Additional error details
* `stacktrace`: Stack trace
**Example Error Handling**
```dart theme={null}
try {
final response = await walletKit.getPaymentOptions(request: request);
} on GetPaymentOptionsError catch (e) {
print('Error code: ${e.code}');
print('Error message: ${e.message}');
} on PayError catch (e) {
// Catch any Pay-related error
print('Pay error: ${e.message}');
} catch (e) {
print('Unexpected error: $e');
}
```
## Best Practices
1. **Use WalletKit Integration**: If your wallet already uses WalletKit, prefer this approach for automatic configuration
2. **Use `isPaymentLink()` for Detection**: Use the utility method instead of manual URL parsing for reliable payment link detection
3. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
4. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
5. **Signature Order**: Maintain the same order of signatures as the actions array
6. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
7. **Loading States**: Show loading indicators during API calls and signing operations
8. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
9. **User Data**: Only collect data when `collectData` is present in the response and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
10. **WebView Data Collection**: When `selectedOption.collectData.url` is present, display the URL in a WebView using `webview_flutter` rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
# WalletConnect Pay via WalletKit - Kotlin
Source: https://docs.walletconnect.com/payments/wallets/walletkit/kotlin
Integrate WalletConnect Pay through WalletKit for a unified payment experience in your Android wallet.
This documentation covers integrating WalletConnect Pay through WalletKit. This approach provides a unified API where Pay is automatically initialized alongside WalletKit, simplifying the integration for wallet developers.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference Android wallet app demonstrating WalletConnect Pay via WalletKit.
**Using AI for Integration?** If you're using an AI IDE or assistant to help with integration, you can provide it with our comprehensive [AI integration prompt](/payments/wallets/walletkit/ai-prompts/kotlin) for better context and guidance.
## Requirements
* **Min SDK**: 23 (Android 6.0)
* **WalletKit**: 1.6.0+
You also need a WCP ID for your project, obtained from the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
**How to obtain a WCP ID**
1. Navigate to the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
2. Select the project that is associated with your wallet (as in, the projectId that is being used for your wallet's WalletConnect integration).
3. Click on the "Get Started" button to get a WCP ID associated with your project.
4. The Dashboard will now show the WCP ID associated with your project.
5. Click on the three dots on the right of the WCP ID and select "Copy WCP ID". You will be using this for your wallet's WalletConnect Pay integration.
## Installation
First, add the required repositories to your project's `settings.gradle.kts` or root `build.gradle.kts`:
```kotlin theme={null}
allprojects {
repositories {
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
```
Then add WalletKit to your app's `build.gradle.kts` using the BOM (Bill of Materials):
```kotlin theme={null}
releaseImplementation(platform("com.reown:android-bom:$BOM_VERSION"))
releaseImplementation("com.reown:android-core")
releaseImplementation("com.reown:walletkit")
```
WalletConnectPay is automatically included as a dependency of WalletKit.
Check the [GitHub releases](https://github.com/reown-com/reown-kotlin/releases) for the latest BOM version.
## Initialization
WalletConnectPay is automatically initialized when you initialize WalletKit. No additional setup is required.
```kotlin theme={null}
import com.reown.android.Core
import com.reown.android.CoreClient
import com.reown.walletkit.client.WalletKit
import com.reown.walletkit.client.Wallet
// First, initialize CoreClient in your Application class
val projectId = "" // Get Project ID at https://dashboard.walletconnect.com/
val appMetaData = Core.Model.AppMetaData(
name = "Wallet Name",
description = "Wallet Description",
url = "Wallet URL",
icons = listOf(/* list of icon url strings */),
redirect = "kotlin-wallet-wc:/request" // Custom Redirect URI
)
CoreClient.initialize(
projectId = projectId,
application = this,
metaData = appMetaData,
)
// Then initialize WalletKit
val initParams = Wallet.Params.Init(core = CoreClient)
WalletKit.initialize(initParams) { error ->
// Error will be thrown if there's an issue during initialization
}
```
The Pay SDK is initialized internally with:
* `wcpId` from your WalletConnect Project ID
* `packageName` from your application context
For more details on WalletKit initialization, see the [WalletKit Usage documentation](https://docs.walletconnect.network/wallet-sdk/android/usage#initialization).
## Payment Link Detection
Use `WalletKit.Pay.isPaymentLink()` to determine if a scanned URI is a payment link or a standard WalletConnect pairing URI:
```kotlin theme={null}
fun handleScannedUri(uri: String, accounts: List) {
if (WalletKit.Pay.isPaymentLink(uri)) {
// Handle as payment - call getPaymentOptions
WalletKit.Pay.getPaymentOptions(uri, accounts)
} else {
// Handle as WalletConnect pairing
WalletKit.pair(Wallet.Params.Pair(uri))
}
}
```
## Payment Flow
The payment flow consists of six main steps:
**Detect Payment Link -> Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant WalletKit as WalletKit.Pay
participant Backend as WalletConnect Pay
participant WebView
User->>Wallet: Scan QR / Open payment link
Wallet->>WalletKit: isPaymentLink(uri)
WalletKit-->>Wallet: true
Wallet->>WalletKit: getPaymentOptions(link, accounts)
WalletKit->>Backend: Fetch payment options
Backend-->>WalletKit: Payment options + merchant info
WalletKit-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Selected option requires data collection
Wallet->>WebView: Load selectedOption.collectData.url
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>WalletKit: getRequiredPaymentActions(params)
WalletKit->>Backend: Get signing actions
Backend-->>WalletKit: Required wallet RPC actions
WalletKit-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>WalletKit: confirmPayment(params)
WalletKit->>Backend: Submit payment
Backend-->>WalletKit: Payment status
WalletKit-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
Retrieve available payment options for a payment link:
```kotlin theme={null}
import com.reown.walletkit.client.WalletKit
import com.reown.walletkit.client.Wallet
viewModelScope.launch {
val result = WalletKit.Pay.getPaymentOptions(
paymentLink = "https://pay.walletconnect.com/pay_xxx",
accounts = listOf(
"eip155:1:0xYourAddress", // Ethereum
"eip155:8453:0xYourAddress", // Base
"eip155:10:0xYourAddress", // Optimism
"eip155:137:0xYourAddress", // Polygon
"eip155:42161:0xYourAddress" // Arbitrum
)
)
result.onSuccess { response ->
// Payment metadata
val paymentId = response.paymentId
val paymentInfo = response.info // Merchant info, amount, expiry
// Available payment options
val options = response.options
options.forEach { option ->
println("Option: ${option.id}")
println("Amount: ${option.amount.value} ${option.amount.unit}")
println("Account: ${option.account}")
println("Requires IC: ${option.collectData != null}")
}
}.onFailure { error ->
handleError(error)
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```kotlin theme={null}
// Check per-option data collection requirement after user selects an option
selectedOption.collectData?.let { collectAction ->
val url = collectAction.url
if (url != null) {
// Build prefill URL with known user data
// Use the "required" list from collectAction.schema to determine which fields to prefill
val prefillJson = JSONObject().apply {
put("fullName", "John Doe")
put("dob", "1990-01-15")
put("pobAddress", "123 Main St, New York, NY 10001")
}.toString()
// Encode prefill as base64url (URL-safe, no padding)
val prefillBase64 = Base64.encodeToString(
prefillJson.toByteArray(),
Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING
)
val webViewUrl = Uri.parse(url).buildUpon()
.appendQueryParameter("prefill", prefillBase64)
// Optional appearance params (see "Form URL parameters"):
.appendQueryParameter("theme", "dark") // "light" | "dark"
// themeVariables is a base64url string exported from the Pay Dashboard:
// .appendQueryParameter("themeVariables", themeVariables)
.build().toString()
// Show WebView for this specific option and wait for IC_COMPLETE message
showWebView(webViewUrl)
}
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
Get the wallet RPC actions needed to complete the payment. Make sure any required data collection has already completed before calling this:
```kotlin theme={null}
val actionsResult = WalletKit.Pay.getRequiredPaymentActions(
Wallet.Params.RequiredPaymentActions(
paymentId = paymentId,
optionId = selectedOption.id
)
)
actionsResult.onSuccess { actions ->
actions.forEach { action ->
when (action) {
is Wallet.Model.RequiredAction.WalletRpc -> {
val rpcAction = action.action
// rpcAction.chainId - e.g., "eip155:8453"
// rpcAction.method - e.g., "eth_signTypedData_v4" or "personal_sign"
// rpcAction.params - JSON string with signing parameters
}
}
}
}.onFailure { error ->
handleError(error)
}
```
Sign each required action using your wallet's signing implementation:
```kotlin theme={null}
val signatures = actions.map { action ->
when (action) {
is Wallet.Model.RequiredAction.WalletRpc -> {
val rpc = action.action
when (rpc.method) {
"eth_signTypedData_v4" -> signTypedDataV4(rpc.params)
"personal_sign" -> personalSign(rpc.params)
"eth_sendTransaction" -> sendTransaction(rpc.params)
else -> throw UnsupportedOperationException("Unsupported: ${rpc.method}")
}
}
}
}
```
#### Signing eth\_signTypedData\_v4
```kotlin theme={null}
import org.json.JSONArray
import org.web3j.crypto.ECKeyPair
import org.web3j.crypto.Sign
import org.web3j.crypto.StructuredDataEncoder
fun signTypedDataV4(params: String): String {
// params is a JSON array: [address, typedData]
val paramsArray = JSONArray(params)
val requestedAddress = paramsArray.getString(0)
val typedData = paramsArray.getString(1)
// Use StructuredDataEncoder for proper EIP-712 hashing
val encoder = StructuredDataEncoder(typedData)
val hash = encoder.hashStructuredData()
val keyPair = ECKeyPair.create(privateKeyBytes)
val signatureData = Sign.signMessage(hash, keyPair, false)
val rHex = signatureData.r.bytesToHex()
val sHex = signatureData.s.bytesToHex()
val v = signatureData.v[0].toInt() and 0xff
val vHex = v.toString(16).padStart(2, '0')
return "0x$rHex$sHex$vHex".lowercase()
}
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.action.method` (or `rpc.method` if you assign the action payload to a local variable) and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Submit signatures and finalize the payment:
```kotlin theme={null}
val confirmResult = WalletKit.Pay.confirmPayment(
Wallet.Params.ConfirmPayment(
paymentId = paymentId,
optionId = selectedOption.id,
signatures = signatures
)
)
confirmResult.onSuccess { response ->
when (response.status) {
Wallet.Model.PaymentStatus.SUCCEEDED -> {
// Payment completed successfully
}
Wallet.Model.PaymentStatus.PROCESSING -> {
// Payment is being processed
}
Wallet.Model.PaymentStatus.FAILED -> {
// Payment failed
}
Wallet.Model.PaymentStatus.EXPIRED -> {
// Payment expired
}
Wallet.Model.PaymentStatus.REQUIRES_ACTION -> {
// Additional action required
}
Wallet.Model.PaymentStatus.CANCELLED -> {
// Payment cancelled by user
}
}
}.onFailure { error ->
handleError(error)
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a WebView. The WebView handles form rendering, validation, and T\&C acceptance.
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```kotlin theme={null}
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.WebResourceRequest
import android.content.Intent
import android.net.Uri
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
@Composable
fun PayDataCollectionWebView(
url: String,
onComplete: () -> Unit,
onError: (String) -> Unit
) {
AndroidView(factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowFileAccess = false
addJavascriptInterface(
object {
@JavascriptInterface
fun onDataCollectionComplete(json: String) {
val message = JSONObject(json)
when (message.optString("type")) {
"IC_COMPLETE" -> onComplete()
"IC_ERROR" -> onError(
message.optString("error", "Unknown error")
)
}
}
},
"AndroidWallet"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val requestUrl = request?.url?.toString() ?: return false
if (!requestUrl.contains("pay.walletconnect.com")) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(requestUrl)))
return true
}
return false
}
}
loadUrl(url)
}
})
}
```
## Complete Example
Here's a complete implementation example:
```kotlin theme={null}
import com.reown.walletkit.client.WalletKit
import com.reown.walletkit.client.Wallet
import kotlinx.coroutines.launch
class PaymentViewModel : ViewModel() {
fun handleScannedUri(uri: String) {
if (WalletKit.Pay.isPaymentLink(uri)) {
processPayment(uri)
} else {
// Handle as WalletConnect pairing
WalletKit.pair(Wallet.Params.Pair(uri))
}
}
fun processPayment(paymentLink: String) {
viewModelScope.launch {
val walletAddress = "0xYourAddress"
// Step 1: Get payment options
val optionsResult = WalletKit.Pay.getPaymentOptions(
paymentLink = paymentLink,
accounts = listOf(
"eip155:1:$walletAddress",
"eip155:8453:$walletAddress",
"eip155:10:$walletAddress"
)
)
optionsResult.onSuccess { response ->
val paymentId = response.paymentId
val selectedOption = response.options.first()
// Step 2: Collect data via WebView if required for selected option.
// IC must happen BEFORE fetching the required actions — the WebView
// submits the data directly to the backend.
selectedOption.collectData?.url?.let { webViewUrl ->
showDataCollectionWebView(webViewUrl)
return@launch // Resume after WebView reports IC_COMPLETE
}
// Step 3: Get required actions
val actionsResult = WalletKit.Pay.getRequiredPaymentActions(
Wallet.Params.RequiredPaymentActions(
paymentId = paymentId,
optionId = selectedOption.id
)
)
actionsResult.onSuccess { actions ->
// Step 4: Sign actions
val signatures = signActions(actions)
// Step 5: Confirm payment
val confirmResult = WalletKit.Pay.confirmPayment(
Wallet.Params.ConfirmPayment(
paymentId = paymentId,
optionId = selectedOption.id,
signatures = signatures
)
)
confirmResult.onSuccess { confirmation ->
handlePaymentStatus(confirmation.status)
}.onFailure { error ->
handleError(error)
}
}.onFailure { error ->
handleError(error)
}
}.onFailure { error ->
handleError(error)
}
}
}
private suspend fun signActions(
actions: List
): List {
return actions.map { action ->
when (action) {
is Wallet.Model.RequiredAction.WalletRpc -> {
signWithWallet(action.action)
}
}
}
}
private fun handlePaymentStatus(status: Wallet.Model.PaymentStatus) {
when (status) {
Wallet.Model.PaymentStatus.SUCCEEDED -> showSuccess()
Wallet.Model.PaymentStatus.PROCESSING -> showProcessing()
Wallet.Model.PaymentStatus.FAILED -> showFailure()
Wallet.Model.PaymentStatus.EXPIRED -> showExpired()
Wallet.Model.PaymentStatus.REQUIRES_ACTION -> { /* Handle */ }
Wallet.Model.PaymentStatus.CANCELLED -> showCancelled()
}
}
}
```
## API Reference
**WalletKit.Pay**
The payment operations object within WalletKit.
**Methods**
| Method | Description |
| ------------------------------------------ | -------------------------------- |
| `isPaymentLink(uri: String): Boolean` | Check if URI is a payment link |
| `getPaymentOptions(paymentLink, accounts)` | Get available payment options |
| `getRequiredPaymentActions(params)` | Get actions requiring signatures |
| `confirmPayment(params)` | Confirm and finalize payment |
**Parameters**
**Wallet.Params.RequiredPaymentActions**
```kotlin theme={null}
data class RequiredPaymentActions(
val paymentId: String,
val optionId: String
)
```
**Wallet.Params.ConfirmPayment**
```kotlin theme={null}
data class ConfirmPayment(
val paymentId: String,
val optionId: String,
val signatures: List
)
```
## Data Models
**Wallet.Model.PaymentOptionsResponse**
```kotlin theme={null}
data class PaymentOptionsResponse(
val paymentId: String,
val info: PaymentInfo?,
val options: List,
val collectDataAction: CollectDataAction?,
val resultInfo: PaymentResultInfo? // Transaction result details (present when payment already completed)
)
data class PaymentResultInfo(
val txId: String, // Transaction ID
val optionAmount: PaymentAmount // Token amount details
)
```
**Wallet.Model.PaymentInfo**
```kotlin theme={null}
data class PaymentInfo(
val status: PaymentStatus,
val amount: PaymentAmount,
val expiresAt: Long,
val merchant: MerchantInfo
)
data class MerchantInfo(
val name: String,
val iconUrl: String?
)
```
**Wallet.Model.PaymentOption**
```kotlin theme={null}
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)
)
```
**Wallet.Model.PaymentAmount**
```kotlin theme={null}
data class PaymentAmount(
val value: String,
val unit: String,
val display: PaymentAmountDisplay?
)
data class PaymentAmountDisplay(
val assetSymbol: String,
val assetName: String,
val decimals: Int,
val iconUrl: String?,
val networkName: String?,
val networkIconUrl: String?
)
```
**Wallet.Model.WalletRpcAction**
```kotlin theme={null}
data class WalletRpcAction(
val chainId: String, // CAIP-2 chain ID (e.g., "eip155:8453")
val method: String, // RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
val params: String // JSON-encoded parameters
)
```
**Wallet.Model.CollectDataAction**
```kotlin theme={null}
data class CollectDataAction(
val url: String, // WebView URL for data collection
val schema: String? // JSON schema describing required fields
)
```
**Wallet.Model.ConfirmPaymentResponse**
```kotlin theme={null}
data class ConfirmPaymentResponse(
val status: PaymentStatus,
val isFinal: Boolean,
val pollInMs: Long?,
val info: PaymentResultInfo? // Transaction result details (present on success)
)
```
**Wallet.Model.PaymentStatus**
| Status | Description |
| ----------------- | ------------------------- |
| `REQUIRES_ACTION` | Additional action needed |
| `PROCESSING` | Payment in progress |
| `SUCCEEDED` | Payment completed |
| `FAILED` | Payment failed |
| `EXPIRED` | Payment expired |
| `CANCELLED` | Payment cancelled by user |
## Best Practices
1. **Use WalletKit Integration**: If your wallet already uses WalletKit, prefer this approach for automatic configuration
2. **Use `isPaymentLink()` for Detection**: Use the utility method instead of manual URL parsing for reliable payment link detection
3. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
4. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
5. **Signature Order**: Maintain the same order of signatures as the actions array
6. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
7. **Loading States**: Show loading indicators during API calls and signing operations
8. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
9. **User Data**: Only collect data when `collectData` is present on the selected payment option and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
10. **WebView Data Collection**: When `selectedOption.collectData?.url` is present, display the URL in a WebView rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
11. **Per-Option Data Collection**: When displaying payment options, check each option's `collectData` field. Show a visual indicator (e.g., "Info required" badge) on options that require data collection. Only open the WebView when the user selects an option with `collectData` present — use the option's `collectData.url` which is already scoped to that option's account.
# WalletConnect Pay via WalletKit - React Native
Source: https://docs.walletconnect.com/payments/wallets/walletkit/react-native
Integrate WalletConnect Pay through WalletKit for a unified payment experience in your React Native wallet.
This documentation covers integrating WalletConnect Pay through WalletKit for React Native wallets. This approach provides a unified API where Pay is built into WalletKit, simplifying the integration for wallet developers.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference React Native wallet app demonstrating WalletConnect Pay via WalletKit.
**Using AI for Integration?** If you're using an AI IDE or assistant to help with integration, you can provide it with our comprehensive [AI integration prompt](/payments/wallets/walletkit/ai-prompts/react-native) for better context and guidance.
## Requirements
* Node.js 16+
* WalletKit (`@reown/walletkit`)
You also need a WCP ID for your project, obtained from the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
**How to obtain a WCP ID**
1. Navigate to the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
2. Select the project that is associated with your wallet (as in, the projectId that is being used for your wallet's WalletConnect integration).
3. Click on the "Get Started" button to get a WCP ID associated with your project.
4. The Dashboard will now show the WCP ID associated with your project.
5. Click on the three dots on the right of the WCP ID and select "Copy WCP ID". You will be using this for your wallet's WalletConnect Pay integration.
## Installation
Install WalletKit using npm or yarn:
```bash theme={null}
npm install @reown/walletkit @walletconnect/core
```
```bash theme={null}
yarn add @reown/walletkit @walletconnect/core
```
WalletConnect Pay is automatically included as part of WalletKit.
Check the [npm page](https://www.npmjs.com/package/@reown/walletkit) for the latest version.
## Initialization
Initialize WalletKit as usual. Pay functionality is automatically available:
```javascript theme={null}
import { Core } from "@walletconnect/core";
import { WalletKit } from "@reown/walletkit";
const core = new Core({
projectId: process.env.PROJECT_ID,
});
const walletkit = await WalletKit.init({
core, // <- pass the shared `core` instance
metadata: {
name: "Demo app",
description: "Demo Client as Wallet/Peer",
url: "www.walletconnect.com",
icons: [],
},
payConfig: {
appId: "",
// or
apiKey: "",
}
});
```
## Payment Link Detection
Use `isPaymentLink` to determine if a scanned URI is a payment link or a standard WalletConnect pairing URI:
```javascript theme={null}
import { isPaymentLink } from "@reown/walletkit";
// Use when handling a scanned QR code or deep link
if (isPaymentLink(uri)) {
// Handle as payment (see below)
await processPayment(uri);
} else {
// Handle as WalletConnect pairing
await walletkit.pair({ uri });
}
```
## Payment Flow
The payment flow consists of six main steps:
**Detect Payment Link -> Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant WalletKit as WalletKit.Pay
participant Backend as WalletConnect Pay
participant WebView
User->>Wallet: Scan QR / Open payment link
Wallet->>WalletKit: isPaymentLink(uri)
WalletKit-->>Wallet: true
Wallet->>WalletKit: pay.getPaymentOptions(params)
WalletKit->>Backend: Fetch payment options
Backend-->>WalletKit: Payment options + merchant info
WalletKit-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Data collection required
Wallet->>WebView: Load collectDataAction.url in WebView
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>WalletKit: pay.getRequiredPaymentActions(params)
WalletKit->>Backend: Get signing actions
Backend-->>WalletKit: Required wallet RPC actions
WalletKit-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>WalletKit: pay.confirmPayment(params)
WalletKit->>Backend: Submit payment
Backend-->>WalletKit: Payment status
WalletKit-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
Retrieve available payment options for a payment link:
```javascript theme={null}
const options = await walletkit.pay.getPaymentOptions({
paymentLink: "https://pay.walletconnect.com/...",
accounts: ["eip155:1:0x...", "eip155:8453:0x..."],
includePaymentInfo: true,
});
// options.paymentId - unique payment identifier
// options.options - array of payment options (different tokens/chains)
// options.info - payment details (amount, merchant, expiry)
// Display merchant info
if (options.info) {
console.log("Merchant:", options.info.merchant.name);
console.log("Amount:", options.info.amount.display.assetSymbol, options.info.amount.value);
}
// Check which options require data collection (per-option)
for (const option of options.options) {
if (option.collectData) {
console.log(`Option ${option.id} requires info capture`);
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```javascript theme={null}
if (selectedOption.collectData?.url) {
// Use the "required" list from selectedOption.collectData.schema to determine which fields to prefill
const prefillData = {
fullName: "John Doe",
dob: "1990-01-15",
pobAddress: "123 Main St, New York, NY 10001",
};
// Encode prefill as base64url (URL-safe, no padding)
const prefill = btoa(JSON.stringify(prefillData))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// Optional appearance params (see "Form URL parameters"):
// theme=light|dark — base color mode
// themeVariables= — exported from the WalletConnect Pay Dashboard
const themeVariables = "";
const query = `prefill=${prefill}&theme=dark&themeVariables=${themeVariables}`;
const separator = selectedOption.collectData.url.includes("?") ? "&" : "?";
const webViewUrl = `${selectedOption.collectData.url}${separator}${query}`;
// Show WebView and wait for IC_COMPLETE message
showDataCollectionWebView(webViewUrl);
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
Get the wallet RPC actions needed to complete the payment:
```javascript theme={null}
const actions = await walletkit.pay.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: options.options[0].id,
});
// actions - array of wallet RPC calls to sign
// Each action contains: { walletRpc: { chainId, method, params } }
for (const action of actions) {
console.log("Chain:", action.walletRpc.chainId);
console.log("Method:", action.walletRpc.method);
console.log("Params:", action.walletRpc.params);
}
```
Sign each required action using your wallet's signing implementation:
```javascript theme={null}
// Sign each action based on its RPC method
const signatures = await Promise.all(
actions.map(async (action) => {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
})
);
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Submit the signatures and collected data to complete the payment:
```javascript theme={null}
const result = await walletkit.pay.confirmPayment({
paymentId: options.paymentId,
optionId: options.options[0].id,
signatures,
collectedData, // Optional, if collectData was present
});
// result.status - "succeeded" | "processing" | "failed" | "expired"
// result.isFinal - whether the payment is complete
// result.pollInMs - if not final, poll again after this delay
if (result.status === "succeeded") {
console.log("Payment successful!");
} else if (result.status === "processing") {
console.log("Payment is processing...");
} else if (result.status === "failed") {
console.log("Payment failed");
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a WebView using `react-native-webview`. Install the dependency:
```bash theme={null}
npm install react-native-webview@13.16.0
```
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```jsx theme={null}
import React, { useCallback } from "react";
import { WebView } from "react-native-webview";
import { Linking, View, ActivityIndicator } from "react-native";
function PayDataCollectionWebView({ url, onComplete, onError }) {
const handleMessage = useCallback(
(event) => {
try {
const data = JSON.parse(event.nativeEvent.data);
switch (data.type) {
case "IC_COMPLETE":
onComplete();
break;
case "IC_ERROR":
onError(data.error || "Unknown error");
break;
}
} catch {
// Ignore non-JSON messages
}
},
[onComplete, onError]
);
const handleNavigationRequest = useCallback((request) => {
if (!request.url.includes("pay.walletconnect.com")) {
Linking.openURL(request.url);
return false;
}
return true;
}, []);
return (
(
)}
/>
);
}
function buildFormUrl(baseUrl, options = {}) {
const params = [];
if (options.prefill && Object.keys(options.prefill).length > 0) {
// base64url: URL-safe, no padding
const prefill = btoa(JSON.stringify(options.prefill))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
params.push(`prefill=${prefill}`);
}
if (options.theme) params.push(`theme=${options.theme}`);
if (options.themeVariables) params.push(`themeVariables=${options.themeVariables}`);
if (params.length === 0) return baseUrl;
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}${params.join("&")}`;
}
```
## Complete Example
Here's a complete implementation example:
```javascript theme={null}
import { Core } from "@walletconnect/core";
import { WalletKit, isPaymentLink } from "@reown/walletkit";
class PaymentManager {
constructor() {
this.walletkit = null;
}
async initialize(projectId) {
const core = new Core({ projectId });
this.walletkit = await WalletKit.init({
core,
metadata: {
name: "My Wallet",
description: "A crypto wallet",
url: "https://mywallet.com",
icons: ["https://mywallet.com/icon.png"],
},
});
}
async handleScannedUri(uri) {
if (isPaymentLink(uri)) {
await this.processPayment(uri);
} else {
await this.walletkit.pair({ uri });
}
}
async processPayment(paymentLink) {
const walletAddress = "0xYourAddress";
try {
// Step 1: Get payment options
const options = await this.walletkit.pay.getPaymentOptions({
paymentLink,
accounts: [
`eip155:1:${walletAddress}`,
`eip155:8453:${walletAddress}`,
`eip155:137:${walletAddress}`,
],
includePaymentInfo: true,
});
if (options.options.length === 0) {
throw new Error("No payment options available");
}
// Step 2: Let user select an option (simplified - use first option)
const selectedOption = options.options[0];
// Step 3: Collect data via WebView if required — must happen BEFORE
// fetching the required actions, otherwise the backend rejects the request
if (selectedOption.collectData?.url) {
await this.showDataCollectionWebView(selectedOption.collectData.url);
}
// Step 4: Get required actions
const actions = await this.walletkit.pay.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: selectedOption.id,
});
// Step 5: Sign all actions
const signatures = await Promise.all(
actions.map((action) => this.signAction(action, walletAddress))
);
// Step 6: Confirm payment
const result = await this.walletkit.pay.confirmPayment({
paymentId: options.paymentId,
optionId: selectedOption.id,
signatures,
});
return result;
} catch (error) {
console.error("Payment failed:", error);
throw error;
}
}
async signAction(action, walletAddress) {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
}
}
```
## API Reference
**WalletKit Pay Methods**
Pay methods are accessed via `walletkit.pay.*`.
**Utility Functions**
| Function | Description |
| ------------------------------------- | ----------------------------------------------------------------- |
| `isPaymentLink(uri: string): boolean` | Check if URI is a payment link (imported from `@reown/walletkit`) |
**Instance Methods (walletkit.pay)**
| Method | Description |
| ----------------------------------- | ---------------------------------------- |
| `getPaymentOptions(params)` | Fetch available payment options |
| `getRequiredPaymentActions(params)` | Get signing actions for a payment option |
| `confirmPayment(params)` | Confirm and execute the payment |
**Parameters**
**GetPaymentOptionsParams**
```typescript theme={null}
interface GetPaymentOptionsParams {
paymentLink: string; // Payment link URL
accounts: string[]; // CAIP-10 accounts
includePaymentInfo?: boolean; // Include payment info in response
}
```
**GetRequiredPaymentActionsParams**
```typescript theme={null}
interface GetRequiredPaymentActionsParams {
paymentId: string; // Payment ID from getPaymentOptions
optionId: string; // Selected option ID
}
```
**ConfirmPaymentParams**
```typescript theme={null}
interface ConfirmPaymentParams {
paymentId: string; // Payment ID
optionId: string; // Selected option ID
signatures: string[]; // Signatures from wallet RPC calls
}
```
**Response Types**
**PaymentOptionsResponse**
```typescript theme={null}
interface PaymentOptionsResponse {
paymentId: string; // Unique payment identifier
info?: PaymentInfo; // Payment information
options: PaymentOption[]; // Available payment options
collectData?: CollectDataAction; // Data collection requirements
resultInfo?: PaymentResultInfo; // Transaction result details (present when payment already completed)
}
interface PaymentResultInfo {
txId: string; // Transaction ID
optionAmount: PayAmount; // Token amount details
}
```
**PaymentOption**
```typescript theme={null}
interface PaymentOption {
id: string; // Option identifier
amount: PayAmount; // Amount in this asset
etaS: number; // Estimated time to complete (seconds)
actions: Action[]; // Required signing actions
collectData?: CollectDataAction; // Per-option data collection (undefined if not required)
}
```
**Action**
```typescript theme={null}
interface Action {
walletRpc: WalletRpcAction;
}
interface WalletRpcAction {
chainId: string; // CAIP-2 chain ID (e.g., "eip155:8453")
method: string; // RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
params: string; // JSON-encoded parameters
}
```
**ConfirmPaymentResponse**
```typescript theme={null}
interface ConfirmPaymentResponse {
status: PaymentStatus; // Payment status
isFinal: boolean; // Whether status is final
pollInMs?: number; // Suggested poll interval
info?: PaymentResultInfo; // Transaction result details (present on success)
}
type PaymentStatus =
| "requires_action"
| "processing"
| "succeeded"
| "failed"
| "expired"
| "cancelled";
```
**PaymentInfo**
```typescript theme={null}
interface PaymentInfo {
status: PaymentStatus; // Current payment status
amount: PayAmount; // Requested payment amount
expiresAt: number; // Expiration timestamp (seconds since epoch)
merchant: MerchantInfo; // Merchant details
buyer?: BuyerInfo; // Buyer info if available
}
interface MerchantInfo {
name: string; // Merchant display name
iconUrl?: string; // Merchant logo URL
}
```
**PayAmount**
```typescript theme={null}
interface PayAmount {
unit: string; // Asset unit
value: string; // Raw value in smallest unit
display: AmountDisplay; // Human-readable display info
}
interface AmountDisplay {
assetSymbol: string; // Token symbol (e.g., "USDC")
assetName: string; // Token name (e.g., "USD Coin")
decimals: number; // Token decimals
iconUrl?: string; // Token icon URL
networkName?: string; // Network name (e.g., "Base")
}
```
**CollectDataAction**
```typescript theme={null}
interface CollectDataAction {
/** WebView URL for data collection */
url: string;
/** JSON schema describing required fields */
schema?: string;
}
```
## Error Handling
Handle errors gracefully in your payment flow:
```javascript theme={null}
try {
const options = await walletkit.pay.getPaymentOptions({
paymentLink,
accounts,
});
} catch (error) {
if (error.message.includes("payment not found")) {
console.error("Payment not found");
} else if (error.message.includes("expired")) {
console.error("Payment has expired");
} else {
console.error("Payment error:", error);
}
}
```
## Best Practices
1. **Use WalletKit Integration**: If your wallet already uses WalletKit, prefer this approach for automatic configuration
2. **Use `isPaymentLink()` for Detection**: Use the utility function instead of manual URL parsing for reliable payment link detection
3. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
4. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
5. **Signature Order**: Maintain the same order of signatures as the actions array
6. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
7. **Loading States**: Show loading indicators during API calls and signing operations
8. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
9. **User Data**: Only collect data when `collectData` is present in the response and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
10. **WebView Data Collection**: When `selectedOption.collectData.url` is present, display the URL in a WebView using `react-native-webview` rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
# WalletConnect Pay via WalletKit - Swift
Source: https://docs.walletconnect.com/payments/wallets/walletkit/swift
Integrate WalletConnect Pay through WalletKit for a unified payment experience in your iOS wallet.
This documentation covers integrating WalletConnect Pay through WalletKit. This approach provides a unified API where Pay is automatically configured when you configure WalletKit, simplifying the integration for wallet developers.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference iOS wallet app demonstrating WalletConnect Pay via WalletKit.
**Using AI for Integration?** If you're using an AI IDE or assistant to help with integration, you can provide it with our comprehensive [AI integration prompt](/payments/wallets/walletkit/ai-prompts/swift) for better context and guidance.
## Requirements
* iOS 13.0+
* Swift 5.7+
* Xcode 14.0+
* WalletKit (ReownWalletKit)
You also need a WCP ID for your project, obtained from the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
**How to obtain a WCP ID**
1. Navigate to the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
2. Select the project that is associated with your wallet (as in, the projectId that is being used for your wallet's WalletConnect integration).
3. Click on the "Get Started" button to get a WCP ID associated with your project.
4. The Dashboard will now show the WCP ID associated with your project.
5. Click on the three dots on the right of the WCP ID and select "Copy WCP ID". You will be using this for your wallet's WalletConnect Pay integration.
## Installation
**Swift Package Manager**
Add ReownWalletKit to your `Package.swift`:
```swift theme={null}
dependencies: [
.package(url: "https://github.com/reown-com/reown-swift", from: "1.0.0")
]
```
Then add `ReownWalletKit` to your target dependencies:
```swift theme={null}
.target(
name: "YourApp",
dependencies: ["ReownWalletKit"]
)
```
WalletConnectPay is automatically included as a dependency of WalletKit.
Check the [GitHub releases](https://github.com/reown-com/reown-swift/releases) for the latest version.
## Initialization
When using WalletKit, Pay is automatically configured using your project's `Networking.projectId`. No separate configuration is needed.
```swift theme={null}
import ReownWalletKit
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) {
// Configure WalletKit - Pay is automatically configured
WalletKit.configure(
metadata: AppMetadata(
name: "My Wallet",
description: "A crypto wallet",
url: "https://mywallet.com",
icons: ["https://mywallet.com/icon.png"]
),
crypto: DefaultCryptoProvider(),
payLogging: true // Enable Pay debug logging
)
}
```
## Payment Link Detection
Use the static `isPaymentLink` method to detect payment links before processing:
```swift theme={null}
// Static method - can be called before configure()
if WalletKit.isPaymentLink(scannedString) {
startPaymentFlow(paymentLink: scannedString)
}
// Or via the instance
if WalletKit.instance.Pay.isPaymentLink(scannedString) {
startPaymentFlow(paymentLink: scannedString)
}
```
The `isPaymentLink` utility method detects WalletConnect Pay links by checking for:
* `pay.` hosts (e.g., pay.walletconnect.com)
* `pay=` parameter in WalletConnect URIs
* `pay_` prefix in bare payment IDs
Call it wherever your wallet receives a link — from a deep link or a scanned QR code:
```swift theme={null}
// Deep link opened from outside your app (SceneDelegate or AppDelegate)
func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
guard let url = URLContexts.first?.url else { return }
if WalletKit.isPaymentLink(url.absoluteString) {
startPaymentFlow(paymentLink: url.absoluteString)
}
}
// QR code payload
func handleScannedQR(_ content: String) {
if WalletKit.isPaymentLink(content) {
startPaymentFlow(paymentLink: content)
}
}
```
## Payment Flow
The payment flow consists of six main steps:
**Detect Payment Link -> Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant WalletKit as WalletKit.Pay
participant Backend as WalletConnect Pay
participant WebView
User->>Wallet: Scan QR / Open payment link
Wallet->>WalletKit: isPaymentLink(uri)
WalletKit-->>Wallet: true
Wallet->>WalletKit: getPaymentOptions(link, accounts)
WalletKit->>Backend: Fetch payment options
Backend-->>WalletKit: Payment options + merchant info
WalletKit-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Selected option requires data collection
Wallet->>WebView: Load selectedOption.collectData.url
WebView->>User: Display data collection form
User->>WebView: Fill form & accept T&C
WebView-->>Wallet: IC_COMPLETE message
end
Wallet->>WalletKit: getRequiredPaymentActions(paymentId, optionId)
WalletKit->>Backend: Get signing actions
Backend-->>WalletKit: Required wallet RPC actions
WalletKit-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>WalletKit: confirmPayment(params)
WalletKit->>Backend: Submit payment
Backend-->>WalletKit: Payment status
WalletKit-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
When a user scans a payment QR code or opens a payment link, fetch available payment options:
```swift theme={null}
// 1. Get payment options
let options = try await WalletKit.instance.Pay.getPaymentOptions(
paymentLink: paymentLink,
accounts: ["eip155:1:\(address)", "eip155:137:\(address)"]
)
// Display merchant info
if let info = options.info {
print("Merchant: \(info.merchant.name)")
print("Amount: \(info.amount.display.assetSymbol) \(info.amount.value)")
}
// Show available payment options to user
for option in options.options {
print("Pay with \(option.amount.display.assetSymbol) on \(option.amount.display.networkName ?? "Unknown")")
}
// Check which options require data collection
for option in options.options {
if option.collectData != nil {
print("Option \(option.id) requires info capture")
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```swift theme={null}
// Check per-option data collection requirement after user selects an option
if let collectData = selectedOption.collectData, let url = collectData.url {
// Use the "required" list from collectData.schema to determine which fields to prefill
let prefillData: [String: String] = [
"fullName": "John Doe",
"dob": "1990-01-15",
"pobAddress": "123 Main St, New York, NY 10001"
]
let jsonData = try JSONSerialization.data(withJSONObject: prefillData)
// Encode prefill as base64url (URL-safe, no padding)
let prefillBase64 = jsonData.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
var components = URLComponents(string: url)!
var queryItems = components.queryItems ?? []
queryItems.append(URLQueryItem(name: "prefill", value: prefillBase64))
// Optional appearance params (see "Form URL parameters"):
queryItems.append(URLQueryItem(name: "theme", value: "dark")) // "light" | "dark"
// themeVariables is a base64url string exported from the WalletConnect Pay Dashboard:
// queryItems.append(URLQueryItem(name: "themeVariables", value: themeVariables))
components.queryItems = queryItems
let webViewUrl = components.string!
// Show WebView for this specific option and wait for IC_COMPLETE message
showWebView(url: webViewUrl)
}
```
### WebView Message Types
The WebView communicates with your wallet through JavaScript bridge messages. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
**Platform-Specific Bridge Names**
| Platform | Bridge Name | Handler |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Kotlin (Android) | `AndroidWallet` | `@JavascriptInterface onDataCollectionComplete(json: String)` |
| Swift (iOS) | `payDataCollectionComplete` | `WKScriptMessageHandler.didReceive(message:)` |
| Flutter | `ReactNativeWebView` (injected via JS bridge) | `JavaScriptChannel.onMessageReceived` |
| React Native | `ReactNativeWebView` (native) | `WebView.onMessage` prop |
After the user selects a payment option, get the signing actions:
```swift theme={null}
// 2. Get required actions for selected option
let actions = try await WalletKit.instance.Pay.getRequiredPaymentActions(
paymentId: options.paymentId,
optionId: selectedOption.id
)
```
Each action contains a `walletRpc` describing the RPC call to execute. Your wallet must check the `method` field and dispatch accordingly — for example, a payment option may require an `eth_sendTransaction` to approve a token allowance followed by an `eth_signTypedData_v4` to sign Permit2 typed data:
```swift theme={null}
// 3. Sign each action based on its RPC method
var signatures: [String] = []
for action in actions {
let rpc = action.walletRpc
let result: String
switch rpc.method {
case "eth_signTypedData_v4":
result = try await signTypedData(rpc)
case "eth_sendTransaction":
result = try await sendTransaction(rpc)
case "personal_sign":
result = try await personalSign(rpc)
default:
throw PaymentError.unsupportedMethod(rpc.method)
}
signatures.append(result)
}
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
#### Signing Implementation
```swift theme={null}
private func signAction(
action: Action,
walletAddress: String,
signer: YourSignerProtocol
) async throws -> String {
let rpc = action.walletRpc
switch rpc.method {
case "eth_signTypedData_v4":
guard let paramsData = rpc.params.data(using: .utf8),
let params = try JSONSerialization.jsonObject(with: paramsData) as? [Any],
params.count >= 2,
let typedDataJson = params[1] as? String else {
throw PaymentError.invalidParams
}
return try await signer.signTypedData(
data: typedDataJson,
address: walletAddress
)
case "eth_sendTransaction":
return try await signer.sendTransaction(
params: rpc.params,
chainId: rpc.chainId
)
case "personal_sign":
return try await signer.personalSign(
params: rpc.params,
address: walletAddress
)
default:
throw PaymentError.unsupportedMethod(rpc.method)
}
}
```
Signatures must be in the same order as the actions array.
Submit the signatures to complete the payment:
When using the WebView approach for data collection, the WebView submits the captured data directly to the backend, so you do **not** pass `collectedData` to `confirmPayment`.
```swift theme={null}
// 4. Confirm payment
let result = try await WalletKit.instance.Pay.confirmPayment(
paymentId: options.paymentId,
optionId: selectedOption.id,
signatures: signatures
)
switch result.status {
case .succeeded:
print("Payment successful!")
case .processing:
print("Payment is being processed...")
case .failed:
print("Payment failed")
case .expired:
print("Payment expired")
case .requiresAction:
print("Additional action required")
case .cancelled:
print("Payment cancelled")
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in a `WKWebView`. The WebView handles form rendering, validation, and T\&C acceptance.
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```swift theme={null}
import WebKit
import SwiftUI
struct PayDataCollectionWebView: UIViewRepresentable {
let url: URL
let onComplete: () -> Void
let onError: (String) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onComplete: onComplete, onError: onError)
}
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.userContentController.add(
context.coordinator,
name: "payDataCollectionComplete"
)
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let onComplete: () -> Void
let onError: (String) -> Void
init(onComplete: @escaping () -> Void, onError: @escaping (String) -> Void) {
self.onComplete = onComplete
self.onError = onError
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let body = message.body as? String,
let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return }
DispatchQueue.main.async {
switch type {
case "IC_COMPLETE":
self.onComplete()
case "IC_ERROR":
let error = json["error"] as? String ?? "Unknown error"
self.onError(error)
default:
break
}
}
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
if let host = url.host, !host.contains("pay.walletconnect.com") {
UIApplication.shared.open(url)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
}
}
```
## Complete Example
Here's a complete implementation using WalletKit:
```swift theme={null}
import ReownWalletKit
class PaymentManager {
func processPayment(
paymentLink: String,
walletAddress: String,
signer: YourSignerProtocol
) async throws {
// 1. Get payment options
let accounts = [
"eip155:1:\(walletAddress)",
"eip155:137:\(walletAddress)",
"eip155:8453:\(walletAddress)"
]
let optionsResponse = try await WalletKit.instance.Pay.getPaymentOptions(
paymentLink: paymentLink,
accounts: accounts
)
guard !optionsResponse.options.isEmpty else {
throw PaymentError.noOptionsAvailable
}
// 2. Let user select an option (simplified - use first option)
let selectedOption = optionsResponse.options[0]
// 3. Collect data via WebView if required (before fetching actions)
if let collectData = selectedOption.collectData, let url = collectData.url {
try await showDataCollectionWebView(url: url)
}
// 4. Get required actions
let actions = try await WalletKit.instance.Pay.getRequiredPaymentActions(
paymentId: optionsResponse.paymentId,
optionId: selectedOption.id
)
// 5. Sign all actions
var signatures: [String] = []
for action in actions {
let signature = try await signAction(
action: action,
walletAddress: walletAddress,
signer: signer
)
signatures.append(signature)
}
// 6. Confirm payment
let result = try await WalletKit.instance.Pay.confirmPayment(
paymentId: optionsResponse.paymentId,
optionId: selectedOption.id,
signatures: signatures
)
switch result.status {
case .succeeded:
break // Success
case .cancelled:
throw PaymentError.paymentCancelled
default:
throw PaymentError.paymentFailed(result.status)
}
}
private func signAction(
action: Action,
walletAddress: String,
signer: YourSignerProtocol
) async throws -> String {
let rpc = action.walletRpc
switch rpc.method {
case "eth_signTypedData_v4":
guard let paramsData = rpc.params.data(using: .utf8),
let params = try JSONSerialization.jsonObject(with: paramsData) as? [Any],
params.count >= 2,
let typedDataJson = params[1] as? String else {
throw PaymentError.invalidParams
}
return try await signer.signTypedData(
data: typedDataJson,
address: walletAddress
)
case "eth_sendTransaction":
return try await signer.sendTransaction(
params: rpc.params,
chainId: rpc.chainId
)
case "personal_sign":
return try await signer.personalSign(
params: rpc.params,
address: walletAddress
)
default:
throw PaymentError.unsupportedMethod(rpc.method)
}
}
}
```
## API Reference
**WalletKit Integration**
When using WalletKit, Pay methods are accessed via `WalletKit.instance.Pay.*`.
**Static Methods**
| Method | Description |
| ----------------------------- | ------------------------------------------------------------------- |
| `WalletKit.isPaymentLink(_:)` | Check if a string is a payment link (can call before `configure()`) |
**Instance Methods (WalletKit.instance.Pay)**
| Method | Description |
| ------------------------------------------------------------- | ---------------------------------------- |
| `isPaymentLink(_:)` | Check if a string is a payment link |
| `getPaymentOptions(paymentLink:accounts:includePaymentInfo:)` | Fetch available payment options |
| `getRequiredPaymentActions(paymentId:optionId:)` | Get signing actions for a payment option |
| `confirmPayment(paymentId:optionId:signatures:maxPollMs:)` | Confirm and execute the payment |
**Data Types**
**PaymentOptionsResponse**
```swift theme={null}
struct PaymentOptionsResponse {
let paymentId: String // Unique payment identifier
let info: PaymentInfo? // Merchant and amount details
let options: [PaymentOption] // Available payment methods
let collectData: CollectDataAction? // Required user data fields (travel rule)
let resultInfo: PaymentResultInfo? // Transaction result details (present when payment already completed)
}
struct PaymentResultInfo {
let txId: String // Transaction ID
let optionAmount: PayAmount // Token amount details
}
```
**PaymentInfo**
```swift theme={null}
struct PaymentInfo {
let status: PaymentStatus // Current payment status
let amount: PayAmount // Requested payment amount
let expiresAt: Int64 // Expiration timestamp
let merchant: MerchantInfo // Merchant details
let buyer: BuyerInfo? // Buyer info if available
}
```
**PaymentOption**
```swift theme={null}
struct PaymentOption {
let id: String // Option identifier
let amount: PayAmount // Amount in this asset
let etaS: Int64 // Estimated time to complete (seconds)
let actions: [Action] // Required signing actions
let collectData: CollectDataAction? // Per-option data collection (nil if not required)
}
```
**PayAmount**
```swift theme={null}
struct PayAmount {
let unit: String // Asset unit (e.g., "USDC")
let value: String // Raw value in smallest unit
let display: AmountDisplay // Human-readable display info
}
struct AmountDisplay {
let assetSymbol: String // Token symbol (e.g., "USDC")
let assetName: String // Token name (e.g., "USD Coin")
let decimals: Int64 // Token decimals
let iconUrl: String? // Token icon URL
let networkName: String? // Network name (e.g., "Base")
}
```
**Action & WalletRpcAction**
```swift theme={null}
struct Action {
let walletRpc: WalletRpcAction // RPC call to sign
}
struct WalletRpcAction {
let chainId: String // Chain ID (e.g., "eip155:8453")
let method: String // RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
let params: String // JSON-encoded parameters
}
```
**CollectDataAction**
```swift theme={null}
struct CollectDataAction {
let url: String // WebView URL for data collection
let schema: String? // JSON schema describing required fields
}
```
**ConfirmPaymentResultResponse**
```swift theme={null}
struct ConfirmPaymentResultResponse {
let status: PaymentStatus // Final payment status
let isFinal: Bool // Whether status is final
let pollInMs: Int64? // Suggested poll interval
let info: PaymentResultInfo? // Transaction result details (present on success)
}
```
**PaymentStatus**
```swift theme={null}
enum PaymentStatus {
case requiresAction // Additional action needed
case processing // Payment in progress
case succeeded // Payment completed
case failed // Payment failed
case expired // Payment expired
case cancelled // Payment cancelled by user
}
```
## Error Handling
The SDK throws specific error types for different failure scenarios:
**GetPaymentOptionsError**
| Error | Description |
| ------------------- | -------------------------- |
| `.paymentNotFound` | Payment ID doesn't exist |
| `.paymentExpired` | Payment has expired |
| `.invalidRequest` | Invalid request parameters |
| `.invalidAccount` | Invalid account format |
| `.complianceFailed` | Compliance check failed |
| `.http` | Network error |
| `.internalError` | Server error |
**GetPaymentRequestError**
| Error | Description |
| ------------------ | ----------------------------- |
| `.optionNotFound` | Selected option doesn't exist |
| `.paymentNotFound` | Payment ID doesn't exist |
| `.invalidAccount` | Invalid account format |
| `.http` | Network error |
**ConfirmPaymentError**
| Error | Description |
| ------------------- | ----------------------------- |
| `.paymentNotFound` | Payment ID doesn't exist |
| `.paymentExpired` | Payment has expired |
| `.invalidOption` | Invalid option ID |
| `.invalidSignature` | Signature verification failed |
| `.routeExpired` | Payment route expired |
| `.http` | Network error |
## Best Practices
1. **Use WalletKit Integration**: If your wallet already uses WalletKit, prefer the `WalletKit.instance.Pay.*` access pattern for automatic configuration
2. **Use `isPaymentLink()` for Detection**: Use the utility method instead of manual URL parsing for reliable payment link detection
3. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
4. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
5. **Signature Order**: Maintain the same order of signatures as the actions array
6. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
7. **Loading States**: Show loading indicators during API calls and signing operations
8. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
9. **User Data**: Only collect data when `collectData` is present on the selected payment option and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
10. **WebView Data Collection**: When `selectedOption.collectData?.url` is present, display the URL in a WKWebView rather than building native forms. The WebView handles form rendering, validation, and T\&C acceptance.
11. **Per-Option Data Collection**: When displaying payment options, check each option's `collectData` field. Show a visual indicator (e.g., "Info required" badge) on options that require data collection. Only open the WebView when the user selects an option with `collectData` present — use the option's `collectData.url` which is already scoped to that option's account.
# WalletConnect Pay via WalletKit - Web/Node.js
Source: https://docs.walletconnect.com/payments/wallets/walletkit/web
Integrate WalletConnect Pay through WalletKit for a unified payment experience in your Web/Node.js wallet.
This documentation covers integrating WalletConnect Pay through WalletKit for Web/Node.js wallets. This approach provides a unified API where Pay is built into WalletKit, simplifying the integration for wallet developers.
## Sample Wallet
For a complete working example, check out our sample wallet implementation:
A reference web wallet app demonstrating WalletConnect Pay via WalletKit.
**Using AI for Integration?** If you're using an AI IDE or assistant to help with integration, you can provide it with our comprehensive [AI integration prompt](/payments/wallets/walletkit/ai-prompts/react-native) for better context and guidance. The React Native prompt applies to Web/Node.js as well since they share the same JavaScript SDK.
## Requirements
* Node.js 16+
* WalletKit (`@reown/walletkit`)
You also need a WCP ID for your project, obtained from the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
**How to obtain a WCP ID**
1. Navigate to the [WalletConnect Dashboard](https://dashboard.walletconnect.com).
2. Select the project that is associated with your wallet (as in, the projectId that is being used for your wallet's WalletConnect integration).
3. Click on the "Get Started" button to get a WCP ID associated with your project.
4. The Dashboard will now show the WCP ID associated with your project.
5. Click on the three dots on the right of the WCP ID and select "Copy WCP ID". You will be using this for your wallet's WalletConnect Pay integration.
## Installation
Install WalletKit using npm or yarn:
```bash theme={null}
npm install @reown/walletkit @walletconnect/core
```
```bash theme={null}
yarn add @reown/walletkit @walletconnect/core
```
WalletConnect Pay is automatically included as part of WalletKit.
Check the [npm page](https://www.npmjs.com/package/@reown/walletkit) for the latest version.
## Initialization
Initialize WalletKit as usual. Pay functionality is automatically available:
```javascript theme={null}
import { Core } from "@walletconnect/core";
import { WalletKit } from "@reown/walletkit";
const core = new Core({
projectId: process.env.PROJECT_ID,
});
const walletkit = await WalletKit.init({
core, // <- pass the shared `core` instance
metadata: {
name: "Demo app",
description: "Demo Client as Wallet/Peer",
url: "www.walletconnect.com",
icons: [],
},
payConfig: {
appId: "",
// or
apiKey: "",
}
});
```
## Payment Link Detection
Use `isPaymentLink` to determine if a scanned URI is a payment link or a standard WalletConnect pairing URI:
```javascript theme={null}
import { isPaymentLink } from "@reown/walletkit";
// Use when handling a scanned QR code or deep link
if (isPaymentLink(uri)) {
// Handle as payment (see below)
await processPayment(uri);
} else {
// Handle as WalletConnect pairing
await walletkit.pair({ uri });
}
```
## Payment Flow
The payment flow consists of six main steps:
**Detect Payment Link -> Get Options -> Collect Data (if required) -> Get Actions -> Sign Actions -> Confirm Payment**
```mermaid theme={null}
sequenceDiagram
participant User
participant Wallet
participant WalletKit as WalletKit.Pay
participant Backend as WalletConnect Pay
participant Iframe
User->>Wallet: Scan QR / Open payment link
Wallet->>WalletKit: isPaymentLink(uri)
WalletKit-->>Wallet: true
Wallet->>WalletKit: pay.getPaymentOptions(params)
WalletKit->>Backend: Fetch payment options
Backend-->>WalletKit: Payment options + merchant info
WalletKit-->>Wallet: PaymentOptionsResponse
Wallet->>User: Display payment options
User->>Wallet: Select payment option
alt Data collection required
Wallet->>Iframe: Load collectDataAction.url in iframe
Iframe->>User: Display data collection form
User->>Iframe: Fill form & accept T&C
Iframe-->>Wallet: IC_COMPLETE message via postMessage
end
Wallet->>WalletKit: pay.getRequiredPaymentActions(params)
WalletKit->>Backend: Get signing actions
Backend-->>WalletKit: Required wallet RPC actions
WalletKit-->>Wallet: List of actions to sign
Wallet->>User: Request signature(s)
User->>Wallet: Approve & sign
Wallet->>WalletKit: pay.confirmPayment(params)
WalletKit->>Backend: Submit payment
Backend-->>WalletKit: Payment status
WalletKit-->>Wallet: ConfirmPaymentResponse
Wallet->>User: Show result
```
Retrieve available payment options for a payment link:
```javascript theme={null}
const options = await walletkit.pay.getPaymentOptions({
paymentLink: "https://pay.walletconnect.com/...",
accounts: ["eip155:1:0x...", "eip155:8453:0x..."],
includePaymentInfo: true,
});
// options.paymentId - unique payment identifier
// options.options - array of payment options (different tokens/chains)
// options.info - payment details (amount, merchant, expiry)
// Display merchant info
if (options.info) {
console.log("Merchant:", options.info.merchant.name);
console.log("Amount:", options.info.amount.display.assetSymbol, options.info.amount.value);
}
// Check which options require data collection (per-option)
for (const option of options.options) {
if (option.collectData) {
console.log(`Option ${option.id} requires info capture`);
}
}
```
After the user selects an option, check for `collectData` on it. If present, collect the data **before** fetching the required actions.
## Embedded Data Collection Form
When a payment requires user information (e.g., for Travel Rule compliance), the SDK returns a `collectData` field on individual payment options. Each option may independently require data collection — some options may require it while others don't.
The form is loaded from `selectedOption.collectData.url` and embedded in your wallet (a WebView on mobile, an iframe on web). It handles field rendering, validation, Terms & Conditions and Privacy Policy acceptance, and submits data directly to the backend.
Collect this data **before** fetching the required actions. For an option that requires Information Capture, `getRequiredPaymentActions` fails with `400 IC data required` until the data has been submitted.
### Recommended Flow
The recommended approach is to display all payment options upfront, then handle data collection only when the user selects an option that requires it:
1. Call `getPaymentOptions` and display all available options to the user
2. Show a visual indicator (e.g., "Info required" badge) on options where `option.collectData` is present
3. When the user selects an option, check `selectedOption.collectData`
4. If present, load `selectedOption.collectData.url` in the embedded form
5. Optionally append query parameters to the form URL — `prefill` (known user data), `theme`, and `themeVariables` (appearance). See [Form URL parameters](#form-url-parameters) below. Use proper URL building so existing query parameters are preserved.
6. Listen for completion messages: `IC_COMPLETE` (success) or `IC_ERROR` (failure)
7. On `IC_COMPLETE`, continue the flow — fetch the required actions, sign, and confirm the payment. Don't pass `collectedData` to `confirmPayment()`; the form submits data directly to the backend
### Decision Matrix
| Response `collectData` | `option.collectData` | Behavior |
| ---------------------- | -------------------- | ------------------------------------------------------------------- |
| present | present | Option requires IC — use `option.collectData.url` |
| present | `null` | Option does NOT require IC (others might) — skip IC for this option |
| `null` | `null` | No IC needed for any option |
### Form URL parameters
The form URL accepts the following optional query parameters. Append them to `selectedOption.collectData.url` before loading it, preserving any existing query parameters.
| Parameter | Format | Description |
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefill` | base64url-encoded JSON | Pre-populates known user fields so the user doesn't re-enter them. Keys must match the `required` fields from `collectData.schema` (e.g. `fullName`, `dob`, `pobAddress`). |
| `theme` | `light` or `dark` | Sets the form's base color mode. |
| `themeVariables` | base64url-encoded JSON | Overrides design tokens to match your brand — font, font size, select colors, button border radius, and input border radius. Generate and export this value from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). |
`collectData.schema` is a JSON schema **string** — parse it and read its `required` array to discover the field keys for `prefill`. For example, a `required` array of `["fullName", "dob", "pobAddress"]` maps to a prefill object of `{"fullName": "...", "dob": "...", "pobAddress": "..."}`.
#### Customizing the form appearance
`theme` and `themeVariables` are optional and independent — pass either, both, or neither:
* **`theme`** switches the form between `light` and `dark` base color modes. Match it to your wallet's active mode for a seamless transition.
* **`themeVariables`** applies brand-level overrides (font, font size, select colors, button border radius, and input border radius). Generate the theme in the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com), export it as a base64url string, and append it to the form URL verbatim — you don't need to encode it at runtime.
The top-level `collectData` on the payment options response is still available for backward compatibility. However, the per-option `collectData` is the recommended approach as it provides more granular control over the flow.
Do **not** pass `collectedData` to `confirmPayment()` when using the embedded form. The form handles data submission directly.
```javascript theme={null}
if (selectedOption.collectData?.url) {
// Use the "required" list from selectedOption.collectData.schema to determine which fields to prefill
const prefillData = {
fullName: "John Doe",
dob: "1990-01-15",
pobAddress: "123 Main St, New York, NY 10001",
};
// Encode prefill as base64url (URL-safe, no padding)
const prefill = btoa(JSON.stringify(prefillData))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// Optional appearance params (see "Form URL parameters"):
// theme=light|dark — base color mode
// themeVariables= — exported from the WalletConnect Pay Dashboard
const themeVariables = "";
const query = `prefill=${prefill}&theme=dark&themeVariables=${themeVariables}`;
const separator = selectedOption.collectData.url.includes("?") ? "&" : "?";
const iframeUrl = `${selectedOption.collectData.url}${separator}${query}`;
// Show data collection in an iframe or popup and wait for IC_COMPLETE message
showDataCollectionView(iframeUrl);
}
```
### Iframe Message Types
The iframe communicates with your wallet through `postMessage` events. The message payload is a JSON string with the following structure:
| Message Type | Payload | Description |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `IC_COMPLETE` | `{ "type": "IC_COMPLETE", "success": true }` | User completed the form successfully. Proceed to payment confirmation. |
| `IC_ERROR` | `{ "type": "IC_ERROR", "error": "..." }` | An error occurred. Display the error message and allow the user to retry. |
Get the wallet RPC actions needed to complete the payment:
```javascript theme={null}
const actions = await walletkit.pay.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: options.options[0].id,
});
// actions - array of wallet RPC calls to sign
// Each action contains: { walletRpc: { chainId, method, params } }
for (const action of actions) {
console.log("Chain:", action.walletRpc.chainId);
console.log("Method:", action.walletRpc.method);
console.log("Params:", action.walletRpc.params);
}
```
Sign each required action using your wallet's signing implementation:
```javascript theme={null}
// Sign each action based on its RPC method
const signatures = await Promise.all(
actions.map(async (action) => {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
})
);
```
Payment options may include multiple actions with different RPC methods. For example, a Permit2 payment where the user lacks sufficient allowance returns two actions: an `eth_sendTransaction` to approve the token allowance, followed by an `eth_signTypedData_v4` to sign the Permit2 transfer. Your wallet must check `action.walletRpc.method` and dispatch to the appropriate handler. For full implementation guidance, see [USDT support](/payments/wallets/token-chain-support/usdt-support).
Signatures must be in the same order as the actions array.
Submit the signatures and collected data to complete the payment:
```javascript theme={null}
const result = await walletkit.pay.confirmPayment({
paymentId: options.paymentId,
optionId: options.options[0].id,
signatures,
collectedData, // Optional, if collectData was present
});
// result.status - "succeeded" | "processing" | "failed" | "expired"
// result.isFinal - whether the payment is complete
// result.pollInMs - if not final, poll again after this delay
if (result.status === "succeeded") {
console.log("Payment successful!");
} else if (result.status === "processing") {
console.log("Payment is processing...");
} else if (result.status === "failed") {
console.log("Payment failed");
}
```
## Data Collection Implementation
When `selectedOption.collectData.url` is present, display the URL in an iframe or modal. Listen for `postMessage` events to handle completion:
**Data Collection Best Practices**
* **Display prominently**: Show the form full-screen or as a prominent modal so users can interact with it easily
* **Loading indicator**: Show a loading indicator while the form loads
* **Handle errors**: Listen for `IC_ERROR` messages and display a user-facing error message with an option to retry
* **External links**: Open Terms & Conditions and Privacy Policy links in the system browser rather than navigating within the form
* **Domain restriction**: Only allow navigation to WalletConnect pay domains and HTTPS URLs
* **Back navigation**: Handle back/dismiss gracefully — confirm cancellation with the user before closing the form mid-flow
* **Keyboard behavior**: Test that the soft keyboard appears and behaves correctly when users tap on form inputs
* **Theme to match your brand**: Pass `theme=light` or `theme=dark` to match your wallet's active color mode, and apply brand tokens with `themeVariables` exported from the [WalletConnect Pay Dashboard](https://dashboard.walletconnect.com). See [Form URL parameters](#form-url-parameters).
```javascript theme={null}
function showDataCollectionView(url) {
// Create a modal/overlay container
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;";
const iframe = document.createElement("iframe");
iframe.src = url;
iframe.style.cssText = "width:450px;height:650px;border:none;border-radius:12px;";
overlay.appendChild(iframe);
document.body.appendChild(overlay);
return new Promise((resolve, reject) => {
function handleMessage(event) {
try {
const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
if (data.type === "IC_COMPLETE") {
cleanup();
resolve();
} else if (data.type === "IC_ERROR") {
cleanup();
reject(new Error(data.error || "Unknown error"));
}
} catch {
// Ignore non-JSON messages
}
}
function cleanup() {
window.removeEventListener("message", handleMessage);
document.body.removeChild(overlay);
}
window.addEventListener("message", handleMessage);
});
}
function buildFormUrl(baseUrl, options = {}) {
const params = [];
if (options.prefill && Object.keys(options.prefill).length > 0) {
// base64url: URL-safe, no padding
const prefill = btoa(JSON.stringify(options.prefill))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
params.push(`prefill=${prefill}`);
}
if (options.theme) params.push(`theme=${options.theme}`);
if (options.themeVariables) params.push(`themeVariables=${options.themeVariables}`);
if (params.length === 0) return baseUrl;
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}${params.join("&")}`;
}
```
## Complete Example
Here's a complete implementation example:
```javascript theme={null}
import { Core } from "@walletconnect/core";
import { WalletKit, isPaymentLink } from "@reown/walletkit";
class PaymentManager {
constructor() {
this.walletkit = null;
}
async initialize(projectId) {
const core = new Core({ projectId });
this.walletkit = await WalletKit.init({
core,
metadata: {
name: "My Wallet",
description: "A crypto wallet",
url: "https://mywallet.com",
icons: ["https://mywallet.com/icon.png"],
},
});
}
async handleScannedUri(uri) {
if (isPaymentLink(uri)) {
await this.processPayment(uri);
} else {
await this.walletkit.pair({ uri });
}
}
async processPayment(paymentLink) {
const walletAddress = "0xYourAddress";
try {
// Step 1: Get payment options
const options = await this.walletkit.pay.getPaymentOptions({
paymentLink,
accounts: [
`eip155:1:${walletAddress}`,
`eip155:8453:${walletAddress}`,
`eip155:137:${walletAddress}`,
],
includePaymentInfo: true,
});
if (options.options.length === 0) {
throw new Error("No payment options available");
}
// Step 2: Let user select an option (simplified - use first option)
const selectedOption = options.options[0];
// Step 3: Collect data via iframe if required for selected option.
// This must happen BEFORE fetching required actions, otherwise the
// backend rejects the fetch for options that require info capture.
if (selectedOption.collectData?.url) {
await this.showDataCollectionView(selectedOption.collectData.url);
}
// Step 4: Get required actions
const actions = await this.walletkit.pay.getRequiredPaymentActions({
paymentId: options.paymentId,
optionId: selectedOption.id,
});
// Step 5: Sign all actions
const signatures = await Promise.all(
actions.map((action) => this.signAction(action, walletAddress))
);
// Step 6: Confirm payment
const result = await this.walletkit.pay.confirmPayment({
paymentId: options.paymentId,
optionId: selectedOption.id,
signatures,
});
return result;
} catch (error) {
console.error("Payment failed:", error);
throw error;
}
}
async signAction(action, walletAddress) {
const { chainId, method, params } = action.walletRpc;
const parsedParams = JSON.parse(params);
switch (method) {
case "eth_signTypedData_v4":
return await wallet.signTypedData(chainId, parsedParams);
case "eth_sendTransaction":
return await wallet.sendTransaction(chainId, parsedParams[0]);
case "personal_sign":
return await wallet.personalSign(chainId, parsedParams);
default:
throw new Error(`Unsupported RPC method: ${method}`);
}
}
}
```
## API Reference
**WalletKit Pay Methods**
Pay methods are accessed via `walletkit.pay.*`.
**Utility Functions**
| Function | Description |
| ------------------------------------- | ----------------------------------------------------------------- |
| `isPaymentLink(uri: string): boolean` | Check if URI is a payment link (imported from `@reown/walletkit`) |
**Instance Methods (walletkit.pay)**
| Method | Description |
| ----------------------------------- | ---------------------------------------- |
| `getPaymentOptions(params)` | Fetch available payment options |
| `getRequiredPaymentActions(params)` | Get signing actions for a payment option |
| `confirmPayment(params)` | Confirm and execute the payment |
**Parameters**
**GetPaymentOptionsParams**
```typescript theme={null}
interface GetPaymentOptionsParams {
paymentLink: string; // Payment link URL
accounts: string[]; // CAIP-10 accounts
includePaymentInfo?: boolean; // Include payment info in response
}
```
**GetRequiredPaymentActionsParams**
```typescript theme={null}
interface GetRequiredPaymentActionsParams {
paymentId: string; // Payment ID from getPaymentOptions
optionId: string; // Selected option ID
}
```
**ConfirmPaymentParams**
```typescript theme={null}
interface ConfirmPaymentParams {
paymentId: string; // Payment ID
optionId: string; // Selected option ID
signatures: string[]; // Signatures from wallet RPC calls
}
```
**Response Types**
**PaymentOptionsResponse**
```typescript theme={null}
interface PaymentOptionsResponse {
paymentId: string; // Unique payment identifier
info?: PaymentInfo; // Payment information
options: PaymentOption[]; // Available payment options
collectData?: CollectDataAction; // Data collection requirements
resultInfo?: PaymentResultInfo; // Transaction result details (present when payment already completed)
}
interface PaymentResultInfo {
txId: string; // Transaction ID
optionAmount: PayAmount; // Token amount details
}
```
**PaymentOption**
```typescript theme={null}
interface PaymentOption {
id: string; // Option identifier
amount: PayAmount; // Amount in this asset
etaS: number; // Estimated time to complete (seconds)
actions: Action[]; // Required signing actions
collectData?: CollectDataAction; // Per-option data collection (undefined if not required)
}
```
**Action**
```typescript theme={null}
interface Action {
walletRpc: WalletRpcAction;
}
interface WalletRpcAction {
chainId: string; // CAIP-2 chain ID (e.g., "eip155:8453")
method: string; // RPC method (e.g., "eth_signTypedData_v4", "eth_sendTransaction")
params: string; // JSON-encoded parameters
}
```
**ConfirmPaymentResponse**
```typescript theme={null}
interface ConfirmPaymentResponse {
status: PaymentStatus; // Payment status
isFinal: boolean; // Whether status is final
pollInMs?: number; // Suggested poll interval
info?: PaymentResultInfo; // Transaction result details (present on success)
}
type PaymentStatus =
| "requires_action"
| "processing"
| "succeeded"
| "failed"
| "expired"
| "cancelled";
```
**PaymentInfo**
```typescript theme={null}
interface PaymentInfo {
status: PaymentStatus; // Current payment status
amount: PayAmount; // Requested payment amount
expiresAt: number; // Expiration timestamp (seconds since epoch)
merchant: MerchantInfo; // Merchant details
buyer?: BuyerInfo; // Buyer info if available
}
interface MerchantInfo {
name: string; // Merchant display name
iconUrl?: string; // Merchant logo URL
}
```
**PayAmount**
```typescript theme={null}
interface PayAmount {
unit: string; // Asset unit
value: string; // Raw value in smallest unit
display: AmountDisplay; // Human-readable display info
}
interface AmountDisplay {
assetSymbol: string; // Token symbol (e.g., "USDC")
assetName: string; // Token name (e.g., "USD Coin")
decimals: number; // Token decimals
iconUrl?: string; // Token icon URL
networkName?: string; // Network name (e.g., "Base")
}
```
**CollectDataAction**
```typescript theme={null}
interface CollectDataAction {
/** URL for data collection (displayed in iframe) */
url: string;
/** JSON schema describing required fields */
schema?: string;
}
```
## Error Handling
Handle errors gracefully in your payment flow:
```javascript theme={null}
try {
const options = await walletkit.pay.getPaymentOptions({
paymentLink,
accounts,
});
} catch (error) {
if (error.message.includes("payment not found")) {
console.error("Payment not found");
} else if (error.message.includes("expired")) {
console.error("Payment has expired");
} else {
console.error("Payment error:", error);
}
}
```
## Best Practices
1. **Use WalletKit Integration**: If your wallet already uses WalletKit, prefer this approach for automatic configuration
2. **Use `isPaymentLink()` for Detection**: Use the utility function instead of manual URL parsing for reliable payment link detection
3. **Account Format**: Always use CAIP-10 format for accounts: `eip155:{chainId}:{address}`
4. **Multiple Chains**: Provide accounts for all supported chains to maximize payment options
5. **Signature Order**: Maintain the same order of signatures as the actions array
6. **Error Handling**: Always handle errors gracefully and show appropriate user feedback
7. **Loading States**: Show loading indicators during API calls and signing operations
8. **Expiration**: Check `paymentInfo.expiresAt` and warn users if time is running low
9. **User Data**: Only collect data when `collectData` is present in the response and you don't already have the required user data. If you already have the required data, you can submit this without collecting from the user. You must make sure the user accepts WalletConnect Terms and Conditions and Privacy Policy before submitting user information to WalletConnect.
10. **Data Collection**: When `selectedOption.collectData.url` is present, display the URL in an iframe or modal rather than building custom forms. The hosted form handles rendering, validation, and T\&C acceptance.