2026-09-17
Build Your Checkout UI with @klappay/checkout-kit

We wrote before about treating checkout as infrastructure you own instead of a hosted page you redirect to. That post was deliberately conceptual — settlement models, event-driven order state, the shape of a good checkout. It skipped the part every team actually gets stuck on: the code between "here's a Charge" and "the payer's wallet just sent a transaction."
That gap is where a custom checkout quietly turns into a multi-week project. You need to know which chainId a given token lives on, encode a transfer(address,uint256) call by hand, ask a wallet to switch networks and handle the case where it doesn't recognize the chain yet, build an EIP-681 URI for the QR fallback, and keep all of that in sync with whatever the charge is actually doing server-side. None of it is hard exactly — it's just tedious, easy to get subtly wrong, and has nothing to do with the product you're actually trying to ship.
@klappay/checkout-kit is that gap, closed. Not a simplified rewrite for an SDK demo — a straight port of the exact code running behind Klappay's own hosted checkout page, the same logic that already handles real payments today, extracted into a reusable package. One MIT-licensed install, a Node half for your backend, a headless client half for whatever frontend you've already picked.
pnpm add @klappay/checkout-kit @klappay/types
The rest of this post walks through it roughly in the order you'd actually build a checkout: get a payload, show payment options, let a wallet pay, keep the UI honest while you wait, and handle the payers who don't fit the happy path.
Start on the server: turn a charge into a payload
A custom checkout should never let the browser decide the price, the recipient, or whether an order is paid — that logic starts on your backend, and so does this package. createCheckoutKit() wraps @klappay/node with one extra method built for exactly this:
import { createCheckoutKit } from '@klappay/checkout-kit/node'const checkout = createCheckoutKit({apiKey: process.env.KLAP_API_KEY,baseUrl: process.env.KLAP_BASE_URL,})app.get('/api/checkout/:id', async (c) => {const payload = await checkout.getCheckoutPayload(c.req.param('id'))return c.json(payload)})
getCheckoutPayload() fetches the charge with your API key, then hands back a curated CheckoutPayload — amount, currency, status, address, a paymentOptions array. Deliberately curated: apiKeyId, externalRef, source, and metadata stay out of it. Those fields are your own bookkeeping, and a payer's browser has no business receiving them just because they happened to be sitting on the same Charge object.
If you don't pass apiKey/baseUrl explicitly, createCheckoutKit() reads KLAP_API_KEY/KLAP_BASE_URL from the environment instead — an explicit argument always wins, so createCheckoutKit() with no arguments at all is a perfectly valid way to write this if your env is already set.
Not every team wants the default shape. Maybe you want to merge in your own product data, or drop a field, or add one. Rather than bolt on a transform option nobody agrees on the shape of, the convenience wrapper is just composed from the pieces it's built from — call them yourself:
import { resolvePaymentOptions, toCheckoutPayload } from '@klappay/checkout-kit/node'const charge = await checkout.getCharge(chargeId) // the full, raw Chargeconst options = resolvePaymentOptions(charge) // one PaymentOption per accepted pair
Real production code doesn't stop at the happy path, and neither should your route. Here's the same endpoint from the package's own Hono example, with the error handling most teams forget to write on the first pass:
import { KlapApiError, MissingBaseUrlError, MissingCredentialError } from '@klappay/node'app.get('/api/checkout/:id', async (c) => {try {const payload = await checkout.getCheckoutPayload(c.req.param('id'))return c.json(payload)} catch (err) {if (err instanceof KlapApiError && err.status === 404) {return c.json({ error: 'charge not found' }, 404)}// Credential resolution is lazy — createCheckoutKit() itself never// throws for a missing KLAP_API_KEY/KLAP_BASE_URL, only the first// request that actually needs them does.if (err instanceof MissingCredentialError || err instanceof MissingBaseUrlError) {return c.json({ error: err.message }, 500)}throw err}})
That's a real, deployable route, not a sketch — it's what the Hono example actually runs.
The chain math you don't have to re-derive
A charge can accept more than one (token, network) pair — USDC on Base and on Polygon, say. payload.paymentOptions returns one PaymentOption per pair, and each one already carries the chainId, contractAddress, and amountUnits a wallet needs to send the correct transaction. That's the part most teams end up hand-rolling from a viem/ethers chain-ID table and a token-address lookup, kept in sync by hand as networks get added. Here it's just data on the object you already fetched.
Not every pair has a wallet mapping this package knows about yet — some network gets added to the accepted list before it gets added to the chain table. Rather than quietly drop that option from the array (which would make a real, still-payable option invisible to your UI), chainId/contractAddress simply come back null. The payer can still send to payload.address directly, by QR or by copy-paste — they just can't use the one-click wallet button for it. isWalletPayable() is the one-line gate:
const walletOptions = payload.paymentOptions.filter(isWalletPayable)const [option] = walletOptions
Let a wallet pay
This is the part that used to mean reading the EIP-1193 spec, hand-encoding transfer(address,uint256) calldata, and writing your own retry logic for a wallet that doesn't recognize the requested chain yet. Here it's four calls:
import { createWalletPayment, isWalletPayable } from '@klappay/checkout-kit/client'const [option] = payload.paymentOptions.filter(isWalletPayable)const wallet = createWalletPayment(option, payload.address)wallet.on('sent', (txHash) => showPendingState(txHash))wallet.on('error', (error) => showError(error))await wallet.connect()await wallet.pay()
connect()/pay() handle the chain switch for you, including the case where the payer's wallet has simply never heard of, say, Optimism — it tries wallet_switchEthereumChain first, and only falls back to wallet_addEthereumChain if the wallet rejects the switch with "unrecognized chain." You don't write that fallback; it's already in there.
createWalletPayment() is headless on purpose — no DOM assumptions, no framework import. It drops into React, Vue, Svelte, or a plain <script> tag exactly the same way. Here's how it looks as a React hook, straight from the package's own framework docs:
import { useCallback, useEffect, useRef, useState } from 'react'import { createWalletPayment, isWalletPayable } from '@klappay/checkout-kit/client'import type { PaymentOption, WalletStatus } from '@klappay/checkout-kit/client'function useWalletPayment(option: PaymentOption | null, address: string | undefined) {const [status, setStatus] = useState<WalletStatus>('idle')const [txHash, setTxHash] = useState<string | null>(null)const walletRef = useRef<ReturnType<typeof createWalletPayment> | null>(null)useEffect(() => {if (!option || !address || !isWalletPayable(option)) returnconst wallet = createWalletPayment(option, address)walletRef.current = walletconst offStatus = wallet.on('status', setStatus)const offSent = wallet.on('sent', setTxHash)return () => {offStatus()offSent()}}, [option, address])return {status,txHash,connect: useCallback(() => walletRef.current?.connect(), []),pay: useCallback(() => walletRef.current?.pay(), []),}}
Notice what isn't there: no hand-rolled state machine for "connecting" vs. "paying" vs. "sent," no manual event-target bookkeeping. wallet.on(...) returns its own unsubscribe function, so it drops straight into useEffect's cleanup. wallet.status already tracks 'idle' | 'connecting' | 'paying' | 'sent' | 'error' for you — a rejected chain-switch prompt correctly lands on 'error' because that's handled once, inside pay() itself, not re-implemented at every call site. Vue and Svelte get the same shape with ref()/onUnmounted or a writable store instead — see docs/frameworks.md for both, type-checked against the real package, not illustrative pseudo-code.
If your frontend has no bundler at all — plain <script> tags, the way Klappay's own hosted checkout is actually built — /client also ships as a pre-bundled IIFE:
<script src="/vendor/checkout-kit/index.global.js"></script><script>const wallet = KlapCheckoutKit.createWalletPayment(option, payload.address)wallet.on('sent', (txHash) => console.log('sent', txHash))await wallet.connect()await wallet.pay()</script>
Every function above is a property of window.KlapCheckoutKit — same behavior, zero build step.
When the payer has three wallets installed
A payer with MetaMask, Rabby, and Coinbase Wallet all installed doesn't have one window.ethereum — they have several extensions fighting over it, and createWalletPayment() guessing which one it grabbed is a real, reported source of "I paid but the site says pending" support tickets. discoverProviders() (EIP-6963) dispatches the standard discovery event and gives you back every wallet that answers, so you can let the payer choose instead:
import { discoverProviders, createWalletPayment } from '@klappay/checkout-kit/client'const providers = await discoverProviders()// [{ info: { name, icon, rdns, uuid }, provider }, ...]const wallet = providers.length > 1? createWalletPayment(option, payload.address, providers[0].provider) // pass the one the payer picked: createWalletPayment(option, payload.address) // 0 or 1 found — default window.ethereum guess is fine
The Hono example wires this into an actual picker UI — one button per discovered wallet, its own icon and name, no framework involved:
for (const { info, provider } of providers) {const walletButton = document.createElement('button')walletButton.appendChild(document.createTextNode(info.name))walletButton.addEventListener('click', () => {setupWallet(createWalletPayment(option, payload.address, provider), payload, option)})pickerEl.appendChild(walletButton)}
Worth calling out because it's easy to miss: discoverProviders() only fires the request once you call it — nothing runs at import time. That matters for SvelteKit/Next.js/Nuxt apps, where the same client module can get imported during a server render pass with no window at all. A top-level listener would crash there; this one just waits to be asked.
Don't lose state to a page reload
Here's a scenario every checkout eventually hits: a payer sends a transaction, then closes the tab, or their phone locks, or they just refresh out of habit. Your in-memory wallet state is gone the moment that happens, but the transaction they already sent is still real. Showing "pay now" again, on a charge that's actually mid-confirmation, is the kind of bug that generates a support ticket and a very annoyed customer.
saveConfirming()/getConfirming()/clearConfirming() cover exactly this, backed by localStorage:
import { saveConfirming, getConfirming, clearConfirming } from '@klappay/checkout-kit/client'wallet.on('sent', (txHash) => {saveConfirming(payload.id, option.network, txHash)})// On page load, before the SSE stream has caught up:const confirming = getConfirming(payload.id)if (confirming) {showPendingState(`Waiting for confirmation on ${confirming.network} (tx ${confirming.txHash})`)}// Once the charge reaches a terminal state:clearConfirming(payload.id)
Small function, easy to skip writing yourself, and exactly the kind of edge case that only shows up after launch if you don't.
Skip the wait: check a transaction the moment it's sent
Klappay's background reconciliation catches every payment eventually, but "eventually" can feel slow to a payer staring at a spinner right after their wallet confirms. checkCheckout() — new as of checkout-kit@1.7 — triggers an immediate, targeted on-chain check instead of waiting out that pass:
app.post('/api/checkout/:id/check', async (c) => {const { txHash, network } = await c.req.json()return c.json(await checkout.checkCheckout(c.req.param('id'), { txHash, network }))})
wallet.on('sent', (txHash) => {fetch(`/api/checkout/${payload.id}/check`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ txHash, network: option.network }),}).catch(console.error)})
Passing txHash/network means one direct RPC lookup instead of a block-range scan — but it never trusts what the client claims. The amount credited always comes from what that transaction actually moved on-chain; a payer (or a malicious script) can't just POST a fake txHash and mark themselves paid. Core rate-limits this to once every ten seconds per charge, so the right pattern is "call it once, right after sent," not a polling loop — watchCheckout() is what tells you the result.
The response also carries confirmationProgress — { network, blocksSeen, blocksRequired, percent } — while a detected transfer hasn't yet reached full confirmation depth on a network where that matters. Instead of a spinner with no information, you can show "4 of 12 confirmations." watchCheckoutWithProgress() is the live-stream version, emitting the same progress events over the same SSE connection watchCheckout() already opens.
Keep status live without exposing your API key
The live event stream lives behind your secret key, so it can never be something a browser calls directly. watchCheckout() wraps it into an AsyncGenerator<CheckoutPayload> that your own backend relays over its own route — here it is end to end, server and client, straight from the Hono example:
app.get('/api/checkout/:id/events', (c) => {return streamSSE(c, async (stream) => {const controller = new AbortController()stream.onAbort(() => controller.abort())for await (const payload of checkout.watchCheckout(c.req.param('id'), controller.signal)) {await stream.writeSSE({ event: 'charge', data: JSON.stringify(payload) })}})})
import { watchCheckoutEvents, isOpenStatus, resolveRedirectUrl } from '@klappay/checkout-kit/client'const stop = watchCheckoutEvents(`/api/checkout/${payload.id}/events`, (updated) => {render(updated)if (isOpenStatus(updated.status)) return // still 'pending' or 'partially_paid'stop()if (updated.status === 'confirmed') {const url = resolveRedirectUrl(updated.redirectUrl) // null unless http(s)if (url) window.location.href = url}})
isOpenStatus() and resolveRedirectUrl() show up on both subpaths so you never have to remember which state values are terminal or whether a merchant-configured redirect URL is safe to actually navigate to — both of those checks are the same ones the hosted checkout itself runs.
For durable, retry-safe order fulfillment, webhooks are still the right tool, and they're not left out here either. verifyWebhookSignature()/constructWebhookEvent() are re-exported straight from @klappay/node — a real HMAC-SHA256 check, already correct, already tested — so validating X-Klappay-Signature is one import away instead of a second hand-rolled implementation of something security-sensitive:
import { constructWebhookEvent, InvalidWebhookSignatureError } from '@klappay/checkout-kit/node'app.post('/webhooks/klap', async (c) => {const rawBody = await c.req.text()const signature = c.req.header('x-klappay-signature')if (!signature) return c.text('Missing signature', 400)try {const event = constructWebhookEvent(rawBody, signature, process.env.KLAP_WEBHOOK_SECRET)if (event.event.startsWith('charge.')) {// event.data is a fully-typed Charge — enqueue fulfillment here.}return c.text('ok', 200)} catch (err) {if (err instanceof InvalidWebhookSignatureError) return c.text('invalid signature', 400)throw err}})
The payers who don't fit the happy path
A demo checkout assumes a payer on desktop Chrome with MetaMask already installed and already on the right network. Real payers aren't that considerate, and this package covers the three ways they usually differ:
No browser extension, just a wallet app. Someone on mobile Safari, or a desktop with nothing installed, only has a phone to pair with. client/walletconnect is a separate, optional subpath (@walletconnect/universal-provider is a peer dependency, so nobody paying only with injected wallets ever downloads it) that turns a Reown Cloud projectId into a pairing URI and, once approved, the exact same Eip1193Provider shape everything above already expects:
import { createWalletConnectProvider } from '@klappay/checkout-kit/client/walletconnect'import { createWalletPayment } from '@klappay/checkout-kit/client'const wc = await createWalletConnectProvider({projectId: 'YOUR_REOWN_CLOUD_PROJECT_ID',chainIds: [option.chainId],metadata: { name: 'Your Store', description: '...', url: 'https://...', icons: ['...'] },})wc.on('uri', (uri) => showYourOwnQrOrDeepLink(uri)) // no modal shipped — bring your own UIconst provider = await wc.connect() // resolves once the payer approves on their phoneconst wallet = createWalletPayment(option, payload.address, provider) // everything else, unchanged
Wants to pay in something the charge doesn't accept. A charge set up for USDC doesn't have to turn away a payer holding only ETH. createSwapPayment() routes their payment through a 0x swap into whatever the charge does accept, delivered straight to the charge's own address — no separate wallet, no separate settlement path for your backend to reconcile.
Wants to double-check what they're about to send. buildPaymentUri() builds a scannable EIP-681 URI from data already sitting in the payload — no extra network call — for the QR-code fallback every checkout needs regardless of wallet situation.
None of these three cost you anything if you don't use them. The base /client bundle stays a few kilobytes; each one is an explicit, separate import.
What it deliberately leaves to you
No UI, no design system, no QR-rendering library picked for you (any library that turns a string into an SVG/canvas works — buildPaymentUri() just hands you the string). No webhook route scaffolded, no wallet-error copy written for you — error.code === 4001 means the payer rejected the transaction in their wallet, and what you say about that is your product's voice, not this package's. No forced WalletConnect modal.
That's not a gap, it's the actual design decision this package is built on: own the pixels, own the copy, own the framework — don't own the two pieces that are genuinely the same problem for every single custom checkout, and that Klappay had already solved, audited, and running in production before this package existed at all.
Which is really the whole pitch. pnpm add @klappay/checkout-kit @klappay/types isn't a shortcut version of the real thing — it's the real thing, the same code paying customers hit right now on Klappay's hosted checkout, just no longer trapped behind one specific UI. Start at klappay.com/developers, or skip straight to running code:
examples/hono— no bundler at all, plain<script>tags, closest to how Klappay's own checkout is builtexamples/nextjs— App Router Route Handlers plus React Client Componentsexamples/sveltekit—+server.tsroutes with asvelte/store-based wallet storeexamples/nuxt— Nitro server routes with a Vue Composition API composable
Every one of them is a real, standalone app — clone it, pnpm install, pnpm dev, and you're looking at a working checkout inside a couple of minutes, not a snippet you have to piece together yourself.