Implementation - WalletConnect Pay Docs
Documentation Index
Fetch the complete documentation index at: /llms.txt
Use this file to discover all available pages before exploring further.
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 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:
- A server proxy — routes that forward to the Engine with your secret key.
- A browser transport — points the runtime at those routes.
- The AppKit provider — one component (
<PayAppKitProvider>in React) or one factory call (createPayAppKitin 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.
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. Enable the headless feature on the project.
- A WalletConnect Pay Gateway API key for the Engine (server-side). Talk to us 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.
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.
lib/server/engine.ts
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<Response> {
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:
app/api/wcp/payment/[id]/options/route.ts
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 })
}
app/api/wcp/payment/[id]/status/route.ts
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.
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 <PayAppKitProvider> 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.
React — components/providers.tsx
'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 (
<PayAppKitProvider
projectId={projectId}
metadata={{
name: 'Acme Pay',
description: 'Headless checkout',
url: 'https://example.com',
icons: []
}}
>
{children}
</PayAppKitProvider>
)
}
JavaScript — appkit.ts
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()
<PayAppKitProvider> 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.
React — components/checkout.tsx
'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 <div>{/* render per snapshot.state — see Step 5 */}</div>
}
JavaScript — main.ts
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:
app/[paymentId]/page.tsx
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 <Providers><Checkout paymentId={paymentId} /></Providers>
}
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()).
switch (snapshot.state) {
case 'ReadyForWallet':
// Show the QR (from getWcUri/wcUri) and a wallet picker. On pick:
return <WalletPicker wallets={wallets} onPick={(w) => connectWallet(w, w.namespaces[0])} />
case 'ConnectingWallet':
return <Spinner label="Connecting…" />
case 'LoadingOptions':
return <Spinner label="Finding payment options…" />
case 'OptionsReady':
return (
<OptionList
options={snapshot.options}
onSelect={(opt: PaymentOptionExtended, rank: number) => selectOption(opt, rank)}
/>
)
case 'NoOptions':
return <Empty label="No payment options for this wallet." />
case 'InformationCapture':
// Render snapshot.collectData.fields, then:
return <KycForm fields={snapshot.collectData?.fields} onSubmit={submitInfoCapture} />
case 'OptionSelected':
case 'RequiresApproval':
return (
<button onClick={() => confirmSelection()}>
{snapshot.requiresApproval ? 'Approve & pay' : 'Confirm'}
</button>
)
case 'AwaitingWalletApproval':
return <Spinner label="Approve in your wallet…" />
case 'WaitingForConfirmation':
return <Spinner label="Submitting payment…" />
case 'Succeeded':
return <Success payment={snapshot.payment} />
case 'Failed':
case 'PaymentExpired':
case 'PaymentCancelled':
case 'InvalidPayment':
case 'SanctionedUser':
return <Failure state={snapshot.state} error={snapshot.signingError} />
}
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
.env.local
# 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
[**headless-checkout (Next.js)**
The full React checkout — <PayAppKitProvider> + usePaymentSession, no @reown/* in the app.](https://github.com/WalletConnect/walletconnect-pay-examples/tree/main/gateway/headless-checkout)
[**headless-checkout-vanilla**
The same checkout with no framework — createPaymentController + manual subscribe + imperative render.](https://github.com/WalletConnect/walletconnect-pay-examples/tree/main/gateway/headless-checkout-vanilla)
Next steps
[**Packages Reference**
The full public API of pay-core, pay-state, pay-react, and pay-appkit.](https://docs.walletconnect.com/payments/psps/headless-sdk/packages-reference)
[**API Reference**
The Gateway and Payments endpoints behind the SDK.](https://docs.walletconnect.com/api-reference/index)
[How it works
Previous](https://docs.walletconnect.com/payments/psps/headless-sdk/how-it-works) [Packages Reference
Next](https://docs.walletconnect.com/payments/psps/headless-sdk/packages-reference)