Skip to main content
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:
  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 (<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.
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 HandlerMethodEngine call
app/api/wcp/payment/[id]/route.tsGETgetPayment
app/api/wcp/payment/[id]/options/route.tsPOSTgetPaymentOptions
app/api/wcp/payment/[id]/fetch/route.tsPOSTfetchOptionActions
app/api/wcp/payment/[id]/confirm/route.tsPOSTconfirmPayment
app/api/wcp/payment/[id]/status/route.tsGETgetPaymentStatus
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.
'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>
  )
}
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.
'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.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.

headless-checkout-vanilla

The same checkout with no framework — createPaymentController + manual subscribe + imperative render.

Next steps

Packages Reference

The full public API of pay-core, pay-state, pay-react, and pay-appkit.

API Reference

The Gateway and Payments endpoints behind the SDK.