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)
import { createCheckoutKit } from '@klappay/checkout-kit/node'
const checkout = createCheckoutKit({
apiKey: process.env.KLAP_API_KEY!,
baseUrl: process.env.KLAP_API_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) — either { apiKey, baseUrl } or { client }, never both.
Returns:
getCheckoutPayload(chargeId)— fetch the charge and shape it into aCheckoutPayload, the 80%-case one-call path.getCharge(chargeId)— the full rawCharge, if you want to build your own response shape (see "Composing your own shape" below).watchCheckout(chargeId, signal?)— anAsyncGenerator<CheckoutPayload>for live status, see Full checkout flow.client— the underlying@klappay/nodeclient, 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:
{
"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:
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 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:
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:
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 /clientCheckoutPayload 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:
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 internallyresolvePaymentOptions() itself is built from two smaller exported pieces, for anyone doing their own amount math instead of trusting PaymentOption.amountUnits:
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 defaultQR 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:
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<CheckoutPayload> you wire into your own SSE/WS route:
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.
