--- url: https://node-checkout-sdk.klappay.com/getting-started.md --- # Getting started ## Install ```bash pnpm add @klappay/checkout-kit @klappay/types ``` `@klappay/types` is a peer of `@klappay/checkout-kit`, installed mostly so the type-checker can resolve it — for everyday use, every type this package's API surface needs (`Charge`/`ChargeStatus`/`Network`/`Token` included) is importable straight from `@klappay/checkout-kit` itself, see [Importing types](/node#importing-types). ## Requirements `exports` in `package.json` only declares `types`/`import` conditions — ESM only, no `require`. If your `tsconfig.json` has `"moduleResolution": "node"` (the default below TypeScript 5, and still common), subpath imports like `@klappay/checkout-kit/node` fail to resolve with: ``` Cannot find module '@klappay/checkout-kit/node' or its corresponding type declarations. ``` Fix it by setting `"moduleResolution"` to `"bundler"`, `"node16"`, or `"nodenext"` — any of the three resolve `exports` maps correctly: ```json { "compilerOptions": { "moduleResolution": "bundler" } } ``` ## Two subpaths, one package ```ts import { createCheckoutKit } from '@klappay/checkout-kit/node' // holds your API key — backend only import { createWalletPayment } from '@klappay/checkout-kit/client' // talks to window.ethereum — browser only ``` `/node` throws immediately if it's ever evaluated where `window` is defined — importing it into a browser bundle by mistake fails loudly at import time instead of silently shipping your API key to every payer. `/client` never touches an API key or does a network call to Core at all; everything it needs is already in the `CheckoutPayload` your own backend handed it. ## Your first checkout payload On your own backend, wrap your Klappay API key once: ```ts import { createCheckoutKit } from '@klappay/checkout-kit/node' const checkout = createCheckoutKit({ apiKey: process.env.KLAP_API_KEY!, baseUrl: process.env.KLAP_BASE_URL!, }) ``` Then expose whatever route your frontend calls to build its UI: ```ts import { KlapApiError } 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) } throw err } }) ``` A nonexistent/deleted `chargeId` makes the underlying `@klappay/node` call reject with `KlapApiError` (`status`/`code`/`message`, from `@klappay/node` directly — not re-exported from this package since it's already a dependency you can import yourself). `payload` is a `CheckoutPayload` — a curated, JSON-safe subset of the raw `Charge` (no `apiKeyId`/`metadata`/other merchant bookkeeping), plus one `PaymentOption` per accepted `(token, network)` pair with the exact `amountUnits` a wallet needs to send. See [Node](/node) for every field. ## Your first wallet payment In the browser, once you have `payload` from the route above: ```ts import { createWalletPayment, isWalletPayable } from '@klappay/checkout-kit/client' const [option] = payload.paymentOptions.filter(isWalletPayable) const wallet = createWalletPayment(option, payload.address) wallet.on('sent', (txHash) => console.log('sent', txHash)) await wallet.connect() await wallet.pay() ``` That's a full connect → sign → send flow against whatever EIP-1193 wallet the payer has installed — no ethers.js/viem, no ABI file, just a hand-encoded `transfer(address,uint256)` call. See [Client](/client) for reconnecting on reload, QR fallback, and watching live status. ## Where to go next * [`node.md`](./node) — `createCheckoutKit`, `getCheckoutPayload`, the `CheckoutPayload`/`PaymentOption` shape, importing types, and the lower-level pieces it's built from if you want a different response shape. * [`client.md`](./client) — the wallet controller, QR/manual-address fallback, tracking "confirming" across a reload, watching live status, and redirecting the payer back after confirmation. * [`checkout-flow.md`](./checkout-flow) — the whole thing end to end, Node and client wired together in one page. * [`webhooks.md`](./webhooks) — verifying Core's signed webhook deliveries as an alternative/complement to polling. ## For LLMs and agents This site (built from these same files with VitePress) publishes [`llms.txt`](/llms.txt) — a link index of every doc page — and [`llms-full.txt`](/llms-full.txt) — the full content of every doc page concatenated into one plain-text file. Point an agent, RAG pipeline, or MCP server at either as a lightweight way to give it the whole package's documentation without scraping HTML. Both regenerate on every deploy, so they never drift from what's on this page. --- --- url: https://node-checkout-sdk.klappay.com/node.md --- # Node `@klappay/checkout-kit/node` holds your API key — never import this subpath into a browser bundle (it throws immediately if `window` is defined, so the mistake fails loudly instead of silently shipping a secret). Named for the runtime it requires, not a role — this same code runs equally in a serverless function, a long-running server, or a CLI script, anywhere Node and an API-key secret can live. ## `createCheckoutKit(options)` ```ts import { createCheckoutKit } from '@klappay/checkout-kit/node' const checkout = createCheckoutKit({ apiKey: process.env.KLAP_API_KEY!, baseUrl: process.env.KLAP_BASE_URL!, }) // or, if you already built a @klappay/node client elsewhere: const checkout = createCheckoutKit({ client: existingKlapClient }) ``` `options` is `CreateCheckoutKitOptions` (also exported, for typing your own wrapper around this call) — `CreateClientOptions` (re-exported from `@klappay/node`, both fields optional) or `{ client }`, never both. ### Letting `@klappay/node` read `KLAP_API_KEY`/`KLAP_BASE_URL` itself Since `@klappay/node@3.1`, `createClient()` falls back to `process.env.KLAP_API_KEY`/`process.env.KLAP_BASE_URL` for any field you omit — `createCheckoutKit()` passes `options` straight through, so that fallback works here too. With both env vars set, this is equivalent to the explicit call above: ```ts const checkout = createCheckoutKit() // reads KLAP_API_KEY / KLAP_BASE_URL ``` Nothing is validated eagerly — an omitted `apiKey`/`baseUrl` with no matching env var set doesn't throw until the first actual request (`MissingCredentialError`/`MissingBaseUrlError`, both from `@klappay/node`), same as passing them explicitly. An explicit argument always wins over its env var. `createCheckoutKit()` only ever touches `client.charges`, which also accepts its own, more specific `KLAP_CHARGES_API_KEY` as a fallback below `KLAP_API_KEY` — handy if your charges key is scoped narrower than the rest of your Klappay integration. See `@klappay/node`'s own docs if you're reaching for `checkout.client` directly and want the full per-resource env var list. Returns: * `getCheckoutPayload(chargeId)` — fetch the charge and shape it into a `CheckoutPayload`, the 80%-case one-call path. * `getCharge(chargeId)` — the full raw `Charge`, if you want to build your own response shape (see "Composing your own shape" below). * `watchCheckout(chargeId, signal?)` — an `AsyncGenerator` for live status, see [Full checkout flow](/checkout-flow). * `client` — the underlying `@klappay/node` client, for anything this package doesn't wrap (webhook management, metrics, etc.). ## The `CheckoutPayload` shape A real `getCheckoutPayload()` response — a pending **test**-environment charge accepting USDC on two networks, one of them (`polygon`) with no wallet mapping yet because this package's `CHAIN_IDS` table (`src/node/wallet-payment.ts`) only has a `live` chain ID for `polygon`, not a `test` one: ```json { "id": "ch_9f2a1c", "status": "pending", "settlementStatus": null, "amount": 49.9, "amountReceived": null, "isOverpaid": false, "currency": "USD", "environment": "test", "address": "0xAbC123...", "expiresAt": "2026-08-19T15:00:00.000Z", "redirectUrl": "https://your-store.com/orders/1234/thank-you", "paidWith": [], "paymentOptions": [ { "token": "USDC", "network": "base", "chainId": 84532, "contractAddress": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "amountUnits": "49900000" }, { "token": "USDC", "network": "polygon", "chainId": null, "contractAddress": null, "amountUnits": "49900000" } ] } ``` The `polygon` entry is still fully payable — just not by wallet (`isWalletPayable()` returns `false` for it); render `payload.address` directly for that pair instead of a wallet-connect button. The type behind that shape: ```ts type CheckoutPayload = { id: string status: ChargeStatus // 'pending' | 'partially_paid' | 'confirmed' | 'expired' | 'underpaid' settlementStatus: SettlementStatus | null amount: number amountReceived: number | null isOverpaid: boolean currency: string environment: Environment // 'live' | 'test' address: string expiresAt: string redirectUrl: string | null paidWith: AcceptedPayment[] paymentOptions: PaymentOption[] } type PaymentOption = AcceptedPayment & { chainId: number | null contractAddress: string | null amountUnits: string } ``` `apiKeyId`/`externalRef`/`source`/`metadata` are deliberately left out — that's the merchant's own bookkeeping, not something that needs to reach a payer's browser by default. Use `getCharge()` if you need the full raw `Charge`. `resolveRedirectUrl(redirectUrl)` (also exported here, not just from `/client`) validates `redirectUrl` — only `http:`/`https:` survive — for anything you want to check or log server-side before it ever reaches a payer's browser; see [Client](/client#redirecting-after-confirmation) for the usual client-side usage right before `window.location.href = ...`. `paymentOptions` has one entry per `charge.acceptedPayments` pair, always — `chainId`/`contractAddress` are `null` when this package has no wallet chain mapping for that pair, rather than dropping it from the array. It's still payable by QR/manual address (`payload.address`), so hiding it entirely would make a real, still-payable option invisible to your UI. Use `isWalletPayable(option)` to decide whether to show a wallet-connect button for a given option: ```ts import { isWalletPayable } from '@klappay/checkout-kit/node' // also from /client const walletOptions = payload.paymentOptions.filter(isWalletPayable) ``` `OPEN_STATUSES`/`isOpenStatus(status)` tell you which of the five `ChargeStatus` values are still payable — `pending` and `partially_paid` are open; `confirmed`, `expired`, and `underpaid` are terminal. ## Importing types Every type used in the shapes above is importable straight from this package — no separate `@klappay/types` install needed just to type a `payload`: ```ts import type { CheckoutPayload, PaymentOption } from '@klappay/checkout-kit/node' // or /client import type { AcceptedPayment, Charge, ChargeStatus, Environment, Network, SettlementStatus, Token, } from '@klappay/checkout-kit/node' // or /client ``` `CheckoutPayload` and `PaymentOption` are this package's own types — defined in `src/types.ts`, shared by both subpaths. Everything else in that second import (`AcceptedPayment`, `Charge`, `ChargeStatus`, `Environment`, `Network`, `SettlementStatus`, `Token`) is re-exported straight from `@klappay/types`, purely for convenience — same types, same values at runtime, just reachable without a second package import. `Charge` is the one that isn't a field type of `CheckoutPayload` itself; it's the full raw shape `getCharge()`/`toCheckoutPayload()` take as input, exported for when you're composing your own response shape (see below). Install `@klappay/types` directly only if you need something outside this list — other `@klappay/node` resources' types, the Zod schemas themselves, etc. ## Composing your own shape `getCheckoutPayload()` is convenience, not the only path — it's built from smaller, independently exported pieces, so a different response shape doesn't need a bolted-on `transform`/`select` option: ```ts import { resolvePaymentOptions, toCheckoutPayload } from '@klappay/checkout-kit/node' const charge = await checkout.getCharge(chargeId) // full raw Charge const options = resolvePaymentOptions(charge) // one PaymentOption per accepted pair const payload = toCheckoutPayload(charge) // same shaping getCheckoutPayload() uses internally ``` `resolvePaymentOptions()` itself is built from two smaller exported pieces, for anyone doing their own amount math instead of trusting `PaymentOption.amountUnits`: ```ts import { remainingAmountUnits, toTokenUnits } from '@klappay/checkout-kit/node' remainingAmountUnits(charge) // charge.amount minus charge.amountReceived, as token units (bigint) — clamped to 0n, never negative toTokenUnits(49.9) // a plain decimal amount → token units (bigint); optional 2nd arg overrides @klappay/types' TOKEN_DECIMALS default ``` ## QR codes: no round-trip needed Once `resolvePaymentOptions()` has computed `chainId`/`contractAddress`/ `amountUnits`, the EIP-681 payment URI is fully knowable — no extra network call to Core's `/qrcode` endpoint, no extra secret-holding round trip through your backend: ```ts import { buildPaymentUri } from '@klappay/checkout-kit/node' // also from /client const uri = buildPaymentUri(option, payload.address) ``` `buildPaymentUri()` throws for an option with no wallet mapping (`chainId`/`contractAddress` both `null`) — render `payload.address` directly for that pair instead. This package doesn't ship a QR renderer itself; pipe the URI into whatever QR library you already use (e.g. the `qrcode` npm package renders an SVG/canvas from any string). ## Live status: must proxy through your own backend Core's `/v1/charges/{id}/events` (SSE) is API-key-authenticated — a browser can never hit it directly. `watchCheckout()` wraps `@klappay/node`'s `charges.watch()` into an `AsyncGenerator` you wire into your own SSE/WS route: ```ts import { streamSSE } from 'hono/streaming' // any framework's SSE helper works the same way app.get('/api/checkout/:id/events', async (c) => { return streamSSE(c, async (stream) => { for await (const payload of checkout.watchCheckout(c.req.param('id'))) { await stream.writeSSE({ event: 'charge', data: JSON.stringify(payload) }) } }) }) ``` An async generator is the lowest common denominator every framework can consume in a few lines — this package intentionally doesn't ship a framework-specific adapter. ## Verifying webhooks `verifyWebhookSignature()`/`constructWebhookEvent()` are re-exported from `@klappay/node` — see [Webhooks](/webhooks). --- --- url: https://node-checkout-sdk.klappay.com/client.md --- # Client `@klappay/checkout-kit/client` never touches an API key or calls Core directly — everything it needs is already in the `CheckoutPayload` your own backend handed it. No DOM/framework assumptions: every function here is headless, bring your own UI. Every type this package uses (`CheckoutPayload`, `PaymentOption`, `ChargeStatus`, etc.) is importable from this same subpath too — see [Importing types](/node#importing-types). For React/Vue/Svelte-specific wiring (hooks, composables, stores), see [Framework examples](/frameworks). ## Connecting a wallet and paying ```ts import { createWalletPayment, isWalletPayable } from '@klappay/checkout-kit/client' const [option] = payload.paymentOptions.filter(isWalletPayable) const wallet = createWalletPayment(option, payload.address) wallet.on('account', (account) => console.log('connected', account)) wallet.on('status', (status) => console.log('status', status)) // 'idle' | 'connecting' | 'paying' | 'sent' | 'error' wallet.on('sent', (txHash) => console.log('sent', txHash)) wallet.on('error', (error) => console.log('failed', error)) await wallet.connect() // prompts the wallet await wallet.pay() // switches chain if needed, then eth_sendTransaction ``` `createWalletPayment(option, recipientAddress, provider?)` throws immediately if handed an option with no wallet mapping (`chainId`/`contractAddress` both `null`) — filter with `isWalletPayable()` first, or fall back to `buildPaymentUri()`/ `payload.address` for that pair. `provider` defaults to `getInjectedProvider()` (`window.ethereum`); pass your own if you need to target a specific provider among several injected ones (`window.ethereum.providers`). ```ts import { getInjectedProvider } from '@klappay/checkout-kit/client' if (!getInjectedProvider()) { // no window.ethereum at all — show "install a wallet" instead of a connect button } else { const wallet = createWalletPayment(option, payload.address) // uses getInjectedProvider() internally } ``` With multiple wallets installed at once (e.g. MetaMask + Coinbase Wallet), `window.ethereum` is whichever one last claimed the slot — `window.ethereum.providers` (an array, when present) is how the payer picks: ```ts const providers = window.ethereum?.providers ?? (window.ethereum ? [window.ethereum] : []) const metaMask = providers.find((p) => p.isMetaMask) const wallet = createWalletPayment(option, payload.address, metaMask) ``` `pay()` does the full flow in one call: checks the wallet's current chain against `option.chainId` and requests `wallet_switchEthereumChain` if they differ, then sends a hand-encoded `transfer(address,uint256)` call — no ethers.js/viem, no ABI file. On success it emits `'sent'` with the transaction hash and resolves with it; on failure it emits `'error'` and rethrows, so `error.code === 4001` (user rejected) is still inspectable by your own catch block. ### Tracking busy/idle state `WalletStatus` (`'idle' | 'connecting' | 'paying' | 'sent' | 'error'`) is tracked internally and emitted on `'status'` — `wallet.getStatus()` reads it synchronously without subscribing first. This is deliberately *not* something to re-derive per-integration (wrapping every `connect()`/`pay()` call in your own `setStatus('connecting')`/ `setStatus('paying')`): a rejected `wallet_switchEthereumChain` prompt mid-`pay()`, for instance, has to land on `'error'` too, not leave `status` stuck at `'paying'` forever — that transition is handled once, here, instead of every integrator needing to get it right independently. See [Framework examples](/frameworks) for `'status'` wired into React/Vue/Svelte state. `Eip1193Provider` and `WalletPaymentEvents` (both exported) are the types behind `provider`/`wallet.on()` above, for typing your own provider-selection logic or a wrapper around `wallet.on()`. ### Building your own `eth_sendTransaction` call `encodeErc20Transfer(to, amountUnits)` is the raw calldata encoder `pay()` uses internally — also exported, for anyone who wants the `chainId`/switch-check/send steps under their own control instead of going through `createWalletPayment()`: ```ts import { encodeErc20Transfer, getInjectedProvider } from '@klappay/checkout-kit/client' const provider = getInjectedProvider()! const data = encodeErc20Transfer(payload.address, option.amountUnits) // '0xa9059cbb...' const txHash = await provider.request({ method: 'eth_sendTransaction', params: [{ from: account, to: option.contractAddress, data }], }) ``` No chain-switch check, no status tracking, no `'sent'`/`'error'` events — `createWalletPayment()` already does all three; reach for this only when you need a transaction shaped differently than `pay()` produces. ### Reconnecting on reload ```ts const account = await wallet.reconnect() // null if not already authorized — no popup either way ``` `reconnect()` is the non-prompting equivalent of `connect()` — it checks `eth_accounts` instead of requesting `eth_requestAccounts`, so a page reload doesn't force the payer through a re-approval popup for a wallet that's already authorized this origin. ## QR / manual-address fallback Every payment option is showable, wallet-payable or not — for one with no wallet mapping, or on a mobile browser tab with no injected provider, render `payload.address` directly (as a static "send to this address" QR/text). For a wallet-payable option, `buildPaymentUri()` builds the EIP-681 URI with no extra network call: ```ts import { buildPaymentUri } from '@klappay/checkout-kit/client' const uri = buildPaymentUri(option, payload.address) // throws if !isWalletPayable(option) ``` This package doesn't ship a QR renderer — pipe the URI (or the raw address) into whatever QR library you already use. ## Tracking "confirming" state across a reload The payer sent a transaction, but your own status route hasn't caught up to it yet — persist that locally so a reload doesn't lose it: ```ts import { saveConfirming, getConfirming, clearConfirming, remainingMs, confirmingExplorerUrl } from '@klappay/checkout-kit/client' saveConfirming(payload.id, option.network, txHash) // on reload: const record = getConfirming(payload.id) // null if none, or timed out if (record) { console.log(remainingMs(record)) // ms left before this network's timeout console.log(confirmingExplorerUrl(record)) // block explorer link, or null if no txHash yet } clearConfirming(payload.id) // once your status route reflects the real state ``` The timeout is per-network (15 min for `ethereum`, down to 1 min for `avalanche`) — a record past its timeout is treated as gone; `getConfirming()` clears and returns `null` for it automatically. `ConfirmingRecord` (also exported) is the `{ network, startedAt, txHash }` shape `saveConfirming()`/`getConfirming()` return. ## Watching live status ```ts import { watchCheckoutEvents } from '@klappay/checkout-kit/client' const stop = watchCheckoutEvents(`/api/checkout/${payload.id}/events`, (payload) => { // re-render with the new payload }) // later, e.g. on unmount: stop() ``` A plain `EventSource` wrapper expecting `event: charge` / `data: ` — matching Core's own SSE contract shape and pointed at whatever URL your backend's `watchCheckout()` route exposes (see [Node](/node)). Reconnection on drop is the browser's native `EventSource` behavior — no custom backoff logic to configure. ## Redirecting after confirmation ```ts import { resolveRedirectUrl } from '@klappay/checkout-kit/client' if (payload.status === 'confirmed') { const url = resolveRedirectUrl(payload.redirectUrl) // null unless the scheme is http(s) if (url) window.location.href = url } ``` `payload.redirectUrl` is a merchant-configured "send the payer back here" URL, passed straight through from `Charge.redirectUrl`. `resolveRedirectUrl()` rejects anything that isn't `http:`/`https:` before it ever reaches a navigation sink — check `status === 'confirmed'` first, same as this rejection, before trusting the field at all. ## No bundler? Use the script-tag build Every example on this page assumes an `import` — fine with a bundler, but a frontend with none at all (plain ` ``` `KlapCheckoutKit.createWalletPayment`, `.buildPaymentUri`, `.isWalletPayable`, `.resolveRedirectUrl`, `.watchCheckoutEvents`, and every other function on this page — same behavior as the ESM import, just reachable without a build step. Serve `node_modules/@klappay/checkout-kit/dist/client/index.global.js` directly (a second static-file route pointed at that path) instead of copying it into your own repo, so it always matches whatever version is actually installed. `/node` has no equivalent IIFE build — it always runs somewhere `import`/`require` already resolves (Node, a serverless function, a bundler), so there's nothing for it to solve there. ## What this doesn't do * No WalletConnect or any wallet that isn't an injected EIP-1193 provider — no `window.ethereum` (a mobile browser tab, not a wallet app's in-app browser) means no wallet flow; QR/manual-address payment still works there. * No `wallet_addEthereumChain` retry if the wallet doesn't already have the target network configured (`wallet_switchEthereumChain` error `4902`) — `pay()` lets that error surface as-is. * No classification of wallet errors — `error.code === 4001` on the `'error'` event means the payer rejected the transaction; anything else is provider-specific. No UI copy belongs in this package. --- --- url: https://node-checkout-sdk.klappay.com/checkout-flow.md --- # Full checkout flow Everything from the other pages, wired together into one connect → pay → confirm flow. Framework-agnostic on purpose — this example uses plain `fetch`/DOM to stay implementation-neutral; swap in your own framework's equivalents. ## 1. Create the charge (out of scope for this package) Charge creation belongs to `@klappay/node` directly, on your backend — this package starts from an existing `chargeId`: ```ts import { createClient } from '@klappay/node' const klap = createClient({ apiKey: process.env.KLAP_API_KEY!, baseUrl: process.env.KLAP_BASE_URL! }) const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, redirectUrl: 'https://your-store.com/orders/1234/thank-you', }) ``` ## 2. Expose a checkout route ```ts import { KlapApiError } from '@klappay/node' 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) => { try { return c.json(await checkout.getCheckoutPayload(c.req.param('id'))) } catch (err) { if (err instanceof KlapApiError && err.status === 404) { return c.json({ error: 'charge not found' }, 404) } throw err } }) app.get('/api/checkout/:id/events', async (c) => { return streamSSE(c, async (stream) => { for await (const payload of checkout.watchCheckout(c.req.param('id'))) { await stream.writeSSE({ event: 'charge', data: JSON.stringify(payload) }) } }) }) ``` ## 3. Render payment options in the browser ```ts import { buildPaymentUri, isWalletPayable } from '@klappay/checkout-kit/client' const payload = await fetch(`/api/checkout/${chargeId}`).then((r) => r.json()) for (const option of payload.paymentOptions) { if (isWalletPayable(option)) { renderWalletButton(option) // your own UI } else { renderQrCode(buildPaymentUri(option, payload.address)) // or just render payload.address as text } } ``` ## 4. Pay with a connected wallet ```ts import { createWalletPayment, saveConfirming } from '@klappay/checkout-kit/client' async function onWalletButtonClick(option) { const wallet = createWalletPayment(option, payload.address) wallet.on('sent', (txHash) => { saveConfirming(payload.id, option.network, txHash) showConfirmingState(txHash) }) wallet.on('error', (error) => { if (error.code === 4001) showRejectedState() else showGenericErrorState(error) }) await wallet.connect() await wallet.pay() } ``` ## 5. Watch for confirmation ```ts import { isOpenStatus, resolveRedirectUrl, watchCheckoutEvents, clearConfirming } from '@klappay/checkout-kit/client' const stop = watchCheckoutEvents(`/api/checkout/${payload.id}/events`, (updated) => { if (isOpenStatus(updated.status)) return // still 'pending'/'partially_paid', keep waiting stop() clearConfirming(updated.id) if (updated.status === 'confirmed') { const url = resolveRedirectUrl(updated.redirectUrl) if (url) window.location.href = url else showConfirmedState(updated) } else { showTerminalState(updated) // 'expired' or 'underpaid' } }) ``` ## 6. On page reload, before the SSE reconnects ```ts import { getConfirming, remainingMs } from '@klappay/checkout-kit/client' const record = getConfirming(payload.id) if (record) { showConfirmingState(record.txHash, remainingMs(record)) } ``` That's the whole loop: a payer never leaves your site, every step uses data your own backend already has, and the only things you had to build are the render functions (`renderWalletButton`, `showConfirmingState`, etc.) — the styling and framework are entirely yours. --- --- url: https://node-checkout-sdk.klappay.com/frameworks.md --- # Framework examples `@klappay/checkout-kit/client` is headless on purpose — every function is plain JS/TS, no DOM/framework assumptions, no React (or anything else) as a dependency of this package. That means it drops into whatever you're already using: wrap `createWalletPayment()`'s event-emitter (`.on()` returns an unsubscribe function) and `watchCheckoutEvents()`'s `stop()` return in your framework's own effect/cleanup primitive, and you have a fully reactive wallet flow. The examples below (React, Vue, Svelte) are all type-checked against this package's real build output, not illustrative pseudo-code. `createWalletPayment()` tracks its own `WalletStatus` (`'idle' | 'connecting' | 'paying' | 'sent' | 'error'`) and emits it on a `'status'` event — see [Client](/client#connecting-a-wallet-and-paying). None of the examples below hand-roll that state machine; they just subscribe to it, same as `'account'`/`'sent'`/`'error'`. ## React ```tsx import { useCallback, useEffect, useRef, useState } from 'react' import { createWalletPayment, isWalletPayable, watchCheckoutEvents, } from '@klappay/checkout-kit/client' import type { CheckoutPayload, PaymentOption, WalletStatus } from '@klappay/checkout-kit/client' function useWalletPayment(option: PaymentOption | null, recipientAddress: string | undefined) { const [account, setAccount] = useState(null) const [status, setStatus] = useState('idle') const [txHash, setTxHash] = useState(null) const [error, setError] = useState(null) const walletRef = useRef | null>(null) useEffect(() => { if (!option || !recipientAddress || !isWalletPayable(option)) { walletRef.current = null return } const wallet = createWalletPayment(option, recipientAddress) walletRef.current = wallet const offAccount = wallet.on('account', setAccount) const offStatus = wallet.on('status', setStatus) const offSent = wallet.on('sent', setTxHash) const offError = wallet.on('error', setError) return () => { offAccount() offStatus() offSent() offError() } }, [option, recipientAddress]) const connect = useCallback(() => walletRef.current?.connect(), []) const pay = useCallback(() => walletRef.current?.pay(), []) return { account, status, txHash, error, connect, pay } } function useCheckoutPayload(chargeId: string) { const [payload, setPayload] = useState(null) useEffect(() => { let cancelled = false fetch(`/api/checkout/${chargeId}`) .then((r) => r.json()) .then((data: CheckoutPayload) => { if (!cancelled) setPayload(data) }) return () => { cancelled = true } }, [chargeId]) useEffect(() => { const stop = watchCheckoutEvents(`/api/checkout/${chargeId}/events`, setPayload) return stop }, [chargeId]) return payload } ``` Used in a component: ```tsx function CheckoutButton({ chargeId }: { chargeId: string }) { const payload = useCheckoutPayload(chargeId) const option = payload?.paymentOptions.find(isWalletPayable) ?? null const { account, status, txHash, connect, pay } = useWalletPayment(option, payload?.address) if (!payload || !option) return

