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 (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.
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.
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 })}
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.
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.
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.
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.
'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>}
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.walletconst 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:
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.
# 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 browserWCP_API_URL=https://staging.api.pay.walletconnect.orgWCP_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.