--- url: https://node-sdk.klappay.com/getting-started.md --- # Getting started ## Install ```bash npm install @klappay/node ``` `@klappay/types` comes along as a dependency automatically — you don't need to install it separately just to use the SDK. ## Create a client ```ts import { createClient } from '@klappay/node' const klap = createClient({ baseUrl: 'https://your-klap-api-host', apiKey: process.env.KLAP_API_KEY, }) ``` `baseUrl` has no *hardcoded* default on purpose — there's no single API host every integration would want, and a wrong silent default is a much harder bug to notice than one that fails loudly. It still has to come from somewhere, though: pass it explicitly (shown above), or set `KLAP_BASE_URL` and drop the option entirely — `createClient()` falls back to it. `apiKey` is a `klap_live_...`/`klap_test_...` key — it's required for every method on the client (`charges`, `webhooks`, `sandbox`, `distributions`, `networks`, `metrics`, `recipients`). The example above reads it from `process.env` manually, which still works, but is now redundant — `createClient()` already falls back to `KLAP_API_KEY` on its own, so `createClient({ baseUrl: '...' })` alone is enough once that variable is set. Optional: `debug: true` (logs every outgoing request's method + URL — never the `Authorization` header — to help diagnose what the SDK is actually sending), and `timeoutMs` (aborts a request after this long; default 30s — a hung API or dropped connection would otherwise hang your code forever. The `waitFor*()` methods use their own `AbortSignal`/timeout logic and aren't affected by this option). ```ts const klap = createClient({ baseUrl: 'https://your-klap-api-host', apiKey: process.env.KLAP_API_KEY, debug: true, // logs "POST https://your-klap-api-host/v1/charges" etc. timeoutMs: 10_000, // abort any request that hangs past 10s }) ``` `apiKey` can also change after construction, without building a new client — `klap.setApiKey()`. ## Environment variables Every `create*Client()` — including the standalone ones documented in [`tree-shaking.md`](./tree-shaking.md) — falls back to `process.env` when `baseUrl`/`apiKey` are omitted, so a fully env-configured project never has to pass either: ```ts import { createRecipientsClient } from '@klappay/node/recipients' const recipients = createRecipientsClient() // reads KLAP_BASE_URL + KLAP_RECIPIENTS_API_KEY ``` `KLAP_BASE_URL` is shared by every client — one Klap API host per process. `apiKey` is scoped per resource instead, since recipients/ charges/metrics/etc. keys carry different permissions and are deliberately never the same key (see [`recipients.md`](./recipients.md)'s scope-separation section): | Client | Env var | |---|---| | `createClient()` | `KLAP_API_KEY` | | `createChargesClient()` | `KLAP_CHARGES_API_KEY` | | `createWebhooksClient()` | `KLAP_WEBHOOKS_API_KEY` | | `createMetricsClient()` | `KLAP_METRICS_API_KEY` | | `createSandboxClient()` | `KLAP_SANDBOX_API_KEY` | | `createDistributionsClient()` | `KLAP_DISTRIBUTIONS_API_KEY` | | `createNetworksClient()` | `KLAP_NETWORKS_API_KEY` | | `createRecipientsClient()` | `KLAP_RECIPIENTS_API_KEY` | An explicit `apiKey`/`baseUrl` argument always wins over its env var. Going through the composed `createClient()`, `KLAP_API_KEY` (if set) is used for every resource uniformly — the resource-specific vars only kick in when going through a *standalone* `create*Client()` directly, or when `createClient()` has no `apiKey` option and `KLAP_API_KEY` isn't set either. If nothing resolves at all, the first call that needs it throws `MissingBaseUrlError`/`MissingCredentialError` — same as passing neither today, just discovered at the first request instead of at construction for `baseUrl`. ## Your first charge ```ts const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, }) console.log(charge.id, charge.address, charge.status) // 'pending' ``` `charge` here isn't just plain data — it's a live object with methods attached (`refresh()`, `waitForConfirmation()`, `waitForSettlement()`). See [`charges.md`](./charges.md) for the full resource reference, including what those methods actually do and how they resolve/reject. ## Where to go next * [`charges.md`](./charges.md) — the core resource: create, list, paginate, and (the SDK's main value-add) observe a charge's status until it resolves. * [`webhooks.md`](./webhooks.md) — registering webhooks, and verifying signatures on what you receive. * [`recipients.md`](./recipients.md) — registering trusted split recipients, and referencing them by `recipientId` in a charge split. * [`metrics.md`](./metrics.md) — ad-hoc analytics over your charges/ transactions/distributions data. * [`distributions.md`](./distributions.md) — discovering and streaming claimable 0xSplits payouts, for keepers/bots, not a typical merchant integration. * [`networks.md`](./networks.md) — the live `(token, network)` capability matrix, for building a payment-method picker instead of hardcoding it. * [`sandbox-testing.md`](./sandbox-testing.md) — testing your integration end-to-end without any real on-chain activity. * [`errors.md`](./errors.md) — every error class the SDK throws, and when. * [`tree-shaking.md`](./tree-shaking.md) — importing only what you use, for bundle-size-sensitive environments (e.g. serverless cold starts). ## For LLMs and agents This site (built from these same files with VitePress) publishes [`llms.txt`](https://node-sdk.klappay.com/llms.txt) — a link index of every doc page — and [`llms-full.txt`](https://node-sdk.klappay.com/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 SDK's documentation without scraping HTML. Both regenerate on every deploy, so they never drift from what's on this page. --- --- url: https://node-sdk.klappay.com/charges.md --- # Charges `klap.charges` — also available standalone as `createChargesClient` from `@klappay/node/charges` (see [`tree-shaking.md`](./tree-shaking.md)). Requires an `apiKey`. ```ts 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`](./getting-started.md#environment-variables)) if omitted. ## `create(input)` ```ts 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()`](./recipients.md), **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`](./recipients.md) for registering recipients and the full request/response shape split. ### `escrow` `CreateChargeSchema`/`ChargeSchema` carry an `escrow` field — configuring a charge as an escrow instead of a normal payment, released only by a signature from `escrow.releaserAddress` (see [`release(id, input)`](#release-id-input) below). **Not usable yet**: klap-core currently rejects any `create()` request carrying `escrow` with `503 escrow_unavailable` — passing it today will fail. This is independent of `release()` already being live, which exists so integrators aren't blocked on both landing at once; this note will come out once `create()` accepts `escrow` too. `acceptedPayments` lets the payer choose which rail to actually use — at least one `(token, network)` pair, up to 14. 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. Not sure which pairs are actually live for your environment right now? See [`networks.md`](./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()`](#getquote-id-input) 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). ## `get(id)` ```ts 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. ```ts 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`. ```ts 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. ### Cancelling a wait Same pattern as `fetch` — pass an `AbortSignal`, cancel with the matching `AbortController`: ```ts 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." ```ts 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 six events: ```ts 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') const released = await charge.waitFor('charge.escrow_released') ``` 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: ```ts 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). 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`](./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. ```ts const latest = await charge.refresh() ``` ## `list(input?)` and `listAll(filter?)` ```ts const page = await klap.charges.list({ status: 'confirmed', limit: 20 }) // page.data, page.nextCursor, page.hasMore ``` `list()` is one page (cursor-based, same as the REST API) — walk pages yourself by feeding `nextCursor` back in as `cursor` until `hasMore` is `false`: ```ts 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: ```ts 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>`) 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: ```ts for await (const charge of klap.charges.listAll({ status: 'confirmed', network: 'base' })) { console.log(charge.id, charge.amount) } ``` ## `getTimeline(id)` ```ts 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. ## `getQrCode(id, query?)` ```ts const svg = await klap.charges.getQrCode('ch_abc123') // raw SVG string — write it to a file, inline it in HTML, whatever you need ``` A 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: ```ts 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: ```ts 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`. **Two client-side flows depending on `inputToken`**: a network's own native currency (`ETH`/`BNB`/`MATIC`/`AVAX`) needs no extra step — sign and send `quote.transaction` directly. An ERC-20 input (today, only `BTC`) 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: ```ts 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: ```ts 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()`](#watch-id-signal) to observe the result instead of polling this repeatedly. See `@klappay/types`' `CheckChargeRequestSchema` for the full field documentation. ## `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: ```ts 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`](#escrow) above), 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`. Requires `charges:write`. Fires `charge.escrow_released` once the release completes on-chain — see [`webhooks.md`](./webhooks.md). ## `watch(id, signal?)` ```ts 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. ## 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). ```ts import { streamSSEEvents, type SSEEvent } from '@klappay/node' import type { Charge } from '@klappay/types' const controller = new AbortController() for await (const { event, data } of streamSSEEvents( { baseUrl: 'https://your-klap-api-host', apiKey: 'sk_...' }, '/v1/charges/ch_abc123/events', controller.signal, )) { console.log(event, data) } ``` `SSEEvent` is just `{ event: string; data: T }` — the parsed `event: ` / `data: ` 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. --- --- url: https://node-sdk.klappay.com/webhooks.md --- # Webhooks `klap.webhooks` — also available standalone as `createWebhooksClient` from `@klappay/node/webhooks`. The management methods require an `apiKey`; the signature-verification helpers need no credential at all (they're pure functions, no network call). ```ts import { createWebhooksClient } from '@klappay/node/webhooks' const webhooks = createWebhooksClient({ baseUrl: '...', apiKey: '...' }) const webhook = await webhooks.create({ url: 'https://your-server.com/webhooks/klap', events: ['charge.confirmed', 'charge.settled'], }) ``` `baseUrl`/`apiKey` are optional — they fall back to `KLAP_BASE_URL`/ `KLAP_WEBHOOKS_API_KEY` (see [`getting-started.md`](./getting-started.md#environment-variables)) if omitted. ## Registering a webhook ```ts const webhook = await klap.webhooks.create({ url: 'https://your-server.com/webhooks/klap', events: ['charge.confirmed', 'charge.settled'], }) console.log(webhook.secret) // whsec_... — returned in full ONLY this once, store it now ``` `url` must be HTTPS and publicly reachable — private/internal addresses are rejected. `secret` is never returned again after this call; every later `klap.webhooks.list()` only returns a truncated `hint`. If a secret ever leaks, call `klap.webhooks.rotateSecret(webhookId)` (see "Managing webhooks" below) rather than deleting and recreating the webhook — the old secret stops verifying immediately, and the webhook keeps its id and delivery history. `charge.confirmed` vs `charge.settled` is a real distinction, not two names for the same thing: `confirmed` means Klap detected the payment on-chain; `settled` means the merchant's wallet actually received it — a separate, later step. Subscribe to `confirmed` if you only need "will I get paid," or `settled` if you need "has the money actually arrived." See the full event list in `@klappay/types`' `ChargeWebhookEventTypeSchema` (8 charge events) and `WebhookDeliveryEventTypeSchema` (3 delivery-health events) — 11 events total. ### Environment scoping `webhook.environment` (`'live' | 'test' | null`) is set automatically from whichever API key created it — there's no `create()` input field for it, and it can't be changed afterward. `null` means the webhook was created before this field existed, and it keeps receiving every environment, same as always. A webhook only receives events whose own environment matches — a `test`-key webhook never receives a real `live` charge event, and a `live`-key webhook never receives a sandbox-triggered one. Register a webhook with each of your `live` and `test` API keys if you want separate endpoints/handlers per environment. ### Subscribing by category or wildcard, not just individual events Events are grouped into two categories — `payments` (every `charge.*` event) and `webhooks` (the three delivery-health events): ```ts // receive every event in these categories — new events added to a // category later arrive automatically, no need to update the subscription await klap.webhooks.create({ url: 'https://your-server.com/webhooks/klap', eventCategories: ['payments', 'webhooks'], }) // everything except specific exclusions await klap.webhooks.create({ url: 'https://your-server.com/webhooks/klap', events: ['*'], excludeEvents: ['charge.overpaid'], }) ``` `klap.webhooks.categories` lists every event per category, for discoverability: ```ts klap.webhooks.categories.payments // ['charge.created', 'charge.partially_paid', 'charge.confirmed', ...] ``` These are additive — `events` alone (the first example above) keeps working exactly as before; you only reach for `eventCategories`/wildcard when you want them. ## Verifying and parsing an inbound webhook **Always verify the signature before trusting a webhook payload** — anyone who can reach your endpoint can send you a structurally-valid request otherwise. ```ts import { WebhookTimestampToleranceError } from '@klappay/node' app.post('/webhooks/klap', (req, res) => { try { const event = klap.webhooks.constructEvent( req.rawBody, // the raw, unparsed request body string — not req.body req.headers['x-klappay-signature'], process.env.KLAP_WEBHOOK_SECRET, ) switch (event.event) { case 'charge.settled': // event.data is a fully-typed Charge break case 'webhook.endpoint_unhealthy': // event.data is { webhookId, url, failureRatio } — a different, // smaller shape, and TypeScript already knows it here without a cast break // ... } 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 } if (err instanceof SyntaxError) { // signature checked out, but the body isn't valid JSON — a // corrupted delivery, not a forgery; don't lump this in with a bad signature res.sendStatus(400) return } // InvalidWebhookSignatureError — reject, don't process res.sendStatus(400) } }) ``` App Router's `Request` has no raw-body middleware to configure — call `req.text()` yourself before any JSON parsing happens: ```ts import { WebhookTimestampToleranceError } from '@klappay/node' import { NextResponse } from 'next/server' export async function POST(req: Request) { const rawBody = await req.text() try { const event = klap.webhooks.constructEvent( rawBody, req.headers.get('x-klappay-signature') ?? '', process.env.KLAP_WEBHOOK_SECRET!, ) switch (event.event) { case 'charge.settled': break // ... } return new NextResponse(null, { status: 200 }) } catch (err) { if (err instanceof WebhookTimestampToleranceError) { return new NextResponse(null, { status: 400 }) } if (err instanceof SyntaxError) { return new NextResponse(null, { status: 400 }) } return new NextResponse(null, { status: 400 }) } } ``` The only real difference from the Express handler is how the raw body is obtained — everything downstream of `rawBody` is identical. Whichever framework you use, get the raw body first: if you read it as JSON (`req.json()`, `express.json()`) before verification, the bytes `constructEvent` needs to compute the HMAC over no longer exist. **A malformed body is not a signature failure.** `constructEvent` verifies the signature and checks the timestamp tolerance before it ever calls `JSON.parse` on the body — but if the body isn't valid JSON (a corrupted delivery, a proxy that mangled it), `JSON.parse` throws a plain `SyntaxError`, not `InvalidWebhookSignatureError`. A catch-all that assumes "anything that isn't `WebhookTimestampToleranceError` must be a bad signature" silently misattributes this case — check for `SyntaxError` explicitly, as both examples above do. See [`errors.md`](./errors.md) for every error class the SDK throws. `constructEvent(rawBody, signatureHeader, secret, options?)` does three things in one call: verifies the HMAC-SHA256 signature (timing-safe comparison — never implement this comparison yourself with `===`, timing attacks are a real risk), checks that the delivery is recent (`options.toleranceSeconds`, default 300 — 5 minutes), and parses the body into `TypedWebhookPayload` — a discriminated union keyed by `event`, so `data` narrows automatically in a `switch`/`if` on `event` (same pattern as Stripe's `Event.data.object`). Every `charge.*` event carries the full `Charge` object as `data`; every `webhook.*` delivery-health event carries `{ webhookId, url, failureRatio? }` instead (`failureRatio` only present on `webhook.endpoint_unhealthy`) — see `WebhookEventDataMap` in `@klappay/types` for the exact shape per event. Throws `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 — see "Signing and replay protection" below). The envelope (`id`/`event`/`createdAt`) is validated at runtime; `data` itself is trusted rather than re-validated per event type, since Klap controls both producer and consumer of this shape. If you only want the boolean check without parsing (note: this checks the HMAC only, not the timestamp tolerance): ```ts const isValid = klap.webhooks.verifySignature(rawBody, signatureHeader, secret) ``` ### Signing and replay protection The signature header is `t=,v1=` — the HMAC covers `${timestamp}.${rawBody}`, not just the body. This is what lets `constructEvent` reject a delivery that's validly signed but old: anyone who captures one legitimate delivery (a leaked proxy log, a compromised intermediary) and replays the exact same body+signature later gets rejected once the timestamp falls outside the tolerance window, regardless of how long they hold onto it. The tolerance window is a mitigation, not a guarantee — a replay sent *within* the window (a few minutes) still passes. For belt-and-suspenders protection against that narrower case, deduplicate by the payload's own `id` (unique per delivery) on your side, especially for any handler whose effect isn't naturally idempotent. **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. ## Managing webhooks ```ts const webhooks = await klap.webhooks.list() await klap.webhooks.delete(webhookId) const rotated = await klap.webhooks.rotateSecret(webhookId) console.log(rotated.secret) // a fresh whsec_... — the old one stops verifying immediately const page = await klap.webhooks.listDeliveries(webhookId) // page.data, page.nextCursor, page.hasMore — status, HTTP response code, attempt count for await (const delivery of klap.webhooks.listAllDeliveries(webhookId)) { console.log(delivery.id, delivery.status) } await klap.webhooks.retryDelivery(webhookId, deliveryId) // immediately retries a specific delivery, regardless of its normal retry schedule ``` `listDeliveries()` returns one cursor-paginated page (same `{ limit, cursor }` → `{ data, nextCursor, hasMore }` shape as `klap.charges.list()`); `listAllDeliveries()` pages through every delivery automatically. `klap.webhooks.list()` itself (the webhooks, not their deliveries) stays unpaginated — capped at 20 active webhooks per organization. ### None of the management methods are idempotent on a stale id `delete()` and `rotateSecret()`, called with a webhook id that's already been deleted, both throw a `KlapApiError` with `code: 'webhook_not_found'` and `status: 404` — the exact same error you'd get for an id that never existed at all. This is deliberate on the API side, not a bug: a deleted webhook is indistinguishable from a nonexistent one, so don't treat either call as a safe-to-repeat no-op — check `list()` first if you need to know whether a webhook is still there before acting on it. `retryDelivery(webhookId, deliveryId)` behaves slightly differently because it looks up two things: its webhook lookup *does* include deleted webhooks (so retrying a delivery that was recorded before the webhook was deleted still resolves the webhook itself), but it still 404s with `code: 'delivery_not_found'` if that specific `deliveryId` doesn't exist under it. Pass a `webhookId` that never existed at all, and you get `code: 'webhook_not_found'` instead — the two failure modes are distinguishable by `err.code`: ```ts import { KlapApiError } from '@klappay/node' try { await klap.webhooks.retryDelivery(webhookId, deliveryId) } catch (err) { if (err instanceof KlapApiError && err.code === 'delivery_not_found') { // webhookId is valid (even if since deleted); deliveryId isn't } else if (err instanceof KlapApiError && err.code === 'webhook_not_found') { // webhookId itself never existed } else { throw err } } ``` See [`errors.md`](./errors.md) for `KlapApiError`'s full shape. ### Scope errors look the same as not-found errors Every management method (`create`, `list`, `delete`, `rotateSecret`, `listDeliveries`/`listAllDeliveries`, `retryDelivery`) needs an API key carrying the appropriate write scope for webhooks. Calling one without it doesn't throw a distinct "forbidden" class — it raises the same `KlapApiError` as every other API-side rejection, just with a different `status`/`code` describing the permission failure. Branch on `err.code` (not on having caught *a* `KlapApiError` at all) if your handling needs to tell a scope problem apart from a not-found one. --- --- url: https://node-sdk.klappay.com/recipients.md --- # Recipients `klap.recipients` — also available standalone as `createRecipientsClient` from `@klappay/node/recipients`. Requires an `apiKey`. ```ts import { createRecipientsClient } from '@klappay/node/recipients' const recipients = createRecipientsClient({ baseUrl: '...', apiKey: '...' }) const recipient = await recipients.create({ address: '0x...ab', label: 'sales rep' }) ``` `baseUrl`/`apiKey` are optional — they fall back to `KLAP_BASE_URL`/ `KLAP_RECIPIENTS_API_KEY` (see [`getting-started.md`](./getting-started.md#environment-variables)) if omitted. A recipient is a trusted EVM address you register once, so a charge's [`splitRecipients`](./charges.md) can reference it by `id` instead of a raw address. This exists to close a redirect risk: with `charges:write` alone, a key can never route a slice of a payment to an address you haven't already trusted — registering (or approving) a new payout destination needs the separate `recipients:write` scope, and using an already-registered one in a split needs `charges:split_write`. A single key is never issued both. ## Registering a recipient ```ts const recipient = await klap.recipients.create({ address: '0x000000000000000000000000000000000000ab', label: 'sales rep', // optional, your own bookkeeping — never interpreted }) console.log(recipient.id) // rc_... — this is what a charge split references ``` `create()` is an **idempotent upsert** keyed on `address`: registering an address that's already known just updates its `label` and (if it was revoked) un-revokes it — safe to call again without checking whether the recipient already exists first. Registering resets `payout` to `false` regardless of what it was before (see below). ## Using a recipient in a charge split ```ts const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, splitRecipients: [{ recipientId: recipient.id, percent: 10, label: 'sales rep' }], }) console.log(charge.splitRecipients) // [{ address: '0x...ab', percent: 10, label: 'sales rep' }] ``` The request and response use different shapes on purpose: you submit a `recipientId` (something you can only reference, never invent), and read back the resolved `address` (so you can see where the money actually went without a second lookup). See [`charges.md`](./charges.md#splitrecipients) for the full split semantics (percent is of your own net share, max 5 entries, frozen at creation). Creating a charge with `splitRecipients` requires `charges:split_write` in addition to `charges:write`. ## Listing and revoking ```ts const recipients = await klap.recipients.list() // every non-revoked recipient for this environment, newest first — not paginated await klap.recipients.revoke(recipient.id) ``` `list()` returns `[]`, not an error, when the environment has no registered recipients yet — there's no separate "empty" signal to check for beyond the array's length. Revoking a recipient that's currently referenced by `payout: true` (see below) needs `recipients:manage_payout` instead of `recipients:write` — plain `recipients:write` can only revoke recipients that aren't also an API key's payout destination. `revoke()` is **not idempotent** — calling it on a recipient that's already revoked throws a `KlapApiError` with `code: 'recipient_not_found'` and `status: 404`, the same error (and deliberately indistinguishable from) revoking an id that never existed at all. Don't treat a second `revoke()` call as a safe no-op: ```ts import { KlapApiError } from '@klappay/node' try { await klap.recipients.revoke(recipient.id) } catch (err) { if (err instanceof KlapApiError && err.code === 'recipient_not_found') { // already revoked, or this id never existed — the API doesn't // distinguish the two, so neither can you from this error alone } else { throw err } } ``` See [`errors.md`](./errors.md) for `KlapApiError`'s full shape. ## `payout` — the link to an API key's own payout address `recipient.payout` is unrelated to using a recipient in a split (every non-revoked recipient is already usable there). It controls something narrower: whether this address is *eligible to become an API key's own `payoutAddress`* — the destination the merchant's own charges settle to. ```ts await klap.recipients.setPayout(recipient.id, true) ``` This requires `recipients:manage_payout`, deliberately a stricter scope than `recipients:write` — it's meant to be held only by a key that's already gone through its own out-of-band approval (e.g. your dashboard's internal key), never a third-party integration key. Revoking a `payout: true` recipient takes effect immediately: any API key whose `payoutAddress` matches it stops authenticating on its very next request. Turning it back off is the same call with `false`: ```ts await klap.recipients.setPayout(recipient.id, false) ``` **Setting `payout: true` does not unset it on any other recipient.** There's no single-payout-target invariant enforced here — multiple recipients can simultaneously hold `payout: true`, and calling `setPayout(id, true)` on a new one has no side effect on recipients already flagged. This is the most surprising part of the method: if your integration assumes "setting payout on this recipient" implicitly clears it elsewhere (the way, say, a single default payment method usually works), that assumption is wrong here — clear the old one yourself with an explicit `setPayout(oldId, false)` if that's the behavior you want. See [`errors.md`](./errors.md) for the error class these calls throw on failure. --- --- url: https://node-sdk.klappay.com/metrics.md --- # Metrics `klap.metrics` — also available standalone as `createMetricsClient` from `@klappay/node/metrics` (see [`tree-shaking.md`](./tree-shaking.md)). Requires an `apiKey` — every key only ever sees its own tenant's data, so there's no organization id to pass anywhere. ```ts import { createMetricsClient } from '@klappay/node/metrics' const metrics = createMetricsClient({ baseUrl: '...', apiKey: '...' }) const result = await metrics.query({ resource: 'charges', environment: 'live', dateRange: { field: 'createdAt', from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z' }, metrics: [{ aggregation: 'count', alias: 'total' }], }) ``` `baseUrl`/`apiKey` are optional — they fall back to `KLAP_BASE_URL`/ `KLAP_METRICS_API_KEY` (see [`getting-started.md`](./getting-started.md#environment-variables)) if omitted. ## `query(input)` Ad-hoc analytics over your `charges`/`transactions`/`distributions` data, in the same spirit as a log/observability platform's query API: pick a resource, an aggregation, optional `groupBy` (including a single time-bucketed entry), and filters — the response rows are shaped by that query, not a fixed report. Not raw SQL: every filterable/groupable/ aggregatable field is an explicit, typed enum, checked both by TypeScript and by the server. ```ts const result = await klap.metrics.query({ resource: 'charges', environment: 'live', dateRange: { field: 'createdAt', from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z', }, groupBy: [{ type: 'date_bucket', field: 'createdAt', granularity: 'day' }], metrics: [ { aggregation: 'sum', field: 'amount', alias: 'volume' }, { aggregation: 'count' }, ], filters: [{ field: 'status', operator: 'eq', value: 'confirmed' }], }) // result.data: [{ createdAt: '2026-07-01', volume: 4820.5, count: 12 }, ...] // result.meta: { resource: 'charges', environment: 'live', rowCount: ..., truncated: false } ``` `result.meta.truncated` is `true` when more rows matched the query than `limit` allowed, meaning `result.data` holds only the first `limit` of them — `rowCount` still reflects what's in `data`, not the true total. Treat it as a signal to narrow the query (a tighter `dateRange`, an added `filters` entry, a `groupBy` with more buckets) rather than just raising `limit` — it caps out at `METRICS_QUERY_MAX_ROW_LIMIT` (1000) regardless. The input type is `MetricsQueryRequest` (from `@klappay/types`) — a discriminated union on `resource`, so TypeScript narrows which fields are valid the moment you set `resource: 'charges' | 'transactions' | 'distributions'`. `groupBy`, `filters`, and `limit` are all optional there (the schema defaults them to `[]`/`[]`/`100`). `date_bucket` supports a `'year'` granularity alongside the usual `hour`/`day`/`week`/ `month`. Every queryable field per resource — which dimensions you can filter/group by, which numeric fields you can aggregate, which date fields you can range/bucket on (e.g. `charges` now includes `expiresAt`, `distributions` now includes `distributorAddress`/ `processingStartedAt`) — is documented in `@klappay/types`' `metrics-query.md` (`ChargesQueryField`, `TransactionsMetricField`, etc.), not duplicated here. A few more realistic queries, past the single day-bucketed example above: Average transaction size per network, grouped by a plain field instead of a time bucket: ```ts const byNetwork = await klap.metrics.query({ resource: 'transactions', environment: 'live', dateRange: { field: 'detectedAt', from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z', }, groupBy: [{ type: 'field', field: 'network' }], metrics: [ { aggregation: 'avg', field: 'amount', alias: 'avgAmount' }, { aggregation: 'count' }, ], filters: [{ field: 'token', operator: 'eq', value: 'USDC' }], }) // result.data: [{ network: 'base', avgAmount: 128.4, count: 302 }, ...] ``` The 5 highest-volume days in range, using `orderBy` against a metric's own `alias` and `limit` to cap the row count: ```ts const topDays = await klap.metrics.query({ resource: 'charges', environment: 'live', dateRange: { field: 'createdAt', from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z', }, groupBy: [{ type: 'date_bucket', field: 'createdAt', granularity: 'day' }], metrics: [{ aggregation: 'sum', field: 'amount', alias: 'volume' }], orderBy: { key: 'volume', direction: 'desc' }, limit: 5, }) ``` Distributions that are still stuck outside `completed`, combining an `in` filter with a `neq` filter: ```ts const stuck = await klap.metrics.query({ resource: 'distributions', environment: 'live', dateRange: { field: 'createdAt', from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z', }, groupBy: [{ type: 'field', field: 'status' }], metrics: [{ aggregation: 'avg', field: 'attempts', alias: 'avgAttempts' }], filters: [ { field: 'network', operator: 'in', value: ['base', 'optimism'] }, { field: 'status', operator: 'neq', value: 'completed' }, ], }) ``` A few constraints worth knowing up front: * `dateRange` is always required and capped at 366 days — this isn't optional-defaulting-to-unbounded on purpose, it's what keeps a query from scanning your entire history. * At most one `date_bucket` entry is allowed in `groupBy`. * `field` on a `metrics[]` entry is required unless `aggregation` is `'count'`. * Every `metrics[].alias` must be unique, must not collide with a `groupBy` field name (or the reserved word `"bucket"`), and must match `^[a-zA-Z_][a-zA-Z0-9_]*$` — it becomes a SQL column alias server-side for a date-bucketed query. * `orderBy.key` must match that same `^[a-zA-Z_][a-zA-Z0-9_]*$` pattern — it should already be a `groupBy` field name or a metric's alias/ default name, all of which are always shaped like this, so in practice this only ever rejects a value that could never have been a real output column to begin with. A query that satisfies all four at once: ```ts await klap.metrics.query({ resource: 'charges', environment: 'live', dateRange: { field: 'createdAt', from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z', }, groupBy: [{ type: 'date_bucket', field: 'createdAt', granularity: 'week' }], // just one date_bucket metrics: [ { aggregation: 'sum', field: 'amount', alias: 'weekly_volume' }, // unique, matches the alias pattern { aggregation: 'count', alias: 'charge_count' }, ], orderBy: { key: 'weekly_volume', direction: 'desc' }, // an existing alias }) ``` All of the above is enforced server-side regardless of what the SDK does client-side — `klap.metrics.query()` doesn't pre-validate before sending, it's a thin wrapper; a malformed query comes back as a `400` `KlapApiError`: ```ts import { KlapApiError } from '@klappay/node' try { await klap.metrics.query({ resource: 'charges', environment: 'live', dateRange: { field: 'createdAt', from: '2026-08-01T00:00:00.000Z', to: '2026-07-01T00:00:00.000Z', // to before from }, metrics: [{ aggregation: 'count' }], }) } catch (error) { if (error instanceof KlapApiError && error.status === 400) { console.error(`Malformed metrics query (${error.code}): ${error.message}`, error.param) } else { throw error } } ``` --- --- url: https://node-sdk.klappay.com/distributions.md --- # Distributions `klap.distributions` — also available standalone as `createDistributionsClient` from `@klappay/node/distributions` (see [`tree-shaking.md`](./tree-shaking.md)). Requires an `apiKey`. ```ts import { createDistributionsClient } from '@klappay/node/distributions' const distributions = createDistributionsClient({ baseUrl: '...', apiKey: '...' }) const page = await distributions.list() ``` `baseUrl`/`apiKey` are optional — they fall back to `KLAP_BASE_URL`/ `KLAP_DISTRIBUTIONS_API_KEY` (see [`getting-started.md`](./getting-started.md#environment-variables)) if omitted. This is for **keepers/bots**, not a typical merchant integration. 0xSplits' `distribute()` is permissionless — anyone can call it and receive a small `distributorFeePercent` reward — and this resource exists so a keeper can discover which splits are currently claimable within their grace period, before Klap's own worker gets to them. Ignore this entirely unless you're specifically building or running such a keeper. ## Putting it together: a minimal keeper Connect the stream before bootstrapping the backlog, so no delta is missed between the two calls; apply every event as an idempotent add/remove on top of the snapshot `listAll()` gives you: ```ts import type { PendingDistribution } from '@klappay/types' const seen = new Map() const events = klap.distributions.streamPending() // connect first for await (const distribution of klap.distributions.listAll()) { seen.set(distribution.splitAddress, distribution) } for (const distribution of seen.values()) { await distributeSplit(distribution) } for await (const event of events) { if (event.type === 'distribution.available') { seen.set(event.distribution.splitAddress, event.distribution) await distributeSplit(event.distribution) } else { seen.delete(event.splitAddress) } } async function distributeSplit(distribution: PendingDistribution) { // 0xSplits' own on-chain `distribute()` — a contract call your keeper // submits directly, not a klap-node method: // await splitsClient.distribute({ // splitAddress: distribution.splitAddress, // token: distribution.token, // network: distribution.network, // distributorAddress: YOUR_ADDRESS, // }) } ``` ## `list(input?)` and `listAll()` ```ts const page = await klap.distributions.list({ limit: 20 }) // page.data, page.nextCursor, page.hasMore ``` Cursor-paginated, same shape and semantics as every other list endpoint (`klap.charges.list()`, etc.) — pass `limit`/`cursor` to page through it manually, or use `listAll()` to page through everything automatically: ```ts for await (const d of klap.distributions.listAll()) { console.log(d.splitAddress, d.network, d.token, d.estimatedRewardAmount) } ``` A snapshot of every split, in the calling key's own environment, with a confirmed payout still inside its grace period. Each entry has the `splitAddress`/`network`/`token` to identify it, the exact `recipients` array and `distributorFeePercent` you'd need to call `distribute()` correctly, an `estimatedRewardAmount` (an estimate only — read the split's actual on-chain balance before submitting a transaction), and `availableSince`/`graceEndsAt` timestamps. If a page reports `hasMore: true` but its `nextCursor` is `null`, `listAll()` treats that as nothing left to follow and stops instead of looping forever — this is a defensive stop against a malformed page, not an expected steady-state response. Nothing claimable right now is a normal, common result, not an error — `list()` resolves to `{ data: [], nextCursor: null, hasMore: false }` and `listAll()` simply yields nothing, so a keeper's bootstrap loop naturally does nothing until a split enters its grace period: ```ts for await (const distribution of klap.distributions.listAll()) { // never runs when there's nothing currently claimable } ``` ## `streamPending(signal?, limit?)` ```ts for await (const event of klap.distributions.streamPending()) { if (event.type === 'distribution.available') { console.log('new:', event.distribution.splitAddress) } else { console.log('claimed:', event.splitAddress) } } ``` Real-time deltas, scoped to the calling key's own environment. With no `limit`, no initial snapshot is sent over this stream — **connect here first**, then call `list()`/`listAll()` to bootstrap your own state, applying every event you receive (whether it arrives before or after that resolves) as an idempotent add/remove on top of that snapshot — connecting in the opposite order leaves a small gap where a delta between the two calls is never delivered. `event.type` discriminates the union: `'distribution.available'` (a new distribution entered its grace period, or re-entered it after a failed attempt) carries the full `distribution`; `'distribution.claimed'` (settled by anyone, or picked up by Klap's own worker) carries only the `splitAddress` that's no longer claimable. The generator ends when the server closes the stream or the given `signal` aborts. ```ts const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5 * 60_000) try { for await (const event of klap.distributions.streamPending(controller.signal)) { // ... } } finally { clearTimeout(timeout) } ``` Pass `limit` (1-100) for a self-contained connection instead of the connect-then-list dance above: ```ts for await (const event of klap.distributions.streamPending(undefined, 50)) { // ... } ``` The server sends up to `limit` currently-claimable distributions as synthetic `'distribution.available'` events right after connecting, then continues with live deltas — one connection, no separate `list()` call needed. This snapshot isn't a full page (no cursor) — if more than `limit` are claimable, use `list()`/`listAll()` directly instead for a complete listing during a backlog. --- --- url: https://node-sdk.klappay.com/networks.md --- # Networks `klap.networks` — also available standalone as `createNetworksClient` from `@klappay/node/networks` (see [`tree-shaking.md`](./tree-shaking.md)). Requires an `apiKey`. ```ts import { createNetworksClient } from '@klappay/node/networks' const networks = createNetworksClient({ baseUrl: '...', apiKey: '...' }) const capabilities = await networks.get() ``` `baseUrl`/`apiKey` are optional — they fall back to `KLAP_BASE_URL`/ `KLAP_NETWORKS_API_KEY` (see [`getting-started.md`](./getting-started.md#environment-variables)) if omitted. ## `get()` ```ts const capabilities = await klap.networks.get() // { acceptedPayments: [ // { token: 'USDC', network: 'base' }, // { token: 'USDC', network: 'optimism' }, // { token: 'USDT', network: 'base' }, // ... // ] } ``` Returns the live `(token, network)` capability matrix for the calling key's own environment (`live` or `test`) — every pair currently configured, read straight from the same lookup `POST /v1/charges` validates `acceptedPayments` against. A pair listed here is always safe to submit as a charge's `acceptedPayments`; a pair not listed here is rejected with `422 token_not_supported`. Build a payment-method picker from this instead of hardcoding the matrix client-side — it changes as new networks/tokens come online, and `live`/`test` can differ (a network can be enabled on testnet before it goes live). ## Building a picker `acceptedPayments` is a flat list of `(token, network)` pairs — group it however your UI needs. A token-first picker (which networks does this token settle on?) is a one-line reduce: ```ts const { acceptedPayments } = await klap.networks.get() const networksByToken = acceptedPayments.reduce>( (acc, { token, network }) => { ;(acc[token] ??= []).push(network) return acc }, {}, ) // { USDC: ['base', 'optimism'], USDT: ['base'] } ``` Or index the other way, network-first, if your UI picks a chain before a token: ```ts const tokensByNetwork = acceptedPayments.reduce>( (acc, { token, network }) => { ;(acc[network] ??= []).push(token) return acc }, {}, ) // { base: ['USDC', 'USDT'], optimism: ['USDC'] } ``` `acceptedPayments` can come back empty (`{ acceptedPayments: [] }`) — not an error, just nothing currently enabled for that environment. Build the picker to render an empty state rather than assuming the matrix is always non-empty. To see whether `live` and `test` actually differ for your own key, fetch both and diff the pairs — `setApiKey()` swaps which environment the client authenticates as, no need to build a second client: ```ts const test = await klap.networks.get() klap.setApiKey(liveApiKey) const live = await klap.networks.get() ``` See [`charges.md`](./charges.md) for how `acceptedPayments` is used when creating a charge. --- --- url: https://node-sdk.klappay.com/sandbox-testing.md --- # Testing your integration `klap.sandbox` — also available standalone as `createSandboxClient` from `@klappay/node/sandbox` (see [`tree-shaking.md`](./tree-shaking.md)). ```ts import { createSandboxClient } from '@klappay/node/sandbox' const sandbox = createSandboxClient({ baseUrl: '...', apiKey: '...' }) await sandbox.confirm(charge.id) ``` `baseUrl`/`apiKey` are optional — they fall back to `KLAP_BASE_URL`/ `KLAP_SANDBOX_API_KEY` (see [`getting-started.md`](./getting-started.md#environment-variables)) if omitted. Requires a `test`-environment API key (`klap_test_...`). `live` keys don't have access to `klap.sandbox`, and a `test` key can only ever act on a `test` charge — not a `live` one, even in the same organization. ## Simulating any event ```ts const charge = await klap.charges.create({ amount: 10, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, }) // instead of waiting for a real on-chain transfer: await klap.sandbox.confirm(charge.id) ``` `klap.sandbox` wraps a single REST primitive, `POST /v1/sandbox/charges/{id}/trigger`, which can push a charge into **any** state transition — not just full payment. `confirm()` is the convenience method for the most common case; the others cover the rest: | Method | Simulates | |---|---| | `klap.sandbox.confirm(chargeId)` | `charge.confirmed` — full payment | | `klap.sandbox.partiallyPay(chargeId, amount?)` | `charge.partially_paid` — partial payment (defaults to half the charge amount) | | `klap.sandbox.overpay(chargeId, amount?)` | `charge.confirmed` + `charge.overpaid` — payment above `amount` (defaults to 1.5x the charge amount) | | `klap.sandbox.expire(chargeId)` | `charge.expired` — expired with zero payment | | `klap.sandbox.underpay(chargeId)` | `charge.underpaid` — expired after a partial payment (trigger `partiallyPay` first) | | `klap.sandbox.settle(chargeId)` | `charge.settled` — payout completed | | `klap.sandbox.failSettlement(chargeId)` | `charge.settlement_failed` — payout failed | | `klap.sandbox.releaseEscrow(chargeId)` | `charge.escrow_released` — escrow funds released to the split address | | `klap.sandbox.trigger(chargeId, event, amount?)` | Any of the above, by event name — what the others call internally | None of these ever touch the blockchain — `settle()`/`failSettlement()` simulate the outcome directly instead of calling `distribute()`, so a test charge never spends real gas. Each has a precondition on the charge's current state (e.g. `underpay()` requires the charge to already be `partially_paid`); triggering one out of order rejects with a `KlapApiError` (`code: 'invalid_trigger_state'`). `overpay()` isn't a standalone state — it always fires alongside `charge.confirmed`, same as a real overpayment detected on-chain. This is the only sandbox trigger endpoint — webhook-delivery-health events (`webhook.delivery_failed`, etc.) are derived from real delivery attempts and have no simulated trigger of their own. ```ts await klap.sandbox.overpay(charge.id, 15) const overpaid = await charge.waitFor('charge.overpaid') expect(overpaid.isOverpaid).toBe(true) ``` `settle()` and `failSettlement()` only make sense on a charge that's already `confirmed` — this is the pair to reach for when what you're actually testing is your settlement/payout handling, not the payment itself, and you want it to resolve (or fail) without spending real gas: ```ts await klap.sandbox.confirm(charge.id) await klap.sandbox.settle(charge.id) const settled = await charge.waitForSettlement() expect(settled.settlementStatus).toBe('completed') ``` ```ts await klap.sandbox.confirm(charge.id) await klap.sandbox.failSettlement(charge.id) await expect(charge.waitForSettlement()).rejects.toThrow(SettlementFailedError) ``` `expire()` simulates a charge that nobody ever paid: ```ts const charge = await klap.charges.create({ amount: 10, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, }) await klap.sandbox.expire(charge.id) const expired = await charge.waitFor('charge.expired') expect(expired.status).toBe('expired') ``` Reach for `trigger()` directly — instead of the named convenience methods above — when the event you want is only known at runtime, e.g. driven by a parameterized test table, or for a triggerable event that doesn't yet have its own dedicated method: ```ts async function simulate(chargeId: string, event: TriggerableChargeEvent) { await klap.sandbox.trigger(chargeId, event) } await simulate(charge.id, 'charge.settled') ``` ## A full integration test Combine this with `waitFor()` to test your own webhook-handling code end-to-end, for any event, without any real money or waiting for real block times: ```ts const charge = await klap.charges.create({ amount: 10, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, }) const [confirmed] = await Promise.all([ charge.waitFor('charge.confirmed', { timeoutMs: 15_000 }), klap.sandbox.confirm(charge.id), ]) expect(confirmed.status).toBe('confirmed') expect(confirmed.amountReceived).toBe(10) ``` Or drive it through the full partial-payment lifecycle: ```ts await klap.sandbox.partiallyPay(charge.id, 4) await charge.waitFor('charge.partially_paid') await klap.sandbox.underpay(charge.id) const underpaid = await charge.waitFor('charge.underpaid') expect(underpaid.amountReceived).toBe(4) ``` See [`charges.md`](./charges.md#waitfor-event-options) for `waitFor()` in depth. `waitFor()` also takes the same `onStatusChange`/`signal` options as `waitForConfirmation()`/`waitForSettlement()` — useful in a sandbox test to log every intermediate state, or to bound how long a test can hang if the trigger call itself never resolves: ```ts const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5_000) const [confirmed] = await Promise.all([ charge.waitFor('charge.confirmed', { onStatusChange: (c) => console.log('status is now', c.status), signal: controller.signal, }), klap.sandbox.confirm(charge.id), ]) clearTimeout(timeoutId) ``` ## Testing your webhook handler without deploying anything Pair this with `@klappay/cli`'s `klap listen --forward-to` and `klap sandbox trigger` — drive any event from your terminal while your own webhook handler, running on `localhost`, receives it with a real signature. See [`@klappay/cli`](https://www.npmjs.com/package/@klappay/cli)'s own README for the full mechanism. --- --- url: https://node-sdk.klappay.com/errors.md --- # Errors The SDK throws, it doesn't return `{ ok, error }` unions — use `try`/`catch`, and check the error's class (or `instanceof`) to decide what happened. None of the classes below share a common SDK base error — each extends `Error` directly — so there's no single SDK type to `catch` that covers all of them; check `instanceof` against the specific classes you care about, falling back to `instanceof KlapApiError` for the whole family of API-side errors, or `instanceof Error` as the final catch-all. ## `KlapApiError` Thrown for any non-2xx response from the API itself (validation errors, auth failures, not-found, etc.). ```ts import { KlapApiError } from '@klappay/node' try { await klap.charges.create({ amount: -5, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, }) } catch (err) { if (err instanceof KlapApiError) { console.log(err.status) // HTTP status, e.g. 400 console.log(err.code) // stable machine-readable code, e.g. 'validation_error' console.log(err.message) // human-readable explanation console.log(err.param) // which field, when applicable, e.g. 'amount' } } ``` `code`/`message`/`param` come straight from the API's own error payload shape (`@klappay/types`' `ErrorPayloadSchema`) — `code` is the stable value to branch on programmatically; `message` is for logs/debugging, not for showing end users. ## Errors from `waitForConfirmation()` / `waitForSettlement()` These reject instead of resolving with a charge you'd have to inspect — see [`charges.md`](./charges.md) for the full behavior. | Error | Thrown when | |---|---| | `ChargeExpiredError` | `waitForConfirmation()` — the charge's `status` reached `expired` (nobody paid before `expiresAt`) | | `ChargeUnderpaidError` | `waitForConfirmation()` — the charge's `status` reached `underpaid` (partial payment, then `expiresAt` passed) | | `SettlementFailedError` | `waitForSettlement()` — `settlementStatus` reached `failed` (retries exhausted; rare, contact support) | | `WaitTimeoutError` | Either method — the `timeoutMs` elapsed before a terminal state was reached | Each carries a `chargeId` property. `WaitTimeoutError` also carries the `timeoutMs` that was configured. ```ts import { ChargeExpiredError, ChargeUnderpaidError, WaitTimeoutError, } from '@klappay/node' try { await charge.waitForConfirmation({ timeoutMs: 60_000 }) } catch (err) { if (err instanceof ChargeExpiredError) { /* nobody paid */ } else if (err instanceof ChargeUnderpaidError) { /* partial payment only */ } else if (err instanceof WaitTimeoutError) { /* still pending, keep checking later */ } else throw err } ``` ## `InvalidWebhookSignatureError` Thrown by `klap.webhooks.constructEvent()` when the signature doesn't match. See [`webhooks.md`](./webhooks.md). ```ts import { InvalidWebhookSignatureError } from '@klappay/node' try { const event = klap.webhooks.constructEvent( req.rawBody, req.headers['x-klappay-signature'], process.env.KLAP_WEBHOOK_SECRET, ) // ... handle event } catch (err) { if (err instanceof InvalidWebhookSignatureError) { res.sendStatus(400) return } throw err } ``` See [`webhooks.md`](./webhooks.md#verifying-and-parsing-an-inbound-webhook) for the full handler, including `WebhookTimestampToleranceError` and the malformed-body case alongside it. ## `WebhookTimestampToleranceError` Thrown by `klap.webhooks.constructEvent()` when the signature is valid but its timestamp falls outside the tolerance window (default 300s) — a strong signal of a replayed delivery, distinct from a forged one. Carries `timestamp` (the delivery's own, as a Unix timestamp) and `toleranceSeconds`. See [`webhooks.md`](./webhooks.md)'s "Signing and replay protection". ## `MissingCredentialError` Thrown immediately, client-side, when you call a method that needs an `apiKey` that resolved to nothing — not passed to `createClient()`, and no matching `KLAP_*_API_KEY` env var set either (see [`getting-started.md`](./getting-started.md#environment-variables)). Pass it explicitly, set the env var, or call `klap.setApiKey()` first. No request ever reaches the API in this case. ```ts import { createClient, MissingCredentialError } from '@klappay/node' const klap = createClient({ baseUrl: 'https://your-klap-api-host' }) try { await klap.charges.create({ amount: 10, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, }) } catch (err) { if (err instanceof MissingCredentialError) { console.log(err.message) // "charges.create() requires an apiKey — ..." } } ``` ## `MissingBaseUrlError` Thrown immediately, client-side, the same way as `MissingCredentialError` above, but for `baseUrl` — not passed to `createClient()`/`create*Client()`, and `KLAP_BASE_URL` isn't set either. No request ever reaches the API in this case. ```ts import { createChargesClient, MissingBaseUrlError } from '@klappay/node' const charges = createChargesClient({ apiKey: 'klap_test_...' }) try { await charges.get('ch_1') } catch (err) { if (err instanceof MissingBaseUrlError) { console.log(err.message) // "/v1/charges/ch_1 requires a baseUrl — ..." } } ``` --- --- url: https://node-sdk.klappay.com/tree-shaking.md --- # Tree-shaking and minimal bundles The SDK is split into one module per API resource, each independently importable via a subpath, instead of one monolithic client class. This matters even outside the browser — a serverless function's cold-start time depends on how much code it has to load, so a smaller bundle is a real win on the server too, not just for front-end bundle size. ## Full client (convenience) ```ts import { createClient } from '@klappay/node' const klap = createClient({ baseUrl: '...', apiKey: '...' }) klap.charges.create(...) klap.webhooks.create(...) ``` Pulls in every resource module, regardless of which ones you actually call. ## Minimal (only what you use) ```ts import { createChargesClient } from '@klappay/node/charges' const charges = createChargesClient({ baseUrl: '...', apiKey: '...' }) charges.create(...) ``` A bundler never even sees the `webhooks`/`metrics`/etc. modules in this case — they're not in the import graph at all, which doesn't depend on the bundler being smart enough to eliminate unused code from a bigger object (dead-code elimination on object properties isn't reliably supported everywhere; simply not importing the module in the first place always works). ## Available subpaths | Subpath | Exports | |---|---| | `@klappay/node/charges` | `createChargesClient` | | `@klappay/node/webhooks` | `createWebhooksClient`, `verifyWebhookSignature`, `constructWebhookEvent` | | `@klappay/node/metrics` | `createMetricsClient` | | `@klappay/node/sandbox` | `createSandboxClient` | | `@klappay/node/distributions` | `createDistributionsClient` | | `@klappay/node/networks` | `createNetworksClient` | | `@klappay/node/recipients` | `createRecipientsClient` | Each `create*Client(config)` takes the same config shape `createClient()` does (`{ baseUrl, apiKey?, debug?, timeoutMs? }`) — you're just constructing one resource directly instead of the full composed client. ## Verifying a webhook without any client at all `verifyWebhookSignature`/`constructWebhookEvent` (from `@klappay/node/webhooks`) don't need a configured client — they're plain functions. If all you need is webhook signature verification, you don't need to construct a client at all: ```ts import { verifyWebhookSignature } from '@klappay/node/webhooks' const isValid = verifyWebhookSignature(rawBody, signatureHeader, secret) ```