Loading…

return (

Pay {payload.amount} via {option.token} on {option.network}

{!account ? ( ) : ( )} {txHash &&

Sent: {txHash}

}
) } ``` The pattern to notice: `wallet.on(...)` returns an unsubscribe function per call, so `useEffect`'s cleanup is a direct, one-to-one mapping — no manual event-target bookkeeping, and no local status state machine to get subtly wrong (a rejected chain-switch prompt, for instance, still correctly lands on `'error'` — that's handled once, inside `pay()` itself). Same for `watchCheckoutEvents()`'s returned `stop()`. ## Vue Composition API — the same unsubscribe-in-cleanup shape, just `ref()` instead of `useState` and `onUnmounted` instead of a `useEffect` cleanup return: ```ts import { onUnmounted, ref } from 'vue' import { createWalletPayment, isWalletPayable } from '@klappay/checkout-kit/client' import type { PaymentOption, WalletStatus } from '@klappay/checkout-kit/client' function useWalletPayment(option: PaymentOption, recipientAddress: string) { if (!isWalletPayable(option)) { throw new Error('Option is not wallet-payable') } const account = ref(null) const status = ref('idle') const txHash = ref(null) const wallet = createWalletPayment(option, recipientAddress) const offAccount = wallet.on('account', (a) => { account.value = a }) const offStatus = wallet.on('status', (s) => { status.value = s }) const offSent = wallet.on('sent', (hash) => { txHash.value = hash }) onUnmounted(() => { offAccount() offStatus() offSent() }) return { account, status, txHash, connect: wallet.connect, pay: wallet.pay } } ``` ```vue ``` ## Svelte The store pattern (`svelte/store`, works the same in Svelte 4 and 5 — runes are an alternative, not a replacement): ```ts import { writable } from 'svelte/store' import { createWalletPayment, isWalletPayable } from '@klappay/checkout-kit/client' import type { PaymentOption, WalletStatus } from '@klappay/checkout-kit/client' function createWalletStore(option: PaymentOption, recipientAddress: string) { if (!isWalletPayable(option)) { throw new Error('Option is not wallet-payable') } const account = writable(null) const status = writable('idle') const txHash = writable(null) const wallet = createWalletPayment(option, recipientAddress) wallet.on('account', (a) => account.set(a)) wallet.on('status', (s) => status.set(s)) wallet.on('sent', (hash) => txHash.set(hash)) return { account, status, txHash, connect: wallet.connect, pay: wallet.pay } } ``` ```svelte {#if !$account} {:else} {/if} {#if $txHash}

Sent: {$txHash}

{/if} ``` A component-scoped store (created inside the component, not a shared module-level store) needs its own unsubscribe in `onDestroy` if the component can unmount mid-payment — omitted above for brevity, same `wallet.on()` return values as the React/Vue examples. ## Full-stack examples The React hooks above assume `/api/checkout/:id` and `/api/checkout/:id/events` routes already exist. See [Full-stack examples](/examples) for those routes wired up end to end — Hono (matching klap-checkout's own setup) and Next.js App Router. ## No framework, no bundler at all Building against klap-checkout's own `hono/jsx` + zero-bundler `public/*.js` setup, or anything similar? See [No bundler? Use the script-tag build](/client#no-bundler-use-the-script-tag-build) — `window.KlapCheckoutKit` exposes this exact same API without an `import` anywhere. ## Plain JavaScript (no framework) See [Full checkout flow](/checkout-flow) — the same `wallet.on()` / `watchCheckoutEvents()` pattern above, without a framework's reactivity system wrapping it; just `addEventListener`-style callbacks directly. --- --- url: https://node-checkout-sdk.klappay.com/examples.md --- # Full-stack examples Complete integrations — server routes (`/node`) and client wiring (`/client`) together — for two concrete stacks. Both are type-checked against this package's real build output and the real framework's types, not illustrative pseudo-code. Want to clone and run one instead of reading it here? See [`examples/`](https://github.com/klappay/klap-checkout-kit/tree/main/examples) in the repo — four standalone, `pnpm install && pnpm dev`-ready apps covering Hono (no bundler at all), Next.js, SvelteKit, and Nuxt. Each always depends on `@klappay/checkout-kit`'s `latest` npm release, so they double as a live integration check, not a frozen snapshot. ## Hono Mirrors klap-checkout's own `src/app.tsx` — same `serveStatic`, `streamSSE`, and abort-signal wiring it already uses for `./public`, just pointed at `@klappay/checkout-kit` instead of hand-rolled logic: ```ts import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' import { KlapApiError } from '@klappay/node' import { constructWebhookEvent, createCheckoutKit, InvalidWebhookSignatureError, WebhookTimestampToleranceError, } from '@klappay/checkout-kit/node' const checkout = createCheckoutKit({ apiKey: process.env.KLAP_API_KEY!, baseUrl: process.env.KLAP_BASE_URL!, }) const app = new Hono() // The IIFE build, served straight from node_modules — always matches // whatever version is actually installed, no copying into ./public. app.use( '/vendor/checkout-kit/*', serveStatic({ root: './node_modules/@klappay/checkout-kit/dist/client', rewriteRequestPath: (path) => path.replace(/^\/vendor\/checkout-kit/, ''), }), ) 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) } throw err } }) 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) }) } }) }) 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 } return c.text('ok', 200) } catch (err) { if (err instanceof WebhookTimestampToleranceError) return c.text('stale delivery', 400) if (err instanceof InvalidWebhookSignatureError) return c.text('invalid signature', 400) throw err } }) export { app } ``` `stream.onAbort(() => controller.abort())` matters — without it, a payer closing the tab leaves `watchCheckout()`'s underlying `@klappay/node` SSE connection to Core open indefinitely. Passing that same signal into `checkout.watchCheckout(id, controller.signal)` is what actually tears it down. Client-side markup (no bundler — this is klap-checkout's own zero-build `public/*.js` setup) points at the route above: ```html ``` See [No bundler? Use the script-tag build](/client#no-bundler-use-the-script-tag-build) for the full API surface available on `window.KlapCheckoutKit`. ## Next.js (App Router) Route Handlers for the server half, a Client Component for the wallet UI. `lib/checkout-kit.ts`: ```ts import { createCheckoutKit } from '@klappay/checkout-kit/node' export const checkout = createCheckoutKit({ apiKey: process.env.KLAP_API_KEY!, baseUrl: process.env.KLAP_BASE_URL!, }) ``` `app/api/checkout/[id]/route.ts`: ```ts import { KlapApiError } from '@klappay/node' import { NextResponse } from 'next/server' import { checkout } from '@/lib/checkout-kit' export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params try { const payload = await checkout.getCheckoutPayload(id) return NextResponse.json(payload) } catch (err) { if (err instanceof KlapApiError && err.status === 404) { return NextResponse.json({ error: 'charge not found' }, { status: 404 }) } throw err } } ``` `app/api/checkout/[id]/events/route.ts` — Next.js has no `streamSSE` helper, so the SSE framing is built directly from a `ReadableStream`; `req.signal` (aborted when the client disconnects) is passed straight into `watchCheckout()`, same role as Hono's `controller.signal` above: ```ts import { checkout } from '@/lib/checkout-kit' export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { try { for await (const payload of checkout.watchCheckout(id, req.signal)) { controller.enqueue(encoder.encode(`event: charge\ndata: ${JSON.stringify(payload)}\n\n`)) } } finally { controller.close() } }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }) } ``` `app/api/webhooks/klap/route.ts` — same `constructWebhookEvent()` as the Hono example: ```ts import { constructWebhookEvent, InvalidWebhookSignatureError, WebhookTimestampToleranceError, } from '@klappay/checkout-kit/node' export async function POST(req: Request) { const rawBody = await req.text() const signature = req.headers.get('x-klappay-signature') if (!signature) return new Response('Missing signature', { status: 400 }) try { const event = constructWebhookEvent(rawBody, signature, process.env.KLAP_WEBHOOK_SECRET!) if (event.event.startsWith('charge.')) { // event.data is a fully-typed Charge } return new Response('ok', { status: 200 }) } catch (err) { if (err instanceof WebhookTimestampToleranceError) return new Response('stale delivery', { status: 400 }) if (err instanceof InvalidWebhookSignatureError) return new Response('invalid signature', { status: 400 }) throw err } } ``` `app/checkout/[id]/hooks.ts` — the same `useWalletPayment`/ `useCheckoutPayload` hooks from [Framework examples](/frameworks#react), in their own `'use client'` module (App Router Server Components can't call `useState`/`useEffect` — the hooks and anything importing `@klappay/checkout-kit/client` need this boundary): ```ts 'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { createWalletPayment, isWalletPayable, watchCheckoutEvents, } from '@klappay/checkout-kit/client' import type { CheckoutPayload, PaymentOption, WalletStatus } from '@klappay/checkout-kit/client' export function useWalletPayment(option: PaymentOption | null, recipientAddress: string | undefined) { const [account, setAccount] = useState(null) const [status, setStatus] = useState('idle') const [txHash, setTxHash] = useState(null) const [error, setError] = useState(null) const walletRef = useRef | null>(null) useEffect(() => { if (!option || !recipientAddress || !isWalletPayable(option)) { walletRef.current = null return } const wallet = createWalletPayment(option, recipientAddress) walletRef.current = wallet const offAccount = wallet.on('account', setAccount) const offStatus = wallet.on('status', setStatus) const offSent = wallet.on('sent', setTxHash) const offError = wallet.on('error', setError) return () => { offAccount() offStatus() offSent() offError() } }, [option, recipientAddress]) const connect = useCallback(() => walletRef.current?.connect(), []) const pay = useCallback(() => walletRef.current?.pay(), []) return { account, status, txHash, error, connect, pay } } export function useCheckoutPayload(chargeId: string) { const [payload, setPayload] = useState(null) useEffect(() => { let cancelled = false fetch(`/api/checkout/${chargeId}`) .then((r) => r.json()) .then((data: CheckoutPayload) => { if (!cancelled) setPayload(data) }) return () => { cancelled = true } }, [chargeId]) useEffect(() => { const stop = watchCheckoutEvents(`/api/checkout/${chargeId}/events`, setPayload) return stop }, [chargeId]) return payload } ``` `app/checkout/[id]/CheckoutButton.tsx`: ```tsx 'use client' import { useEffect } from 'react' import { isWalletPayable, resolveRedirectUrl } from '@klappay/checkout-kit/client' import { useCheckoutPayload, useWalletPayment } from './hooks' export function CheckoutButton({ chargeId }: { chargeId: string }) { const payload = useCheckoutPayload(chargeId) const option = payload?.paymentOptions.find(isWalletPayable) ?? null const { account, status, txHash, connect, pay } = useWalletPayment(option, payload?.address) useEffect(() => { if (payload?.status === 'confirmed') { const url = resolveRedirectUrl(payload.redirectUrl) if (url) window.location.href = url } }, [payload]) if (!payload || !option) return

Loading…

return (

Pay {payload.amount} via {option.token} on {option.network}

{!account ? ( ) : ( )} {txHash &&

Sent: {txHash}

}
) } ``` `app/checkout/[id]/page.tsx` — a Server Component, only responsible for reading the route param and rendering the client boundary: ```tsx import { CheckoutButton } from './CheckoutButton' export default async function CheckoutPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params return } ``` --- --- url: https://node-checkout-sdk.klappay.com/webhooks.md --- # Webhooks `verifyWebhookSignature()`/`constructWebhookEvent()` (node subpath) are re-exported straight from `@klappay/node` — this package already depends on it, and it already ships a correct, tested implementation of Core's `X-Klappay-Signature` scheme. No second hand-rolled HMAC check here; reuse before writing, same rule this package follows everywhere else. Webhooks are an alternative/complement to polling `getCheckoutPayload()` or streaming `watchCheckout()` — set one up on Core if you want push notifications straight to your backend instead of (or alongside) either. ## Verifying and parsing an inbound webhook **Always verify the signature before trusting a webhook payload** — anyone who can reach your endpoint can send a structurally-valid request otherwise. ```ts import { constructWebhookEvent, WebhookTimestampToleranceError } from '@klappay/checkout-kit/node' app.post('/webhooks/klap', (req, res) => { try { const event = constructWebhookEvent( req.rawBody, // the raw, unparsed request body string — not req.body req.headers['x-klappay-signature'], process.env.KLAP_WEBHOOK_SECRET!, ) if (event.event.startsWith('charge.')) { // event.data is a fully-typed Charge } res.sendStatus(200) } catch (err) { if (err instanceof WebhookTimestampToleranceError) { // validly signed, but too old — likely a replay of a captured delivery res.sendStatus(400) return } // InvalidWebhookSignatureError — reject, don't process res.sendStatus(400) } }) ``` `constructWebhookEvent(rawBody, signatureHeader, secret, options?)` verifies the HMAC-SHA256 signature with a timing-safe comparison, checks the delivery is recent (`options.toleranceSeconds`, default 300), and parses the body — throwing `InvalidWebhookSignatureError` if the HMAC doesn't match, or `WebhookTimestampToleranceError` if the signature is valid but the timestamp is outside the tolerance window (a strong signal of a replayed delivery). If you only want the boolean check without parsing: ```ts import { verifyWebhookSignature } from '@klappay/checkout-kit/node' const isValid = verifyWebhookSignature(rawBody, signatureHeader, secret) ``` **Getting the raw body**: most Node frameworks parse the request body into an object before your handler runs, which is too late for signature verification (the signature is computed over the exact raw bytes). Make sure your framework gives you the raw string — e.g. in Express, use `express.raw({ type: 'application/json' })` (not `express.json()`) on this specific route, or capture the raw body in middleware before the JSON parser runs. ## Signing and replay protection The signature header is `t=,v1=` — the HMAC covers `${timestamp}.${rawBody}`, not just the body, which is what lets `constructWebhookEvent` reject a delivery that's validly signed but old. The tolerance window is a mitigation, not a guarantee — a replay sent *within* the window still passes. For belt-and-suspenders protection, deduplicate by the payload's own `id` (unique per delivery) on your side, especially for a handler whose effect isn't naturally idempotent. Registering a webhook, rotating its secret, and everything else about managing webhooks (not just verifying deliveries) is a `@klappay/node`/Core API concern outside this package's scope — see `@klappay/node`'s own docs for `klap.webhooks.create()` and friends.