Charges
klap.charges — also available standalone as createChargesClient from @klappay/node/charges (see tree-shaking.md). Requires an apiKey.
import { createChargesClient } from '@klappay/node/charges'
const charges = createChargesClient({ baseUrl: '...', apiKey: '...' })
const charge = await charges.create({
amount: 49.9,
acceptedPayments: [{ token: 'USDC', network: 'base' }],
expiresIn: 3600,
})baseUrl/apiKey are optional — they fall back to KLAP_BASE_URL/ KLAP_CHARGES_API_KEY (see getting-started.md) if omitted.
create(input)
const charge = await klap.charges.create({
amount: 49.9,
acceptedPayments: [
{ token: 'USDC', network: 'base' },
{ token: 'USDC', network: 'optimism' },
{ token: 'USDT', network: 'base' },
],
expiresIn: 3600, // seconds, required — 60 to 3600 (1 hour max)
externalRef: 'order_123', // your own correlation id, optional
source: 'checkout', // free-form label, optional
metadata: {
plan: 'pro', // yours — any shape, never validated
klappay: { products: [{ name: 'Pro plan', quantity: 1 }] }, // reserved, see below
},
redirectUrl: 'https://yourapp.com/thank-you', // optional, see below
splitRecipients: [{ recipientId: 'rc_...', percent: 10, label: 'sales rep' }], // optional, see below
})metadata is yours to fill with anything — never validated, returned as-is on every read. One key is reserved: metadata.klappay. If present, it must match KlappayCheckoutMetadataSchema (imported from @klappay/types, same as every other type here) or the whole request is rejected — today that's just products (up to 20 items, each a name plus optional quantity/imageUrl), shown on Klappay's hosted checkout page. Every other key in metadata is unaffected and stays exactly as free-form as before.
redirectUrl only matters if you use Klappay's hosted checkout page (charge.checkoutUrl on the returned charge) — it's where the payer gets sent once that page's charge resolves; ignored otherwise. Must be http(s). charge.checkoutUrl itself is never something you set — it's null unless hosted checkout is configured for your account, and present on every read (create(), get(), list()) once it is.
splitRecipients
Routes a slice of the charge to up to 5 extra recipients (e.g. a supplier, or whoever closed the sale) — each identified by recipientId (from klap.recipients.create(), not a raw address) plus a percent and an optional label for your own bookkeeping. percent is of your own net share (100 - feePercent), not the charge's gross amount — Klappay's fee is computed on the gross amount first and is never diluted by how you split what's left. Frozen at creation like everything else that shapes the split address; a request whose percents don't fit within your available share is rejected. Requires the charges:split_write scope in addition to charges:write.
The response shape is different from the request on purpose: charge.splitRecipients echoes back the resolved address for each entry (not the recipientId you submitted) — an empty array if none — so reading a charge back tells you where the money actually went without a second lookup. See recipients.md for registering recipients and the full request/response shape split.
feePayer
By default (feePayer: 'merchant', the default) Klappay's fee is deducted from amount before it reaches your payout. Set feePayer: 'payer' instead to gross up amount so the payer covers the fee — you receive the full amount you asked for:
const charge = await klap.charges.create({
amount: 49.9,
acceptedPayments: [{ token: 'USDC', network: 'base' }],
expiresIn: 3600,
feePayer: 'payer',
})Either way, the returned charge carries feePercent, feeAmount, and merchantAmount so you can render a price breakdown without reimplementing the fee math yourself — merchantAmount is always what actually lands in your payout, regardless of who covered the fee.
Exact amounts
amount and amountReceived are legacy JSON numbers, which can lose precision. amountExact (up to 6 fractional digits, the target's own precision) and amountReceivedExact (up to 18, matching what's actually detected on-chain — null until the first transfer arrives) are the same values as decimal strings with no scientific notation; prefer them for arithmetic and payment preparation. Both are optional, for compatibility with responses from an older API version — fall back to the numeric field when absent.
BNB Chain's USDC/USDT are Binance-Peg tokens with 18 on-chain decimals (every other deployment uses 6) — a charge paid on bnb is exactly the case where amountReceivedExact carries more fractional digits than a JSON number can hold. Binance-Peg tokens are backed and custodied by Binance, not issued directly by Circle/Tether, so don't label them as such in your own UI. If you build transfers yourself, resolve the address and decimals together with getTokenDeployment from @klappay/types rather than assuming 6.
paymentUnavailable is true when payment processing for this charge is temporarily paused (its other status/monetary fields still reflect where it stood, but don't rely on them to fulfill, and don't request another transfer, until it clears) — check it before showing payment instructions, alongside the existing status/settlementStatus checks.
escrow
CreateChargeSchema/ChargeSchema carry an escrow field — configuring a charge as an escrow instead of a normal payment, moved out only by a signature from escrow.releaserAddress, either to the split address (release(id, input)) or back to the payer (refund(id, input)) — mutually exclusive, an escrow charge only ever goes one way.
Every acceptedPayments network must be EVM when escrow is set — funds land in a dedicated Safe, which isn't deployed on TRON and never will be (it isn't an EVM chain); create() rejects a mixed or TRON-only acceptedPayments list with 400 validation_error when escrow is present. Arc is EVM-compatible, so it's fine for escrow.
const charge = await klap.charges.create({
amount: 49.9,
acceptedPayments: [{ token: 'USDC', network: 'base' }],
expiresIn: 3600,
escrow: { releaserAddress: '0xabc1234567890123456789012345678901234567' },
})releaserAddress must be a plain externally-owned account (a private key that produces an ECDSA signature) — a smart-contract wallet or multisig can't sign the escrow's Safe transaction. It's set once and can't be rotated for an existing charge, and it can move the escrowed funds directly on-chain, so treat its key like the funds themselves. Omit it to default to the API key's own payoutAddress.
Everything else about create() — acceptedPayments, expiresIn, metadata, splitRecipients — works exactly the same on an escrow charge; escrow only changes how the funds are released once paid.
acceptedPayments lets the payer choose which rail to actually use — at least one (token, network) pair, up to 18. Every transfer on an accepted pair is credited and sums toward the charge total — charge.paidWith is an array of every distinct pair that has actually contributed so far (empty until the first one arrives), so a charge accepting both USDC and USDT can be confirmed by, say, $9 in USDC plus $1 in USDT. A transfer on a pair that isn't in acceptedPayments is still recorded but never credited. Every EVM network (Arc included) can be mixed freely in one charge, but TRON can't be mixed with any of them — a charge pays out to one split address predicted up front, and TRON's split contract differs from the EVM one, so create() rejects a list mixing tron with an EVM network with 400 validation_error. Not sure which pairs are actually live for your environment right now? See networks.md — klap.networks.get() returns the current matrix; build a payment-method picker from it instead of hardcoding the pairs client-side, since it changes as new networks/tokens come online.
charge.swapAlternatives is a separate list — cryptocurrencies the payer can pay with instead, swapped into an accepted pair under the hood via getQuote() below, not something you configure on create().
amount and expiresIn are both required — every charge has a target amount and a fixed deadline; there's no default to fall back on. expiresIn is capped at 3600 seconds (60 minutes), sized off the slowest chain Klap supports (a safely-confirmed Ethereum mainnet transfer can take up to ~15 minutes), leaving real margin for payer-side delay on top of that.
Every field is documented in @klappay/types' CreateChargeSchema — the SDK doesn't duplicate that documentation, it re-exports the same types. The parameter type is CreateChargeRequest (not CreateChargeInput — that's the post-parse shape, where defaulted fields like currency are always present; CreateChargeRequest is what you actually build, where they're optional).
Idempotency: if you don't pass idempotencyKey, the SDK generates one for you automatically. That makes every create() call safe to retry after a network failure or timeout — a retried request with the same key returns the original charge unchanged instead of creating a duplicate. Pass your own idempotencyKey explicitly if you want to control it yourself (e.g. deriving it from your own order id). Reusing a key with a different request body, instead of a true retry, gets rejected with 409 idempotency_key_reused.
get(id)
const charge = await klap.charges.get('ch_abc123')Observing a charge until it resolves
This is the SDK's main reason to exist over calling the REST API directly — payments aren't request/response, they have state (pending → partially_paid → confirmed, or expired/underpaid), and watching that state used to mean hand-rolling a polling loop yourself. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation.
try {
const confirmed = await charge.waitForConfirmation({ timeoutMs: 60 * 60_000 })
console.log('Paid!', confirmed.amountReceived)
} catch (err) {
// ChargeExpiredError | ChargeUnderpaidError | WaitTimeoutError
// see errors.md
}waitForConfirmation() resolves only when status reaches 'confirmed'. Every other terminal outcome — expired, underpaid, or the timeout elapsing first — rejects with a specific typed error instead of resolving with a charge you'd have to inspect yourself. This matches normal Promise semantics: await succeeding means the happy path happened; anything else you have to explicitly catch.
await charge.waitForConfirmation({
timeoutMs: 3600_000, // default: 1 hour
pollIntervalMs: 2000, // default: starts at 2s
onStatusChange: (c) => console.log('now:', c.status), // fires on partially_paid too
})How it works: opens a live event stream first (GET /v1/charges/{id}/events, Server-Sent Events) and resolves as soon as the matching status change is pushed — no fixed polling interval to wait out. If the stream can't be opened or drops (proxy strips SSE, network blip, older server), the SDK transparently falls back to polling GET /v1/charges/{id} for the rest of the timeout budget, with backoff (starts at the pollIntervalMs you set or 2s by default, grows ×1.5 per attempt, caps at 15s) so a long wait doesn't hammer the API. This is why the public option names (timeoutMs, pollIntervalMs, onStatusChange) are transport-agnostic — pollIntervalMs only matters if the fallback path ends up being used; your code never has to know which transport actually resolved the wait.
onConfirmationProgress reports how close an already-detected transfer is to its network's required confirmation depth, for rendering a "confirming payment" progress bar instead of a blank wait — only fires on the live SSE path (there's no polling equivalent; it's not part of GET /v1/charges/{id}'s response):
await charge.waitForConfirmation({
onConfirmationProgress: (p) => console.log(`${p.network}: ${p.percent}%`),
})Cancelling a wait
Same pattern as fetch — pass an AbortSignal, cancel with the matching AbortController:
const controller = new AbortController()
cancelButton.onclick = () => controller.abort()
try {
const confirmed = await charge.waitForConfirmation({ signal: controller.signal })
} catch (err) {
if (err.name === 'AbortError') {
// the merchant's own customer clicked "cancel" — not a payment failure
}
}Works the same way on waitForSettlement()/waitFor(). Rejects with the signal's own reason (an AbortError DOMException by default, or whatever you passed to controller.abort(reason)) — an already-aborted signal rejects immediately, before any request is made. One thing this does not do: cancel a status check already in flight — abort takes effect on the next check, or during the wait between checks, not mid-request. There's no reason to abort klap.charges.create() itself (the charge already exists on the backend the moment that call resolves; aborting the SDK call doesn't undo it) or a webhook delivery (the abort is entirely local to your process, not something Klap's server would ever see). One asymmetry worth knowing: if the wait is currently on the live SSE-streaming path when you abort, the abort propagates immediately and rejects the wait — it does not fall back to polling first the way a dropped/failed stream otherwise would.
waitForSettlement(options?)
A separate wait, for a separate question. status: 'confirmed' means Klap detected the on-chain transfer — it does not mean the money has reached the merchant's wallet yet, which is a distinct, later step (settlementStatus). Use this when you specifically need to know "has the payout actually happened," not just "did the payer pay."
const confirmed = await charge.waitForConfirmation()
const settled = await confirmed.waitForSettlement({ timeoutMs: 10 * 60_000 })Resolves when settlementStatus === 'completed', rejects with SettlementFailedError if it reaches 'failed', or WaitTimeoutError if the timeout elapses first.
waitFor(event, options?)
waitForConfirmation/waitForSettlement cover the two most common questions — did the payer pay, did the merchant get paid. waitFor() is the general form, for the other five events:
const partiallyPaid = await charge.waitFor('charge.partially_paid')
const expired = await charge.waitFor('charge.expired')
const underpaid = await charge.waitFor('charge.underpaid')
const failed = await charge.waitFor('charge.settlement_failed')
const overpaid = await charge.waitFor('charge.overpaid')Since waitFor() never rejects with a state-specific typed error, timing out and reaching a different terminal state both surface the same way — catch WaitTimeoutError (from @klappay/node) if you want to tell "gave up waiting" apart from other failures in your own error handling:
import { WaitTimeoutError } from '@klappay/node'
try {
const partiallyPaid = await charge.waitFor('charge.partially_paid', { timeoutMs: 30_000 })
} catch (err) {
if (err instanceof WaitTimeoutError) {
console.log(`gave up after ${err.timeoutMs}ms waiting on ${err.chargeId}`)
} else {
throw err
}
}event is any TriggerableChargeEvent (@klappay/types) — every charge WebhookEventType except charge.created (a charge already exists by the time you have an id to trigger against) and the two escrow-terminal events, charge.escrow_released/charge.escrow_refunded (reached by calling release()/refund() directly, whose response already reflects the terminal state — no need to wait for it separately). Same underlying engine as waitForConfirmation/waitForSettlement (SSE-first, polling fallback, same WaitOptions), but simpler on purpose: it only resolves on the specific event you asked for, and never rejects with a typed error for a different terminal state the way waitForConfirmation does — if the charge reaches some other terminal state instead, or the timeout elapses first, you get WaitTimeoutError either way. Reach for waitForConfirmation()/waitForSettlement() when you want that richer, typed-rejection behavior for the common case; reach for waitFor() when you're testing a specific event directly (pairs naturally with klap.sandbox.trigger() — see sandbox-testing.md) or want uniform handling across events.
refresh()
Returns a fresh copy of the charge (a new API call), still wrapped with the same waitForConfirmation/waitForSettlement/refresh methods — useful if you're holding onto a charge object for a while and want the current state without waiting for anything.
const latest = await charge.refresh()list(input?) and listAll(filter?)
const page = await klap.charges.list({ status: 'confirmed', limit: 20 })
// page.data, page.nextCursor, page.hasMorelist() is one page (cursor-based, same as the REST API) — walk pages yourself by feeding nextCursor back in as cursor until hasMore is false:
let cursor: string | undefined
do {
const page = await klap.charges.list({ status: 'confirmed', cursor })
for (const charge of page.data) console.log(charge.id)
cursor = page.nextCursor ?? undefined
} while (cursor)listAll() is an async generator that pages through everything automatically instead:
for await (const charge of klap.charges.listAll({ status: 'confirmed' })) {
console.log(charge.id)
}Each charge yielded by listAll() is the same live-wrapped object as create()/get() return — waitForConfirmation() etc. all work on it too.
listAll()'s parameter is typed ListChargesFilter (Partial<Omit<ListChargesInput, 'cursor'>>) rather than ListChargesInput itself — cursor is deliberately excluded since listAll() manages pagination internally and always drives it itself. Combine as many filter fields as you need; they're passed through unchanged on every page it fetches:
for await (const charge of klap.charges.listAll({ status: 'confirmed', network: 'base' })) {
console.log(charge.id, charge.amount)
}getTimeline(id)
const events = await klap.charges.getTimeline('ch_abc123')
// [{ type: 'charge.created', at: '...' }, { type: 'transaction.detected', ... }, ...]Every event recorded against a charge, in chronological order — useful for debugging a specific payment (why didn't a webhook fire? was a transfer detected at all?) without separate audit tooling. Includes transfer.reclaimed — an on-chain transfer that was detected but never reached its network's required confirmation depth before vanishing (reverted, or dropped from the canonical chain).
getQrCode(id, query?)
const svg = await klap.charges.getQrCode('ch_abc123')
// raw SVG string — write it to a file, inline it in HTML, whatever you needA scannable EIP-681 payment QR code, returned as a raw SVG string (not JSON — this is the one SDK method that isn't). Encodes the charge's address and amount for one accepted (token, network) pair. query ({ token, network }) is only required when the charge accepts more than one pair — with exactly one, it's resolved automatically:
const svg = await klap.charges.getQrCode('ch_abc123', { token: 'USDC', network: 'base' })getQuote(id, input)
Quotes a swap-to-pay: the payer settles the charge with a different cryptocurrency than any of its acceptedPayments, swapped (via 0x) into one of them under the hood. charge.swapAlternatives lists which (token, network) pairs are trusted as swap input for a given charge — pass one straight through as inputToken/inputNetwork, alongside the payer's own wallet address:
const quote = await klap.charges.getQuote('ch_abc123', {
inputToken: 'ETH',
inputNetwork: 'base',
takerAddress: '0x1111111111111111111111111111111111111111',
})The swap's output is delivered straight to the charge's own address, so once the payer signs and submits quote.transaction, the resulting USDC/USDT is detected and credited exactly like any other transfer — Klap never sees or custodies the input cryptocurrency, and the merchant always receives the charge's full remaining amount (quote.outputAmount) regardless of what the payer sent. Klap charges the payer a separate fee on top (quote.fees.klappayFee, plus 0x's own quote.fees.zeroExFee when it applies) — neither ever reduces outputAmount. outputAmount and fees.klappayFee/fees.zeroExFee are legacy JSON numbers; outputAmountExact/inputAmountExact and fees.klappayFeeExact/fees.zeroExFeeExact are the same values as decimal strings (zeroExFeeExact is null when no 0x fee applies) — optional, for compatibility with an older API version, but prefer them for arithmetic.
Two client-side flows depending on inputToken: a network's own native currency (ETH/BNB/POL/AVAX) needs no extra step — sign and send quote.transaction directly (POL replaced MATIC as Polygon's native currency ticker after its 2024 token migration — same asset). An ERC-20 input (BTC, LINK, ARB, OP, CBETH — see AltTokenSchema in @klappay/types for which network trusts which) additionally returns quote.permit2 — sign that EIP-712 message first and append the signature to quote.transaction.data before sending.
quote.expiresAt is a rough guide for a UI countdown only — the actual price is enforced on-chain by the swap transaction itself, not by this timestamp. Requires the charges:write scope (not just charges:read), since each call is a real, billable request against Klap's own 0x account — rate-limited per charge on top of the general per-key rate limit (429 rate_limited).
Not available for test-environment charges — 0x has no testnet support, so this always rejects with 422 swap_test_environment_unsupported (charge.swapAlternatives is correspondingly always empty on a test charge). See @klappay/types' CreateSwapQuoteSchema/SwapQuoteSchema for every field's full documentation.
check(id, input?)
Triggers an immediate on-chain re-check of a charge instead of waiting for the background reconciliation pass, which otherwise catches a missed webhook within roughly a minute as a backstop:
const charge = await klap.charges.check('ch_abc123')Never trusts the caller — it re-runs the same independent on-chain lookup the reconciliation job and webhook ingestion already use, and only changes the charge's state if a real matching transfer is found. If you already have a transaction hash (e.g. right after a swap-to-pay or wallet-connect transaction is sent), pass it with network to verify that specific transaction directly — one RPC call instead of a block-range scan, so it resolves faster and cheaper:
const charge = await klap.charges.check('ch_abc123', {
txHash: '0x1234567890123456789012345678901234567890123456789012345678901234',
network: 'base',
})txHash and network must be provided together, or both omitted. Rate-limited to once every 10 seconds per charge, shared across every caller — prefer watch() to observe the result instead of polling this repeatedly. See @klappay/types' CheckChargeRequestSchema for the full field documentation.
The result also carries three pieces of on-chain evidence of who paid. Klappay reports them; deciding who the payer "is" stays with you:
transactionSender— the checked transaction's own signer. For a wallet that signs its own transactions that's the payer, swap-to-pay included (where the credited transfer's own sender is the router/pool contract instead). It is not the payer when someone else submitted the transaction: a gas-sponsoring relayer (an EIP-7702 smart account, e.g. MetaMask's) or an ERC-4337 bundler.tokenSenders— the checksummedfromof each paying transfer (a Transfer emitted by an accepted token's own contract to the charge's address, mints excluded). Covers the relayer case: the tokens left the wallet even though the relayer signed. For a swap-to-pay it holds the router, not the payer.userOperationSenders— the ERC-4337 account whose own successful user operation produced a paying transfer (official EntryPoints v0.6–v0.9 only). Other accounts sharing the same bundle are never listed.
A wallet appearing in any of the three is evidence it paid. A transaction settling several parties at once can list more than one address in tokenSenders, so pair the evidence with your own per-txHash uniqueness check. Not covered by any of them: an EIP-7702 wallet behind a relayer that swaps from native ETH, whose outgoing value leaves no log.
All three are populated only when txHash/network was passed and that transaction actually paid this charge: on a still-open (or expired) charge, its receipt has to contain a Transfer of an accepted token to the charge's address (even one not yet deep enough to be credited); on an already-confirmed/underpaid charge, it has to be a transfer already credited to it on that network. Otherwise transactionSender is null and both lists are [] — no hint, a not-found/reverted transaction, or a successful transaction that never paid this charge — so a public txHash can't be used to claim someone else's payment. That makes it safe to call check() again with the same hint after watch() reports confirmed, to learn the payer evidence the stream itself doesn't carry.
It also carries confirmationProgress — non-null while a detected transfer hasn't yet reached its network's required confirmation depth, same shape waitForConfirmation()'s onConfirmationProgress reports live over SSE (see above); null otherwise. A terminal charge is never re-scanned — it costs at most one receipt read, and only when the hint is a transfer already credited to it.
prepareRelease(id) and prepareRefund(id)
Return the exact Safe transaction release() or refund() will execute, so you sign it with any EIP-712 signer instead of rebuilding the Safe transaction yourself — no Safe SDK or RPC access needed on your side:
import { toSignableEscrowTypedData } from '@klappay/types'
import { privateKeyToAccount } from 'viem/accounts'
const releaser = privateKeyToAccount(process.env.ESCROW_RELEASER_KEY as `0x${string}`)
const prepared = await klap.charges.prepareRelease('ch_abc123')
// check prepared.destination, tokenAddress, amount, chainId and
// safeAddress against your own records before signing
const signature = await releaser.signTypedData(toSignableEscrowTypedData(prepared.typedData))
const charge = await klap.charges.release('ch_abc123', { signature })The result (EscrowSafeTransaction in @klappay/types) carries action, network, token, chainId, safeAddress, releaserAddress, tokenAddress, destination, amount (a base-unit integer string, e.g. '1500000' for 1.50 of a 6-decimal token) with its decimals, the Safe nonce, safeTxHash and typedData — plain JSON. toSignableEscrowTypedData() turns typedData into the shape viem's typings expect (bigint numbers, literal field types); other EIP-712 signers (ethers, a KMS) can take typedData as-is.
Preparing is read-only — it never moves funds — and the signature is only valid while the escrow's balance is unchanged: if more funds arrive before you submit, release()/refund() rejects with 400 escrow_invalid_signature; prepare again and re-sign. For a refund, check destination before signing: it's the sender of the most recently credited transfer, which isn't always the buyer (an exchange withdrawal arrives from the exchange's wallet, a swap from a router contract, and a charge paid by several wallets refunds only to the last one).
Both reject the same way the action itself would before anything is signed: 409 escrow_already_released/escrow_already_refunded, 422 escrow_not_configured, 422 escrow_nothing_to_release/ escrow_nothing_to_refund (zero balance), 422 escrow_multiple_payment_pairs, and 422 escrow_safe_mismatch (the Safe can't be rebuilt at the charge's address — not transient, contact support). Requires charges:write.
release(id, input)
Releases an escrow-configured charge's entire live token balance from its dedicated Safe to the charge's split address, where the normal distribution mechanism then pays out the merchant/platform shares exactly as it would for a non-escrow charge:
const charge = await klap.charges.release('ch_abc123', {
signature: '0x2d0fbf1dba287883a4b6c5aeef9da7653dc68b3e20417d42e87db700ad9e878...',
})signature must be a valid Safe transaction signature from this charge's escrowReleaserAddress (see escrow above) over the transaction prepareRelease() returns, authorizing a transfer of the escrow's full live balance — the amount actually received, not whatever was fixed at creation, since it can vary with under/overpayment. Verified on-chain by the Safe contract itself before anything moves, never taken on faith by Klappay. Can only be called once per charge — a second call rejects with 409 escrow_already_released (or 409 escrow_already_refunded if refund() got there first — the two are mutually exclusive). Requires charges:write. Fires charge.escrow_released once the release completes on-chain — see webhooks.md.
refund(id, input)
Refunds an escrow-configured charge's entire live token balance from its dedicated Safe back to the address that funded it, instead of the split address — no distribution follows, the full balance goes straight to the payer:
const charge = await klap.charges.refund('ch_abc123', {
signature: '0x2d0fbf1dba287883a4b6c5aeef9da7653dc68b3e20417d42e87db700ad9e878...',
})signature works exactly like release()'s — a valid Safe transaction signature from this charge's escrowReleaserAddress over the transaction prepareRefund() returns, verified on-chain by the Safe contract itself, authorizing a transfer of the escrow's full live balance. Mutually exclusive with release() — an escrow can only ever be released or refunded once, never both; whichever happens first rejects the other with 409 escrow_already_released/409 escrow_already_refunded. Requires charges:write. Fires charge.escrow_refunded once the refund completes on-chain — see webhooks.md.
watch(id, signal?)
for await (const charge of klap.charges.watch('ch_abc123')) {
console.log(charge.status, charge.settlementStatus)
}Raw access to the same live event stream waitForConfirmation()/ waitForSettlement()/waitFor() already use internally — reach for this only if you need custom logic beyond those three built-in outcomes (e.g. reacting to every intermediate status change, not just one terminal one). Yields the full Charge every time status/ settlementStatus changes; the generator ends when the server closes the stream (terminal state, expiry) or the given signal aborts. Most integrations want waitForConfirmation()/waitForSettlement()/ waitFor() instead — they wrap this exact stream with a typed, Promise-based API and a polling fallback.
This filters the connection down to Charge updates only — see watchEvents() below to also observe confirmation_progress events on the same connection.
watchEvents(id, signal?)
import { isChargeEvent, isConfirmationProgressEvent } from '@klappay/node'
for await (const event of klap.charges.watchEvents('ch_abc123')) {
if (isChargeEvent(event)) {
console.log(event.data.status, event.data.settlementStatus)
} else if (isConfirmationProgressEvent(event)) {
console.log(event.data.network, event.data.percent)
}
}Same underlying stream as watch(), without the event: charge filter — also yields event: confirmation_progress events (the same data WaitOptions.onConfirmationProgress receives, see Observing a charge until it resolves), so a caller can observe both on one connection. Use the exported isChargeEvent/isConfirmationProgressEvent type guards to discriminate the union.
Raw SSE access
watch() itself is built on streamSSEEvents, the SDK's lowest-level SSE primitive — exported directly if you want raw stream access instead of any of the higher-level polling/waiting helpers above (e.g. talking to an endpoint this SDK doesn't wrap yet, or handling event types watch() doesn't surface).
import { streamSSEEvents, type SSEEvent } from '@klappay/node'
import type { Charge } from '@klappay/types'
const controller = new AbortController()
for await (const { event, data } of streamSSEEvents<Charge>(
{ baseUrl: 'https://your-klap-api-host', apiKey: 'sk_...' },
'/v1/charges/ch_abc123/events',
controller.signal,
)) {
console.log(event, data)
}SSEEvent<T> is just { event: string; data: T } — the parsed event: <name> / data: <json> pair for one message on the stream, generic over whatever shape data decodes to. A comment-only heartbeat line (: ping, sent to keep the connection alive) has neither an event: nor a data: line, so it's silently skipped rather than yielded as an empty event.
