--- url: https://docs.klappay.com/how-it-works.md --- # How it works Read this before [Getting started](/getting-started) if "predicted, immutable on-chain address" doesn't already mean something concrete to you — the rest of the docs assume this model, and it's not the one Stripe/PayPal-shaped intuition gives you for free. ## The core idea Klap never holds your money, not even for a second. Every charge gets its own on-chain address, computed deterministically before the payer sends anything — the same charge always predicts the same address, so there's nothing to "generate and store." A payer's transfer goes straight to that address, not to Klap. ```mermaid sequenceDiagram participant M as Merchant backend participant K as Klap API participant P as Payer participant A as Charge address (on-chain) M->>K: POST /charges K-->>M: charge (status: pending, address, checkoutUrl) M->>P: redirect to checkoutUrl (or show address / QR) P->>A: sends token directly to the charge's address A-->>K: transfer detected on-chain K-->>M: charge.confirmed (webhook + SSE) Note over K,A: funds sit at the address until distributed —
not instant, see "Settlement" below K->>A: distribute() A-->>M: funds arrive in the merchant's wallet K-->>M: charge.settled (settlementStatus: completed) ``` ## The address itself `charge.address` is a [0xSplits](https://splits.org) contract, deployed (or predicted, if not deployed yet) via `CREATE2` with no `chainId` in the derivation — which is *why* it's the same address across every network in `acceptedPayments`: the address doesn't belong to a chain, it belongs to the charge. A charge accepting `USDC/base` and `USDT/optimism` has payers on two different chains sending to the exact same address string. There's no private key Klap holds for this address, and nothing to "sweep" — it's a split contract with the merchant's wallet configured as a recipient at creation time. Klap's role is limited to two things: **detecting** that a transfer landed (so `charge.status` can update), and **triggering** the split contract's own `distribute()` call so the funds actually move out to the recipient (see "Settlement" below) — `distribute()` itself is permissionless, callable by anyone, not a privileged Klap-only action. ## Detection: `status` Klap watches on-chain activity and updates `charge.status` as transfers arrive — `pending` → `partially_paid`/`confirmed`, or `expired`/`underpaid` if the deadline passes first. A transfer is credited once it reaches the network's required confirmation depth — seconds on most networks, longer on Ethereum — and the `confirmation_progress` stream event reports how far along it is in the meantime. See [Charges](/charges) for the full state machine and [Real-time status](/realtime) for how to observe it without polling. **`status: confirmed` means the transfer was seen — it does not mean the merchant has the money yet.** That's a separate, later step. ## Settlement: `settlementStatus` Funds sitting at the charge's split address aren't automatically in your wallet — someone still has to call `distribute()` on that contract, which is its own on-chain transaction. Klap does this for you automatically, after a short grace period (a few minutes, to batch and avoid triggering a distribution on every single incoming transfer of a multi-part payment) — not because there's a queue or a manual step, just because "the transfer landed" and "the payout transaction executed" are two different blockchain events with two different timestamps. This is why `charge.settlementStatus` exists separately from `status`, and why [Distributions](/distributions) — the list of payouts still waiting on that call — is its own resource. If you only care "did the payer pay," watch `status`. If you specifically need "has the money actually arrived," watch `settlementStatus` (or use the Node SDK's `waitForSettlement()`). ## What this rules out * **No refund API on a normal charge.** Funds go straight from the payer to an immutable on-chain split the payer isn't a recipient of, so there's no balance to issue a refund from — an over/underpayment is surfaced as data (`isOverpaid`, `amountReceived`) for you to handle as a business decision, not something the platform can reverse on your behalf. See [Charges](/charges). * **No reversal of a distributed payment.** A transfer that lands on-chain is final the moment the network confirms it, and once the split has distributed, the money is already with its recipients. There's no reservation step to void, and no chargeback path to undo a completed distribution. * **No chargebacks.** There's no card network or bank in this flow to initiate one. The one deliberate exception to "the money moves the moment it's confirmed" is an [escrow charge](/charges#holding-funds-with-escrow): its funds accumulate in a Safe dedicated to that charge and distribute only once a signature from the releaser says so — which also means they can still be sent back to the payer in full, up until that point. It's not an authorization-and-capture (the payer's transfer is a real, final on-chain transfer either way) and it's not a partial refund of settled money — it's a settlement step you hold the key to, and it is the only place in Klap where funds pause between arriving and being distributed. ## See also * [Getting started](/getting-started) — create your first charge. * [Charges](/charges) — the full charge lifecycle and `Charge` shape. * [Distributions](/distributions) — tracking payouts still waiting on `distribute()`. * [Real-time status (SSE)](/realtime) — how `status`/`settlementStatus` updates reach you without polling. --- --- url: https://docs.klappay.com/getting-started.md --- # Getting started Klap is a non-custodial crypto payments API — you get a unique on-chain address per charge and never touch a private key or a hosted balance. Two things you need before your first request: * **An account and an API key** — sign up and generate keys from your dashboard at [app.klappay.com](https://app.klappay.com). Keys aren't self-serve through the API itself — creating one is a dashboard action, done once per environment: `klap_live_...` for production, or `klap_test_...` to run against real testnets (Base Sepolia and others) with no real money at risk. See [Authentication](/authentication) for how a key is scoped and what it's allowed to do; the short version, used everywhere below, is `Authorization: Bearer klap_live_...`. * **A way to call the API.** Two options, and neither is "more official" than the other — pick based on your stack: * **`@klappay/node`**, the official Node.js SDK. It wraps every endpoint with typed methods and automatic idempotency keys, and — its real reason to exist over calling REST directly — `waitForConfirmation()`/`waitForSettlement()` methods that watch a charge's status for you instead of you hand-rolling a polling loop. * **Raw REST**, `https://api.klappay.com/v1`, callable from any language. Every response is plain JSON, and the OpenAPI document backing this site's API reference is publicly served at `/v1/openapi.json` if you want to generate a client for a language Klap doesn't ship an SDK for. ## Quickstart ```bash npm install @klappay/node ``` (`@klappay/types` — the Zod schemas and TypeScript types every SDK method is built from — comes along as a dependency automatically. You don't need to install it separately just to use the SDK; see [Type safety](#type-safety) below for when you would.) Create a client, then create a charge. The cURL tab does exactly the same thing with no SDK at all, side by side for comparison: ::: code-group ```ts [Node.js] import { createClient } from '@klappay/node' const klap = createClient({ baseUrl: 'https://api.klappay.com/v1', apiKey: process.env.KLAP_API_KEY, // klap_live_... or klap_test_... }) const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, // seconds, required — up to 3600 (1 hour max) }) console.log(charge.id, charge.address, charge.status) // 'pending' ``` ```bash [cURL] curl https://api.klappay.com/v1/charges \ -X POST \ -H "Authorization: Bearer $KLAP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 49.9, "acceptedPayments": [{ "token": "USDC", "network": "base" }], "expiresIn": 3600 }' ``` ::: [→ Try `POST /charges` in the API Playground](https://api.klappay.com/#tag/charges/POST/charges) Both calls create the identical resource: a `pending` charge with a unique on-chain `address` the payer sends funds to. Funds go straight to your own wallet — Klap never custodies them. `baseUrl` has no built-in default in the SDK — always pass `https://api.klappay.com/v1` explicitly; a required field that fails loudly if you forget it is easier to catch than a silent default. There's one thing the cURL version has to handle that the Node version doesn't: retries. If a request like this times out or the connection drops before you see a response, retrying it verbatim risks creating a second charge for the same order. `klap.charges.create()` generates an `idempotencyKey` for you automatically on every call, so a retry safely returns the original charge unchanged instead of a duplicate. Calling REST directly, pass your own `idempotencyKey` in the body (deriving it from your own order id works well) to get the same guarantee — reusing a key with a different request body is a `409`, not a silent return of the original charge, so it only protects genuine retries. What comes back from the SDK is more than the JSON, too: `charge` here is a live object with `waitForConfirmation()`, `waitForSettlement()`, and `refresh()` methods attached, so you can `await` the payment resolving instead of writing a polling loop yourself. See [Charges](/charges) for the full resource — creating, listing, and watching a charge's status until it resolves, plus QR codes and the per-charge event timeline. ## Type safety ```bash npm install @klappay/types zod ``` `zod` is a peer dependency, not bundled — every schema `@klappay/types` exports *is* a `zod` schema, so if your project already depends on `zod` directly, you already have it. `@klappay/types` is published as its own package, separate from the SDK — both `@klappay/node` and your own code import from it, and the SDK doesn't hand-maintain a parallel set of types that could drift; it re-exports these directly. The package itself has no HTTP client and no networking of any kind — it only describes shapes. Two things, exported side by side for every resource: * A **Zod schema** (`ChargeSchema`, `CreateChargeSchema`, `WebhookPayloadSchema`, ...) — validate data at runtime, not just satisfy the type checker. * An **inferred TypeScript type** for the same shape (`Charge`, `CreateChargeInput`, ...). The type is `z.infer` on top of the schema — there's no separate hand-written type anywhere that could drift from it. Reach for `@klappay/types` directly, rather than only through the SDK, any time you're calling the API yourself but still want the contracts: a `fetch` call in a framework the SDK doesn't fit, a codegen step, a different language's types generated from the same schemas, or validating a webhook payload in a service that doesn't otherwise use `@klappay/node`. **Typing a response:** ```ts import type { Charge } from '@klappay/types' async function getCharge(id: string): Promise { const res = await fetch(`https://api.klappay.com/v1/charges/${id}`, { headers: { Authorization: `Bearer ${process.env.KLAP_API_KEY}` }, }) return res.json() } ``` **Validating a response or webhook payload at runtime** — a type only helps at compile time; it says nothing about what actually came back over the wire. Parse anything you don't fully control through its schema: ```ts import { WebhookPayloadSchema } from '@klappay/types' app.post('/webhooks/klap', (req, res) => { const payload = WebhookPayloadSchema.parse(req.body) // throws on anything invalid res.sendStatus(200) }) ``` This only validates *shape* — it never checks the `X-Klappay-Signature` header, so don't trust a webhook body just because it parsed. See [Webhooks](/webhooks) for verifying that signature yourself (the SDK's `constructEvent()` does both the signature check and the parse in a single call). ## Where to go next * [Charges](/charges) — the core resource: create, list, paginate, and observe a charge's status until it resolves. * [Webhooks](/webhooks) — registering webhooks and verifying signatures on what you receive. * [Authentication](/authentication) — API key format, scopes, and what each scope actually gates. * [Sandbox](/sandbox) — testing your integration end-to-end, including triggering any charge state transition, without any real on-chain activity. * [Errors](/errors) — every error shape the API returns (and, if you're on the SDK, every error class it throws), and when. * [AI assistants (MCP)](/mcp) — let Claude, Cursor or any MCP client inspect charges and webhooks, and drive test charges, with your key. ## For LLMs and agents This site publishes [`llms.txt`](https://docs.klappay.com/llms.txt) — a link index of every page here — and [`llms-full.txt`](https://docs.klappay.com/llms-full.txt) — the full content of every page concatenated into one plain-text file. Point an agent, RAG pipeline, or MCP server at either as a lightweight way to hand it the whole integration documentation without scraping HTML. Both regenerate on every deploy, so they never drift from what's on these pages. Each SDK publishes its own pair at the same paths on its own site: [`@klappay/node`](https://node-sdk.klappay.com/llms.txt), [`@klappay/types`](https://api.klappay.com/types/llms.txt), [`@klappay/cli`](https://cli.klappay.com/llms.txt), [`@klappay/checkout-kit`](https://node-checkout-sdk.klappay.com/llms.txt), [`@klappay/one`](https://js-one.klappay.com/llms.txt), and [`@klappay/mcp`](https://mcp.klappay.com/llms.txt). `llms.txt` lets an agent *read* the docs. To let it *act* on your account — look up a charge, list failed webhook deliveries, create and sandbox-trigger a test charge — run the [MCP server](/mcp) instead. --- --- url: https://docs.klappay.com/authentication.md --- # Authentication ## Overview Klap has exactly one auth scheme: API keys, sent as a bearer token on every request. There's no session cookie, no OAuth flow, no separate "publishable" key safe for client-side use the way Stripe has — a `klap_live_...`/`klap_test_...` key is a secret, same class as a database password. Never embed one in browser JS, a mobile app, or any code a user's device can inspect (view-source, devtools, decompiling the app all extract it trivially). Charges must always be created from a trusted backend holding the key server-side; if you need to show live status in a browser, have your backend relay it (see [Real-time status](/realtime)) instead of shipping the key itself. Treat the whole `klap_live_<...>`/`klap_test_<...>` string as an opaque bearer token and send it as-is; there's nothing in it for you to parse or validate. ## Getting a key Keys are created and managed from your dashboard at [app.klappay.com](https://app.klappay.com) — sign up, then generate a `klap_live_...` and/or `klap_test_...` key per environment. There's no API endpoint for creating a key yourself; this is a one-time dashboard step per environment, not something your integration code does at runtime. Giving a key to an AI assistant follows the same rule: the [MCP server](/mcp) reads it from the file `klap login` writes on your machine rather than from the assistant's own config, and a live key it holds is read-only unless you explicitly opt in. Prefer a dedicated key scoped to what the assistant needs, so revoking it doesn't touch your production integration. ## Sending your API key Every authenticated request needs an `Authorization: Bearer` header carrying the full key, prefix included. ::: code-group ```bash [cURL] curl https://api.klappay.com/v1/networks \ -H "Authorization: Bearer klap_live_..." ``` ```ts [Node.js] import { createClient } from '@klappay/node' const klap = createClient({ baseUrl: 'https://api.klappay.com/v1', apiKey: process.env.KLAP_API_KEY, // klap_live_... or klap_test_... }) const health = await klap.health.check() ``` ::: A request with no key at all gets `401 missing_api_key`; an invalid, malformed, or revoked key gets `401 invalid_api_key`. A key that's valid but lacks a scope a route requires gets a separate `403 insufficient_scope` instead — two different failure classes worth branching on separately in your own error handling: the first means "fix the key you're sending," the second means "ask for a differently-scoped key." `GET /health` is the one route that needs no key at all, useful as an uptime check that doesn't burn a real request against your key's rate limits. See [Errors](/errors) for the full error envelope both classes share. ## Scopes Independent of environment (`live`/`test`) and of which account a key belongs to, a key also carries a fixed set of **scopes** — what it's allowed to *do*. Before scopes existed, any valid key could call every authenticated route; scopes let you hand out a key that's safe to give to a CI pipeline, a read-only internal dashboard, or a single-purpose integration, without granting it the full range of what your account can do. The full vocabulary is a closed set of 14 strings, exported as `ApiKeyScopeSchema` / `ApiKeyScope` from `@klappay/types` — validate against it rather than hand-rolling the list, so a future scope addition doesn't need a second place to update: ```ts import { ApiKeyScopeSchema, API_KEY_SCOPES, type ApiKeyScope } from '@klappay/types' ApiKeyScopeSchema.parse('charges:write') // 'charges:write' API_KEY_SCOPES // readonly array of every valid scope string ``` | Scope | Grants | |---|---| | `charges:read` | `GET /charges`, `GET /charges/{id}`, `/timeline`, `/events`, `/qrcode` | | `charges:write` | `POST /charges` | | `charges:split_write` | Required **in addition to** `charges:write` whenever a `POST /charges` request includes `splitRecipients` — a key without it creates ordinary charges but can never redirect any part of a payout, even to an already-registered recipient | | `webhooks:read` | `GET /webhooks`, `GET /webhooks/{id}/deliveries`, `GET /webhooks/listen` | | `webhooks:write` | `POST /webhooks`, `DELETE /webhooks/{id}`, retrying a delivery | | `webhooks:manage_secret` | `POST /webhooks/{id}/rotate-secret` — kept separate from `webhooks:write` since it returns a new plaintext secret, the single highest-trust action on this resource | | `metrics:read` | `POST /metrics/query`, every `resource` (`charges`, `transactions`, `distributions`) | | `metrics:charges:read` | `POST /metrics/query` with `resource: 'charges'` only | | `metrics:transactions:read` | `POST /metrics/query` with `resource: 'transactions'` only | | `metrics:distributions:read` | `POST /metrics/query` with `resource: 'distributions'` only | | `sandbox:trigger` | `POST /sandbox/charges/{id}/trigger` — kept independent of `charges:write` even though both mutate charge state; simulating a state transition and creating a real charge are different capabilities | | `recipients:read` | `GET /recipients` | | `recipients:write` | `POST /recipients`, `DELETE /recipients/{id}` — registers or revokes a recipient (an address eligible to be *referenced* by `recipientId` in a split); doesn't by itself make it eligible to become a key's `payoutAddress`, and can't revoke a `payout: true` recipient | | `recipients:manage_payout` | `PATCH /recipients/{id}` only — marks a recipient payout-eligible, and is required to revoke one that is. Meant for a key that already went through its own out-of-band approval, never a merchant-facing or third-party integration key. Revoking a `payout: true` recipient also stops any API key whose `payoutAddress` matches it from authenticating (`401 payout_address_revoked`) on its next request | `metrics:read` and the three narrower `metrics:*:read` scopes aren't mutually exclusive alternatives — a key can hold either, or both. A key with only `metrics:charges:read` can query `charges` metrics but gets `403 insufficient_scope` querying `transactions`/`distributions`; a key with `metrics:read` can query all three regardless of which narrower scopes it also has. `charges:split_write` and `recipients:write`/`recipients:manage_payout` are mutually exclusive: no key can hold `charges:split_write` together with either recipient-registration scope, and a key that does is rejected outright with `403 conflicting_api_key_scopes` before any route runs (`CONFLICTING_SCOPE_PAIRS`/`findConflictingScopes` are exported from `@klappay/types` so a key-minting UI can refuse the combination up front). The ability to *register* a recipient and the ability to *route a split to one* must live on separate keys; otherwise a single leaked key could add a new address and immediately send money to it, which defeats the point of requiring recipients to be pre-registered. See [Recipients](/recipients). Two authenticated routes check no scope at all: `GET /networks` (identical response for every key in an environment, nothing tenant-specific to protect) and `GET /distributions/pending`/ `/pending/events` (a cross-tenant public feed by design — on-chain `distribute()` is permissionless, so this endpoint grants no capability that isn't already open to anyone). Both still require a valid, unrevoked, environment-matched key — they just skip the scope check. `scopes` is otherwise a required part of every key: an empty scope set (`[]`) still authenticates fine, it just can't pass any scope check, so every scoped route on it returns `403`. Ask for a key scoped to only what a given integration needs rather than reusing a full-access key everywhere — a `charges:read`-only key, for example, can't create charges or touch webhooks even if it leaks, so treat narrowly-scoped keys as meaningfully lower-risk to hand to a lower-trust integration (a CI job, a third-party analytics tool) even though a scoped key is still a bearer secret, not a safe-to-embed publishable token. ## Environments The prefix on the key — `klap_live_` or `klap_test_` — is the *only* thing that determines which environment a request runs against. A few read endpoints (`GET /charges`, `POST /metrics/query`) accept an `environment` filter, but it can only ever match the key's own environment — anything else is `422 environment_mismatch`, never a way to switch. There's no single key that can act as both: a `live` key and a `test` key are two distinct keys, not one key with a switchable mode. * A `test` key can only ever read or write `test`-environment charges/webhooks — never a `live` one, even under the same account. Reaching for a `live` charge's id with a `test` key fails the same way a wrong-tenant request would (`404`), not with a special "wrong environment" error. * `test` keys unlock [Sandbox](/sandbox)'s trigger endpoint (`POST /sandbox/charges/{id}/trigger`) — manually push a charge through any state transition (confirm, partially pay, overpay, expire, underpay, settle, fail settlement) with no real on-chain transfer and no gas spent. `live` keys don't have access to this route at all; there's no way to accidentally trigger a fake event against real money. * Everything else about the API — request/response shapes, error format, rate limiting, scopes — behaves identically in both environments. Building and testing an integration against a `test` key end-to-end, then swapping in a `live` key for production, should require no code changes beyond the key itself. See [Sandbox](/sandbox) for the full set of triggerable events and how to drive them. ## Rotation and revocation Keys don't expire on a fixed lifetime — a key is valid until it's explicitly revoked, not until some expiry date passes. That makes revocation, not expiry, the mechanism you actually rely on for taking a compromised key out of service. **Revocation** is the lever you control, from your dashboard. Once you revoke a key: * It's checked on **every** request, so revocation takes effect immediately — there's no cache window or propagation delay to wait out. The very next request sent with a revoked key gets `401 invalid_api_key`, whether that request lands one second or one month after you revoked it. * Any request already in flight (accepted and being processed by the time you hit revoke) is unaffected — auth is checked once, at the start of the request, not re-checked partway through. Revocation stops the *next* request from being accepted, not an already-running one from finishing. * Revoking a key is final — there's no "unrevoke." If you revoked the wrong key, or need the same access restored, generate a new key with the same scopes rather than expecting the old one to come back. * Revoking one key never affects any other key on your account, including other keys in the same environment. Keys are independent credentials, not shared instances of one underlying secret. Rotate proactively — generate a new key, deploy it, then revoke the old one — rather than waiting for a suspected leak; a key with a long operational history is also a key that's had more chances to end up somewhere it shouldn't (a committed `.env`, a log line, an error report). Rotating this way costs zero downtime: both keys work simultaneously while you switch over, so there's no window where your integration is left holding a key that doesn't authenticate yet. ## See also * [Getting started](/getting-started) — installing the SDK, creating a client, and your first charge. * [Errors](/errors) — the full error envelope behind `401`/`403`, and every other status code the API returns. * [Sandbox](/sandbox) — everything a `test` key unlocks, and how to drive a charge through every event without real on-chain activity. --- --- url: https://docs.klappay.com/charges.md --- # Charges The core resource. A charge accumulates on-chain transfers toward a target `amount` until either the target is met (`confirmed`) or `expiresAt` passes first (`expired` with zero funds received, `underpaid` with a partial amount). Every charge shares this exact same state machine — the only real choices at creation are which `(token, network)` pairs to accept and how long the checkout window should be. ## Creating a charge ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/charges \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 49.9, "acceptedPayments": [ { "token": "USDC", "network": "base" }, { "token": "USDC", "network": "optimism" }, { "token": "USDT", "network": "base" } ], "expiresIn": 3600, "externalRef": "order_123", "source": "checkout", "metadata": { "plan": "pro" } }' ``` ```ts [Node.js] 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' }, // optional }) ``` ::: [→ Try `POST /charges` in the API Playground](https://api.klappay.com/#tag/charges/POST/charges) `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. `acceptedPayments` lets the payer choose which rail to actually use — at least one `(token, network)` pair, up to 18 (2 tokens × 9 operational networks — the schema's ceiling of distinct pairs, which grows when a network is added; fewer are actually payable in any one environment, see below). 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. `token`/`network`/`environment` combinations that aren't currently payable are rejected at creation (`422 token_not_supported`, naming the first bad pair) — not every token is available on every network and environment, and `tron` currently accepts no payments at all (see [Networks & tokens](/networks) for the live matrix; call `GET /networks` instead of hardcoding pairs client-side). An `acceptedPayments` list can only mix networks that share one split factory — a charge predicts a single address up front and reuses it on every network it accepts, which is only safe when they all derive it the same way. In practice every EVM network (`arc` included) forms one group and can be mixed freely; `tron` has its own split contract, so a list mixing `tron` with any EVM network is rejected with `400 validation_error` (`NETWORK_FAMILIES` in `@klappay/types` tells you which family a network belongs to — see [Networks & tokens](/networks#network)). **Idempotency**: if you don't pass `idempotencyKey`, the SDK generates one for you automatically, making 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. Reusing a key with a *different* body is a `409 idempotency_key_reused` error, not a silent return of the original charge. `externalRef`/`source` are both free-form strings echoed on every webhook payload — `source` is deliberately not a fixed enum, so a new integration flow never needs a core change to use it. `redirectUrl` — optional, only validated as a well-formed URL (Klap never inspects what's at that destination) — is where the payer lands after the charge resolves *if* you send them to Klap's own hosted checkout page (see `checkoutUrl` below). It's ignored entirely if you're not using the hosted checkout — building your own payment UI from `address`/`acceptedPayments` means you control navigation yourself, with no role for this field to play. ### Who pays the fee: `feePayer` `feePayer` defaults to `'merchant'`: `amount` is exactly what you asked for, and Klap's `feePercent` comes out of your own payout. `feePercent` is 1% by default, but it's negotiable per company or commercial proposal, so read it off each charge rather than hardcoding 1%. Set it to `'payer'` and `amount` is grossed up at creation time so that, after the same deduction, you still net the amount you originally requested — the payer sees and sends the larger, fee-inclusive total. ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/charges \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 49.9, "acceptedPayments": [{ "token": "USDC", "network": "base" }], "expiresIn": 3600, "feePayer": "payer" }' ``` ```ts [Node.js] const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, feePayer: 'payer', }) ``` ::: It's frozen at creation like every other fee input, and it doesn't change how `feeAmount`/`merchantAmount` are computed on read — only what `amount` was set to in the first place. Either way the charge comes back carrying `feePercent`, `feeAmount`, and `merchantAmount`, so a price breakdown never means reimplementing the fee math; `merchantAmount` is always what actually lands in your payout. ### Splitting a slice to other recipients: `splitRecipients` {#splitrecipients} Route a slice of the charge to up to 5 extra recipients (a supplier, or whoever closed the sale) with `splitRecipients`. Each entry names a `recipientId` (from [`POST /recipients`](/recipients), **not** a raw address), a `percent`, and an optional `label` (1–64 characters, your own bookkeeping, echoed back unchanged). ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/charges \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 49.9, "acceptedPayments": [{ "token": "USDC", "network": "base" }], "expiresIn": 3600, "splitRecipients": [ { "recipientId": "rc_abc123", "percent": 10, "label": "sales rep" } ] }' ``` ```ts [Node.js] const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, splitRecipients: [{ recipientId: 'rc_abc123', percent: 10, label: 'sales rep' }], }) console.log(charge.splitRecipients) // [{ address: '0x1111...1111', percent: 10, label: 'sales rep' }] ``` ::: **`percent` is a share of your own net amount (`100 - feePercent`), not of the charge's gross `amount`.** Klap's fee is computed on the gross first and is never diluted by how you divide what's left. The percents must fit inside that share, otherwise the request is rejected with `422 split_recipients_exceed_available_percent`. The list is frozen at creation like everything else that shapes the split address; it can't be changed afterward. Request and response differ on purpose: you submit a `recipientId`, and `charge.splitRecipients` echoes back the resolved `address` of each entry (an empty array when there are none), so reading the charge back tells you where the money went without a second lookup. A `recipientId` that doesn't resolve fails the request with `422 recipient_not_found_in_split`. Requires the `charges:split_write` scope **in addition to** `charges:write` — a key with only `charges:write` creates ordinary charges but can never redirect part of a payout, even to an already-registered recipient. See [Recipients](/recipients) for registering them and [Authentication](/authentication#scopes) for the scope model. ### Holding funds with `escrow` Pass `escrow` and the charge stops paying out on confirmation. Funds accumulate in a Safe dedicated to that charge and move only on a signature from `escrow.releaserAddress` — either onward to the split address ([release](#releasing-or-refunding-an-escrow)) or back to the payer ([refund](#releasing-or-refunding-an-escrow)). The two are mutually exclusive: an escrow charge only ever goes one way, once. ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/charges \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 49.9, "acceptedPayments": [{ "token": "USDC", "network": "base" }], "expiresIn": 3600, "escrow": { "releaserAddress": "0x4444444444444444444444444444444444444444" } }' ``` ```ts [Node.js] const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, escrow: { releaserAddress: '0x4444444444444444444444444444444444444444' }, }) ``` ::: `releaserAddress` is set once at creation and immutable after — it is the only address ever authorized to move this charge's escrowed funds. Omit it and it defaults to the API key's own `payoutAddress`, which is the common case: the merchant releasing their own charge is usually the same wallet they already get paid to. Pass one explicitly only when the releaser is a different party, such as an operational key kept separate from the payout wallet. Klap never holds a key with release authority of its own — this is the same non-custodial guarantee as a normal charge, with the settlement step gated on your signature instead of happening automatically. Escrow requires **every** network in `acceptedPayments` to be EVM — the funds sit in a dedicated Safe, which doesn't exist on TRON and never will (it isn't an EVM chain). `POST /charges` rejects `escrow` combined with `tron` in `acceptedPayments` with `400 validation_error`; `arc` is EVM-compatible and works. Everything else works identically on an escrow charge — `acceptedPayments`, `expiresIn`, `metadata`, `splitRecipients`. `escrow` only changes how funds leave once they've arrived. ## Sending the payer to checkout The fastest way to see what a payer actually experiences: every charge gets a hosted checkout page for free, no frontend to build. Redirect to `charge.checkoutUrl` right after creating it: ```ts const charge = await klap.charges.create({ amount: 49.9, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 3600, redirectUrl: 'https://your-site.com/thank-you', // where the payer lands once it resolves }) console.log(charge.checkoutUrl) // https://checkout.klappay.com/c/ch_abc123 — open this in a browser to // see exactly what the payer sees: a method picker if acceptedPayments // has more than one pair, the QR code for the chosen one, and the // status updating live (over the same SSE stream as /realtime) with no // refresh needed once they pay. ``` `checkoutUrl` is `null` if hosted checkout isn't enabled for your account — in that case there's nothing to redirect to, and you're expected to build the payment UI yourself from `address`/`acceptedPayments` (see [Payment QR codes](#payment-qr-codes) below for the piece that's still provided for you either way). Ask to have hosted checkout enabled if you want a page to point customers at without building one. ## The `Charge` shape What every read returns (`GET /charges/{id}`, `GET /charges`, and every webhook's `data` for a `charge.*` event): | Field | Notes | |---|---| | `id` | Klap-generated id (`ch_...`). | | `currency` | Always `USD` today — the only supported currency. | | `feePayer` / `feePercent` / `feeAmount` / `merchantAmount` | Who covers Klap's fee (`'merchant'` default, or `'payer'`), the fee percent frozen at creation, `amount * feePercent / 100`, and `amount - feeAmount` — what you net once the payout settles. | | `amount` | The requested target, set at creation, never changes. A legacy JSON number — see [Exact amounts](#exact-amounts) before doing arithmetic with it. | | `amountExact` | `amount` as a decimal string (up to 6 fractional digits, no scientific notation). Optional for compatibility with older API versions. | | `acceptedPayments` | Echoes exactly what was configured at creation; never changes afterward. | | `paidWith` | Every distinct `(token, network)` pair that has actually contributed a credited transfer so far. `[]`, not `null`, until the first arrives. | | `amountReceived` | Cumulative amount actually received on-chain. `null` until the first transfer. Can exceed `amount` — see `isOverpaid`. A legacy JSON number. | | `amountReceivedExact` | `amountReceived` as a decimal string (up to 18 fractional digits, no scientific notation). `null` until the first transfer; can exceed `amountExact`. Optional for compatibility with older API versions. | | `paymentUnavailable` | `true` while payment processing for this charge is temporarily paused — see [Payment temporarily unavailable](#payment-temporarily-unavailable). Optional for compatibility with older API versions. | | `isOverpaid` | `true` once `amountReceived` exceeds `amount`. Klap never auto-refunds the difference (see "Can the difference be refunded?" below) — this field is how you detect it happened. | | `status` | Payment progress from the payer's side only: `pending` → `partially_paid`/`confirmed` → (if it never fully pays) `expired`/`underpaid`. Every status is reached automatically — there's no merchant-initiated cancellation. | | `settlementStatus` | `'pending' \| 'completed' \| 'failed' \| null`. A **separate** step from `status` — `status: confirmed` means the transfer was detected on-chain; `settlementStatus: completed` means the merchant's wallet actually has the funds. `null` means no payout has been attempted yet. | | `settledAt` | When `settlementStatus` first reached `completed`. `null` otherwise. | | `environment` | `live` or `test`, matching the API key used. `test` runs on each network's testnet where one exists (see [Networks & tokens](/networks)) — real on-chain activity, never real money. | | `address` | The on-chain address the payer sends to, identical no matter which accepted pair they use. Predicted at creation; funds sent here go directly to the merchant. | | `checkoutUrl` | Klap's hosted checkout page for this charge, if configured for your deployment. `null` otherwise — build your own UI from `address`/`acceptedPayments`. | | `splitRecipients` | The resolved split, one `{ address, percent, label? }` per entry (`address`, not the `recipientId` you submitted). `[]` when none — see [`splitRecipients`](#splitrecipients). | | `swapAlternatives` | The `{ token, network }` pairs a payer can pay with *instead* of an accepted token, via [swap-to-pay](#paying-with-another-token-swap-to-pay). Always `[]` on a `test` charge. | | `txHash` | Hash of the most recent transfer detected for this charge. `null` until one is detected. | | `externalRef` / `source` / `metadata` / `redirectUrl` | Echo whatever you set at creation; `null` when unset. | | `apiKeyId` | Which of your API keys created the charge. `null` for charges created before this field existed. | | `createdAt` / `expiresAt` / `confirmedAt` / `lastActivityAt` | ISO timestamps. `confirmedAt` is `null` until `status` first reaches `confirmed`; `lastActivityAt` is when a transfer was last credited (or `createdAt` if none yet). | | `escrow` | `{ releaserAddress, releasedAt, refundedAt }` on an escrow charge, `null` otherwise — see [Holding funds with escrow](#holding-funds-with-escrow). | ### 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) carry the same values as decimal strings with no scientific notation — prefer them for arithmetic and payment preparation. Both are optional, only so responses from an older API version still validate; fall back to the numeric field when absent. The 18-digit ceiling exists because BNB Chain's USDC and 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. If you build transfers yourself, resolve a token's address and decimals together with `getTokenDeployment` from `@klappay/types` instead of assuming 6 (see [Networks & tokens](/networks#token)). ### Payment temporarily unavailable `paymentUnavailable: true` means Klap has paused payment processing for this charge. Its `status` and monetary fields keep showing where it stood, but don't rely on them to fulfill an order, and don't ask the payer for another transfer, until the flag clears. Check it before rendering payment instructions, alongside `status` and `settlementStatus`. The calls that would hand out payment details refuse the same way: `GET /charges/{id}/qrcode` and `POST /charges/{id}/quote` return `503 payment_temporarily_unavailable` for such a charge, as does `POST /charges` for a payment method that is paused at creation time. Treat it as retryable later, not as a rejected request. ## Listing charges ::: code-group ```bash [cURL] curl "https://api.klappay.com/v1/charges?status=confirmed&limit=20" \ -H "Authorization: Bearer klap_live_..." ``` ```ts [Node.js] const page = await klap.charges.list({ status: 'confirmed', limit: 20 }) // page.data, page.nextCursor, page.hasMore for await (const charge of klap.charges.listAll({ status: 'confirmed' })) { console.log(charge.id) } ``` ::: [→ Try `GET /charges` in the API Playground](https://api.klappay.com/#tag/charges/GET/charges) Cursor-paginated (`limit`/`cursor` → `{ data, nextCursor, hasMore }`) — `since` filters on `createdAt`, not on when the status last changed, so a poll-based recovery mechanism needs a `since` window at least as wide as the `expiresIn` used at creation, or it can miss a status change; pair polling with `GET /charges/{id}/events` for live updates instead of relying on `since` alone. The Node SDK's `listAll()` is an async generator that pages through everything automatically — every yielded `charge` is the same live-wrapped object `create()`/`get()` return. ## Observing a charge until it resolves This is the Node SDK's main reason to exist over calling the REST API directly — payments aren't request/response, they have state, and watching that state used to mean hand-rolling a polling loop yourself. ```ts try { const confirmed = await charge.waitForConfirmation({ timeoutMs: 60 * 60_000 }) console.log('Paid!', confirmed.amountReceived) } catch (err) { // ChargeExpiredError | ChargeUnderpaidError | WaitTimeoutError — see /errors } ``` `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. **How it works**: opens a live event stream first (`GET /charges/{id}/events`, Server-Sent Events — see [Real-time status](/realtime)) and resolves as soon as the matching status change is pushed. If the stream can't be opened or drops, the SDK transparently falls back to polling `GET /charges/{id}` for the rest of the timeout budget, with backoff — the public option names (`timeoutMs`, `pollIntervalMs`, `onStatusChange`) are transport-agnostic on purpose. [→ Try `GET /charges/{id}/events` in the API Playground](https://api.klappay.com/#tag/charges/GET/charges/{id}/events) ```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 }) ``` ### `waitForSettlement(options?)` A **separate** wait, for a **separate** question: `status: confirmed` means Klap detected the on-chain transfer, not that the money has reached the merchant's wallet yet — that's `settlementStatus`, a distinct, later step. ```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` on timeout. ### `waitFor(event, options?)` The general form, for the other charge events beyond confirm/settle: ```ts const partiallyPaid = await charge.waitFor('charge.partially_paid') const expired = await charge.waitFor('charge.expired') ``` Same underlying engine (SSE-first, polling fallback) 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 — pairs naturally with [Sandbox](/sandbox) triggers for integration testing. ### Cancelling a wait Same pattern as `fetch` — pass an `AbortSignal`, cancel with the matching `AbortController`. Works on `waitForConfirmation()`/ `waitForSettlement()`/`waitFor()` alike; rejects with the signal's own `reason` (an `AbortError` by default). ```ts const controller = new AbortController() cancelButton.onclick = () => controller.abort() await charge.waitForConfirmation({ signal: controller.signal }) ``` ## Audit trail ```bash [cURL] curl https://api.klappay.com/v1/charges/ch_abc123/timeline \ -H "Authorization: Bearer klap_live_..." ``` [→ Try `GET /charges/{id}/timeline` in the API Playground](https://api.klappay.com/#tag/charges/GET/charges/{id}/timeline) `GET /charges/{id}/timeline` (`getTimeline(id)` on the SDK) returns every event recorded against a charge in chronological order — the on-chain transaction detections, split-distribution events, and webhook dispatch/delivery/failure events — useful for debugging one specific payment without separate audit tooling. ## Payment QR codes `GET /charges/{id}/qrcode` (`getQrCode(id, query?)` on the SDK) returns a scannable [EIP-681](https://eips.ethereum.org/EIPS/eip-681) payment QR code as raw SVG, generated on demand and never stored. Encodes the charge's address and amount for one accepted pair — `query` (`{ token, network }`) is required only when `acceptedPayments` has more than one pair; with exactly one it's resolved automatically. Only available while the charge can still be paid — a `confirmed`/`expired`/ `underpaid` charge returns `409 charge_not_payable` instead. [→ Try `GET /charges/{id}/qrcode` in the API Playground](https://api.klappay.com/#tag/charges/GET/charges/{id}/qrcode) ## Paying with another token (swap-to-pay) A charge only ever accepts the stablecoins in its own `acceptedPayments`, but the payer often holds something else. `charge.swapAlternatives` lists every `(token, network)` pair Klap trusts as swap input for that charge, derived from the networks in `acceptedPayments`; a charge accepting USDC on Base lists `ETH`, `BTC`, `LINK` and `CBETH` on Base, for instance. It's a list of pairs rather than of tokens so the payer knows which chain to send on, and because the same asset resolves to a different contract per network (`BTC` is `cbBTC` on Base, `WBTC` on Optimism, `BTC.b` on Avalanche). Pass an entry straight through as `inputToken`/`inputNetwork` to `POST /charges/{id}/quote`. ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/charges/ch_abc123/quote \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "inputToken": "ETH", "inputNetwork": "base", "takerAddress": "0x1111111111111111111111111111111111111111" }' ``` ```ts [Node.js] const quote = await klap.charges.getQuote('ch_abc123', { inputToken: 'ETH', inputNetwork: 'base', takerAddress: '0x1111111111111111111111111111111111111111', // the payer's own wallet }) ``` ::: [→ Try `POST /charges/{id}/quote` in the API Playground](https://api.klappay.com/#tag/charges/POST/charges/{id}/quote) The swap is routed through [0x](https://0x.org)'s Swap API; Klap's endpoint is a thin proxy that returns a firm quote and never touches the payer's private key. 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 nothing about detection or settlement changes. **The merchant always receives the charge's full remaining amount** (`quote.outputAmount`, i.e. `amount - amountReceived`), whatever the payer sent. Klap charges the **payer** a separate fee on top (`quote.fees.klappayFee`, in output-token units, separate from the charge's own `feePercent`; read it off each quote rather than hardcoding a rate), plus 0x's own protocol fee when it applies (`quote.fees.zeroExFee`, `null` when the pair isn't one 0x charges on). Both are already folded into `quote.inputAmount` (the ceiling the payer must have available; any unused input is swapped back and returned to the payer in the same transaction) and neither ever reduces `outputAmount`. When the remaining balance can't be expressed in the output token's smallest unit, the requested amount rounds up by that unit, which can leave a tiny overpayment. `outputAmount`, `inputAmount` and `fees.klappayFee`/`fees.zeroExFee` are legacy JSON numbers. `outputAmountExact`, `inputAmountExact`, `fees.klappayFeeExact` and `fees.zeroExFeeExact` carry the same values as decimal strings (up to 18 fractional digits, no scientific notation; `zeroExFeeExact` is `null` when no 0x fee applies). They're optional only for compatibility with older API versions; prefer them for arithmetic. **Two client-side flows, depending on `inputToken`:** * **Native currency** (`ETH`, `BNB`, `POL`, `AVAX`): sign and send `quote.transaction` (`{ to, data, value }`) as-is. `quote.permit2` is `null`. `POL` replaced `MATIC` as Polygon's native ticker after its 2024 migration; it's the same asset, but an integration that reads the literal `'MATIC'` off `swapAlternatives` or `inputToken` will break. * **ERC-20** (`BTC`, `LINK`, plus chain-specific `ARB`, `OP`, `CBETH`): `quote.permit2` is an object. Sign that EIP-712 message first and append the signature to `quote.transaction.data` before sending. Check whether `permit2` is non-null rather than special-casing token names. `AltTokenSchema` in `@klappay/types` is the closed set of swap inputs (`ETH`, `BNB`, `POL`, `AVAX`, `BTC`, `LINK`, `ARB`, `OP`, `CBETH`), and the per-network trust list decides which network supports which: `OP` only on Optimism, `ARB` only on Arbitrum, `CBETH` only on Base, `BNB` is the only input on BNB Chain, and Arc currently trusts only `BTC` (Circle's own BTC-backed token). TRON has none. Always read `charge.swapAlternatives` instead of hardcoding it. `quote.expiresAt` is a rough guide for a UI countdown only. The price is enforced on-chain by the swap transaction itself (a signed Permit2 deadline, or a minimum-output check on native sells), so submitting late either reverts or is re-quoted, never silently executes at a stale rate. Quotes aren't stored by Klap. Constraints and errors: * Requires `charges:write` (not just `charges:read`): each call fetches a live firm quote, so it's rate-limited **per charge**, on top of the general per-key limit (`429 rate_limited`). * `422 swap_test_environment_unsupported` on every `test`-environment charge: 0x has no testnet support, so `swapAlternatives` is always empty there and no combination could succeed. * `422 token_not_supported` when `inputToken`/`inputNetwork` isn't in the charge's `swapAlternatives`. * `409 charge_not_payable` when the charge is already `confirmed`/`expired`/`underpaid`, or has no remaining balance. * `503 payment_temporarily_unavailable` when `paymentUnavailable` is set on the charge or its payment method is paused — retry later, see [Payment temporarily unavailable](#payment-temporarily-unavailable). * `503 swap_unavailable` when swap isn't available on the deployment, and `503 swap_quote_failed` when the upstream quote failed, often for insufficient liquidity (safe to retry, including with a different `inputToken`). * `404 charge_not_found`, `403 insufficient_scope` and `401` as on any other route. ## Forcing an on-chain re-check A charge normally updates itself — usually within about a minute of the payment landing on-chain. `POST /charges/{id}/check` (`check(id, input?)` on the SDK) skips that wait and re-runs the lookup immediately: ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/charges/ch_abc123/check \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "txHash": "0x1234567890123456789012345678901234567890123456789012345678901234", "network": "base" }' ``` ```ts [Node.js] const charge = await klap.charges.check('ch_abc123', { txHash: '0x1234...', network: 'base', }) ``` ::: [→ Try `POST /charges/{id}/check` in the API Playground](https://api.klappay.com/#tag/charges/POST/charges/{id}/check) The body is optional. With no `txHash`, Klap looks for any matching transfer; pass `txHash` *and* `network` together (both or neither) and it verifies that one transaction directly, so it resolves faster. This is the natural call to make right after your own frontend sends a wallet or swap transaction and already knows the hash. It never trusts the caller: the claim is re-verified with the same independent on-chain lookup reconciliation already uses, and the charge only changes state if a real matching transfer is found. Rate-limited to once every 10 seconds per charge, shared across every caller — to follow a payment to its resolution, use the [live stream](/realtime) rather than polling this. Two fields come back on the checked charge that a plain `GET` doesn't carry: * `transactionSender` — the checked transaction's own signer. This stays the payer's real wallet even when the payment routed through a swap or aggregator, unlike the credited transfer's sender, which in that case is a router or pool contract. Populated only when `txHash`/`network` was passed and a matching receipt was found; `null` otherwise. * `confirmationProgress` — non-null while a detected transfer still hasn't reached its network's required confirmation depth, carrying `{ network, blocksSeen, blocksRequired, percent }`. It's the same payload the [`confirmation_progress` SSE event](/realtime) pushes live, so a "confirming payment" progress bar can be driven from either source; `null` once the transfer is credited. ## Releasing or refunding an escrow An [escrow-configured charge](#holding-funds-with-escrow) holds its funds in a dedicated Safe after confirmation instead of distributing them. Two endpoints move that balance, and a charge can use exactly one of them, exactly once: | Endpoint | SDK | Where the money goes | |---|---|---| | `POST /charges/{id}/release` | `charges.release(id, input)` | The split address, which then distributes merchant/platform shares exactly as a non-escrow charge would | | `POST /charges/{id}/refund` | `charges.refund(id, input)` | Back to the address that funded the charge — no distribution follows, the full balance goes to the payer | ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/charges/ch_abc123/release \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "signature": "0x…" }' ``` ```ts [Node.js] const released = await klap.charges.release('ch_abc123', { signature: '0x…', }) // or, to send it back to the payer instead: const refunded = await klap.charges.refund('ch_abc123', { signature: '0x…', }) ``` ::: `signature` must be a valid Safe transaction signature from the charge's `escrow.releaserAddress`, authorizing a transfer of the escrow's **full live balance** — the amount actually received, not whatever was fixed at creation, since under- and overpayment both change it. The Safe contract verifies the signature on-chain before anything moves; Klap never takes it on faith and holds no key that could move the funds on its own. Both require the `charges:write` scope. The second call always loses: whichever ran first rejects the other with `409 escrow_already_released` or `409 escrow_already_refunded`. Other failures to handle: * `400 escrow_invalid_signature` — the signature doesn't authorize this transfer from `escrow.releaserAddress`. * `409 escrow_release_in_progress` / `escrow_refund_in_progress` — another request for the same charge is still being processed; safe to retry shortly. * `422 escrow_not_configured` (not an escrow charge), `escrow_nothing_to_release` / `escrow_nothing_to_refund` (zero balance), or `escrow_multiple_payment_pairs` (the payer paid across more than one `(token, network)` pair, which escrow doesn't support). * `503 rpc_unavailable` or `503 payment_temporarily_unavailable` — transient; retry later (see [Payment temporarily unavailable](#payment-temporarily-unavailable)). A completed release fires `charge.escrow_released`, a completed refund fires `charge.escrow_refunded` — see [Webhooks](/webhooks). ## Can an overpayment be refunded automatically? Not on a normal charge, and it's structural rather than a matter of effort. The charge address is an immutable on-chain split with the payer never one of its recipients — there's no contract call that can send funds back to them, and Klap never custodies the funds to refund from in the first place. `amountReceived` (and `isOverpaid`) make an over/underpayment visible so the merchant (or the product built on this API) can decide the business remedy — that decision needs context this API doesn't have. An [escrow charge](#holding-funds-with-escrow) is the deliberate exception, and a different mechanism rather than a loophole in that one: its funds sit in a Safe that has not distributed yet, so a signature from the releaser can still send the whole balance back to the payer. That's an all-or-nothing return of an undistributed balance, decided before settlement — not a partial refund of money that has already been paid out. ## Types `CreateChargeSchema` / `CreateChargeInput` (`@klappay/types`) is the body of `POST /charges` — type a pre-parse request object as `CreateChargeRequest` instead (`currency` optional there; the schema defaults it to `"USD"`, the only currently-supported value): ```ts import { CreateChargeSchema, type CreateChargeRequest } from '@klappay/types' const request: CreateChargeRequest = { amount: 49.9, expiresIn: 1800, acceptedPayments: [{ token: 'USDC', network: 'base' }], } CreateChargeSchema.parse(request) // throws on anything invalid ``` `ChargeSchema` / `Charge` is the read shape documented in the table above. `ListChargesSchema` / `ListChargesInput` is `GET /charges`'s query params; `PaginatedChargesSchema` / `PaginatedCharges` is its response (`{ data: Charge[], nextCursor: string | null, hasMore: boolean }`) — the same shared cursor-pagination pattern every list endpoint in this API uses (`PaginationQuerySchema` + `paginatedSchema(itemSchema)`). `TimelineEventSchema` / `TimelineEvent` is one entry from `GET /charges/{id}/timeline` — `type` is one of `charge.created`/`charge.expired`/`transaction.detected`/ `split.distributed`/`webhook.dispatched`/`webhook.delivered`/ `webhook.failed`, and which other fields are present depends on `type`. `TransactionSourceSchema` / `TransactionSource` (`'contract_watcher' | 'tron_watcher' | 'reconciliation_job' | 'sandbox'`) says how a transfer was detected: `contract_watcher` means it was detected in real time on an EVM network, `tron_watcher` means the same on TRON, `reconciliation_job` means the periodic re-check caught it, and `sandbox` means it was simulated in the sandbox environment. `EscrowConfigSchema` / `EscrowConfig` is `create()`'s `escrow` field, and `ChargeFeePayerSchema` / `ChargeFeePayer` is the `'merchant' | 'payer'` union behind `feePayer`. `ReleaseEscrowRequestSchema` / `ReleaseEscrowRequest` and `RefundEscrowRequestSchema` / `RefundEscrowRequest` are the bodies of `POST /charges/{id}/release` and `POST /charges/{id}/refund` — both just `{ signature }`, kept as two schemas because the two operations stay independently versionable. `CheckChargeRequestSchema` / `CheckChargeRequest` is `POST /charges/{id}/check`'s optional body, and `CheckChargeResponseSchema` is its response: a `Charge` plus the `transactionSender` and `confirmationProgress` fields a plain read doesn't carry. `ConfirmationProgressSchema` / `ConfirmationProgress` is that progress payload on its own — the same shape the `confirmation_progress` SSE event pushes. `SplitRecipientInputSchema` (`recipientId`, `percent`, optional `label`) and `SplitRecipientSchema` (resolved `address`, `percent`, optional `label`) are the request and response shapes of [`splitRecipients`](#splitrecipients), capped at `CHARGE_SPLIT_RECIPIENTS_MAX` (5). `CreateSwapQuoteSchema` / `CreateSwapQuoteInput` is `POST /charges/{id}/quote`'s body (`inputToken`, `inputNetwork`, `takerAddress`), `SwapQuoteSchema` / `SwapQuote` its response, `AltTokenSchema` / `AltToken` the swap-input vocabulary, and `SwapAlternativeSchema` / `SwapAlternative` one entry of `charge.swapAlternatives`. ## See also * [Webhooks](/webhooks) — the full `charge.*` event vocabulary and payload envelope. * [Sandbox](/sandbox) — triggering any charge state transition without a real on-chain transfer, to test the flows above end-to-end. * [Recipients](/recipients) — registering the addresses `splitRecipients` references. * [Networks & tokens](/networks) — the live `(token, network)` matrix `acceptedPayments` validates against. * [Real-time status](/realtime) — the SSE mechanism `waitForConfirmation()` and friends use under the hood. --- --- url: https://docs.klappay.com/webhooks.md --- # Webhooks Outbound notifications — Klap pushes events to a URL you register, instead of you polling for state changes. Every event belongs to exactly one of two categories: **`payments`**, the 10 events that track a charge through its lifecycle, and **`webhooks`**, 3 meta-events about the health of your own webhook endpoint (so you can notice a broken integration without polling `GET /webhooks/{id}/deliveries` yourself). Both categories share one delivery engine, one signing scheme, one retry schedule, and one SSRF guard on the registered URL — there's no separate code path for "payment events" vs. "meta events." Delivery is **at-least-once, not exactly-once**: a delivery that times out or errors after your endpoint already processed it (a slow response right at the edge of the 8s timeout, for instance) gets retried anyway, so your handler will occasionally see the same `id` twice. Design handlers to be idempotent on the payload's `id`, and always verify the signature before trusting anything in the body — the endpoint you register is a public HTTPS URL, and anyone who finds it can send a structurally valid POST to it. Signature verification is what distinguishes a real Klap delivery from that. The charge events themselves are driven by Klap's own on-chain transfer detection running underneath `POST /v1/charges` — fast enough that `charge.confirmed` is typically observable within the same block window a transfer lands in. That detection machinery is an internal implementation detail, not something you configure; everything on this page is about receiving what it produces, not building it. ## Subscribing ::: code-group ```bash [cURL] curl https://api.klappay.com/v1/webhooks \ -X POST \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks/klap", "eventCategories": ["payments"] }' ``` ```ts [Node.js] const webhook = await klap.webhooks.create({ url: 'https://your-server.com/webhooks/klap', eventCategories: ['payments'], }) console.log(webhook.secret) // whsec_... — returned in full ONLY this once, store it now ``` ::: [→ Try `POST /webhooks` in the API Playground](https://api.klappay.com/#tag/webhooks/POST/webhooks) The body is `CreateWebhookSchema` (`@klappay/types`). At least one of `events` or `eventCategories` is required — a `.refine()` on the schema enforces it, not just documentation, so omitting both is a `400` before any URL validation even runs. Three ways to select what you receive, combinable: ```ts // individual events await klap.webhooks.create({ url, events: ['charge.confirmed', 'charge.overpaid'] }) // a whole category — new events added to it later arrive automatically, // no need to touch the subscription again await klap.webhooks.create({ url, eventCategories: ['payments'] }) // everything, minus explicit exclusions await klap.webhooks.create({ url, events: ['*'], excludeEvents: ['charge.expired'] }) ``` * **`events`** — any of the 13 individual event types, or `"*"` for every event (combine with `excludeEvents` to opt back out of specific ones under the wildcard). * **`eventCategories`** — up to 2 (`'payments'` and/or `'webhooks'`). The subscription rule itself is persisted, not expanded into a frozen list of event types at creation time — matching happens per-dispatch against the live rule, so a category subscription created today automatically starts receiving any event type added to that category later, with nothing for you to update. * **`excludeEvents`** — opt back out of specific events even under a `"*"` or category subscription. * **`url`** — max 2048 chars, must be HTTPS, and must resolve to a public address. Private/internal IPs are rejected at creation *and* re-checked before every single delivery attempt (the same address could resolve differently later via DNS rebinding), which is also why a delivery never follows a redirect — both are the same SSRF guard, applied at the one place all deliveries funnel through. `environment` (`'live' | 'test' | null`) is set automatically from whichever API key's environment created the webhook — there's no request field for it, and it can't be chosen or changed afterward. Every `payments` and `webhooks`-category event also carries an environment (a charge event carries the charge's own; a delivery-health event carries the environment of the webhook it's about), and is only delivered to a webhook whose `environment` matches — a `test`-key webhook never receives a real `live` charge event, and a `live`-key webhook never receives a sandbox-triggered one. `environment: null` means the webhook predates this field and keeps receiving both, same as always. Register one webhook per environment (two different API keys) if `test` and `live` traffic should land on different endpoints or handlers. Creating requires the `webhooks:write` scope. Registering more than 20 active webhooks for one organization is rejected outright — `422 webhook_limit_reached` — a hard cap, not a soft warning; delete an unused one first if you're at the limit. The response — `Webhook` — is the **only** place `secret` (the HMAC signing key, `whsec_...`) is ever returned in full. Every later `GET /webhooks` returns `hint` instead: a truncated, safe-to-display form (e.g. `whsec_...ab12`) on the same shape minus `secret`. If a secret leaks, rotate it (see "Managing webhooks" below) rather than deleting and recreating the webhook — deleting loses the id and the delivery history. ## The full event vocabulary Every event Klap can send is one `WebhookEventTypeSchema` value, built from two sub-enums (`ChargeWebhookEventTypeSchema` for `payments`, `WebhookDeliveryEventTypeSchema` for `webhooks`) — `EVENT_CATEGORY_MAP` is the single source of truth mapping one to the other, generated from the enums rather than hand-duplicated, so every event belongs to exactly one category. ### `charge.*` — `payments` category Every one of these carries the full `Charge` object as `data`. | Event | Fires when | |---|---| | `charge.created` | The charge was created. | | `charge.partially_paid` | Cumulative transfers cover part, not all, of the target amount. | | `charge.confirmed` | Cumulative transfers meet or exceed the target amount — the payment was **detected on-chain**. | | `charge.overpaid` | Fires *alongside* `confirmed`/`partially_paid` whenever the cumulative amount exceeds the target — an additional signal, never a standalone state. | | `charge.expired` | A `pending` charge passed its deadline with zero transfers received. | | `charge.underpaid` | A `partially_paid` charge passed its deadline still short of the full amount. | | `charge.settled` | The payout to the merchant's wallet actually completed on-chain. | | `charge.settlement_failed` | Klap's own payout attempt failed and its retries were exhausted (the charge is still watched — a later out-of-band settlement still fires `settled`). | | `charge.escrow_released` | An [escrow charge](/charges#releasing-or-refunding-an-escrow)'s balance was released on-chain to the split address, where normal distribution takes over. | | `charge.escrow_refunded` | An [escrow charge](/charges#releasing-or-refunding-an-escrow)'s balance was sent back on-chain to the address that funded it. No distribution follows. | `confirmed` vs. `settled` is a real distinction, not two names for the same thing: `confirmed` means the payment was detected on-chain, `settled` means the merchant's wallet actually received the funds — a separate, later step (`Charge.settlementStatus`, see [Charges](/charges)). Subscribe to `confirmed` if you only need "will I get paid," or `settled` if you need "has the money actually arrived." ### `webhook.*` — `webhooks` category Meta-events about the health of your own registered endpoint — data is `{ webhookId, url, failureRatio? }` for all three (`failureRatio` only on `endpoint_unhealthy`). | Event | Fires when | |---|---| | `webhook.delivery_failed` | One specific delivery exhausted all 5 retry attempts. | | `webhook.delivery_recovered` | A delivery to a webhook previously marked unhealthy just succeeded. | | `webhook.endpoint_unhealthy` | A webhook's trailing-24h failure ratio crosses 20% — fired once per unhealthy streak, not re-fired on every subsequent failure. | These three never generate further meta-events about their own delivery outcome — a webhook subscribed to the `webhooks` category that itself starts failing doesn't produce an endless delivery-failed-about-delivery-failed stream. They're also excluded from the 24h failure-ratio calculation itself, so a self-referential failure pattern can't compound a webhook's own unhealthy determination. There's no sandbox trigger for these three — they're derived from real delivery attempts, not something you'd need to fabricate; see "Testing" below. ### The payload envelope Every delivery's body — `WebhookPayloadSchema` / `WebhookPayload` — is the same four-field shape regardless of event: ```ts { id: string, event: WebhookEventType, createdAt: string, data: unknown } ``` `data` is typed `unknown` at the schema level on purpose — its real shape depends on `event`, which one flat schema can't express, and Klap controls both producer and consumer of this shape, so a per-event runtime schema for `data` wasn't worth the ongoing maintenance cost. `id` is also sent as the `X-Klappay-Delivery` header — this is the value to dedupe on if you want protection against at-least-once redelivery beyond what idempotent handling already gives you. For the *typed* version, `TypedWebhookPayload` (`@klappay/types`) is a discriminated union over `event` — same pattern as Stripe's `Event.data.object` — so `data` narrows automatically in a `switch`/`if` on `event`, no cast needed: ```ts import type { TypedWebhookPayload } from '@klappay/types' function handle(payload: TypedWebhookPayload) { if (payload.event === 'charge.confirmed') { payload.data.amountReceived // typed as Charge } else if (payload.event === 'webhook.endpoint_unhealthy') { payload.data.failureRatio // typed as WebhookHealthEventData } } ``` `WebhookEventDataMap` is the underlying `{ event: dataShape }` mapping this is built from — every `charge.*` event's `data` is a full `Charge`; every `webhook.*` event's `data` is the small `WebhookHealthEventData` shape above. ## Verifying and parsing an inbound webhook **Always verify the signature before trusting a webhook payload** — your endpoint is a public URL, and anyone who can reach it can send a structurally-valid request otherwise. Every delivery carries three headers: | Header | Value | |---|---| | `X-Klappay-Signature` | `t=,v1=` | | `X-Klappay-Event` | The event type, e.g. `charge.confirmed` | | `X-Klappay-Delivery` | The delivery's `id`, same value as the body's `id` | The signature is `HMAC-SHA256` over `${timestamp}.${rawBody}` — Stripe- style, not just the raw body — computed with the webhook's own secret. Signing the timestamp alongside the body is what makes replay protection possible at all: a signature over the body alone would let anyone who captured one legitimate delivery (a leaked proxy log, a compromised intermediary) re-POST the exact same body+signature indefinitely and have it validate as new. Binding the timestamp in means a captured delivery only replays successfully within whatever tolerance window the verifier enforces. ### Node.js — `@klappay/node` ```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 } — TypeScript // already knows this here, no cast needed 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 } // InvalidWebhookSignatureError — reject, don't process res.sendStatus(400) } }) ``` `constructEvent(rawBody, signatureHeader, secret, options?)` does three things in one call: verifies the HMAC with a timing-safe comparison (never implement this comparison yourself with `===` — timing attacks against a naive string compare are a real risk), checks the delivery is recent (`options.toleranceSeconds`, default `300` — 5 minutes), and parses the body into `TypedWebhookPayload`. It throws `InvalidWebhookSignatureError` if the HMAC doesn't match, or `WebhookTimestampToleranceError` if the HMAC is valid but the timestamp falls outside the tolerance window — a strong signal of a replayed delivery, worth handling distinctly from an outright invalid signature. If you only want the boolean check without parsing (note: HMAC only, this does **not** check timestamp tolerance): ```ts const isValid = klap.webhooks.verifySignature(rawBody, signatureHeader, secret) ``` **Getting the raw body**: most Node frameworks parse the request body into an object before your handler runs, which is too late — the signature is computed over the exact raw bytes, not a re-serialized object that may differ in whitespace or key order. In Express, mount `express.raw({ type: 'application/json' })` on this specific route (not `express.json()`), or capture the raw body in middleware ahead of the JSON parser. ### Verifying without the Node SDK The scheme is plain HMAC-SHA256, so any language can implement it in a few lines: 1. Split the `X-Klappay-Signature` header on `,` and pull out `t=` and `v1=`. 2. Compute `HMAC-SHA256(secret, "${t}.${rawBody}")`, hex-encoded. 3. Compare the result to `v1` with a **constant-time** comparison (`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python, `hash_equals` in PHP — never a plain `==`/`===`). 4. Separately, check that `t` is within your tolerance window (5 minutes is a reasonable default) of the current time. This is a distinct check from step 3 — a valid HMAC on an old timestamp is still a rejection, since the timestamp only closes the replay gap if something actually checks its age. 5. Only after both checks pass, parse `rawBody` as JSON into the envelope shape (`{ id, event, createdAt, data }`). The tolerance window mitigates replay but isn't a guarantee — a replay sent *within* the window still passes both checks. For belt-and-suspenders protection against that narrower case, deduplicate by the payload's own `id` on your side, especially for a handler whose effect isn't naturally idempotent (e.g. incrementing a counter rather than an upsert keyed on `id`). ## 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 // resolves to undefined — the endpoint answers 202 with no body ``` `@klappay/node` before 5.1.4 tried to parse that empty `202` body as JSON and threw `Unexpected end of JSON input` even though the retry had been accepted — upgrade if `retryDelivery()` is the one call in your integration that "fails" on success. | Method | Path (→ open in Playground) | Scope | Notes | |---|---|---|---| | `GET` | [`/webhooks`](https://api.klappay.com/#tag/webhooks/GET/webhooks) | `webhooks:read` | Lists the caller key's webhooks, `hint` instead of `secret`. Stays **unpaginated** — it's hard-capped at 20 per org, so a page wouldn't buy you anything. | | `DELETE` | [`/webhooks/{id}`](https://api.klappay.com/#tag/webhooks/DELETE/webhooks/{id}) | `webhooks:write` | Stops future deliveries. Past delivery history is kept and still queryable afterward. `404 webhook_not_found` if the id doesn't exist (or belongs to the wrong environment — see below). | | `POST` | [`/webhooks/{id}/rotate-secret`](https://api.klappay.com/#tag/webhooks/POST/webhooks/{id}/rotate-secret) | `webhooks:manage_secret` | Generates a fresh secret, replacing the old one immediately — no further delivery validates against the old one. Returns the new secret exactly once, same as creation. Kept as its own scope, separate from `webhooks:write`, since it's the highest-trust action on this resource (it returns a plaintext secret). | | `GET` | [`/webhooks/{id}/deliveries`](https://api.klappay.com/#tag/webhooks/GET/webhooks/{id}/deliveries) | `webhooks:read` | Delivery attempts, newest first: `status` (`pending`/`delivered`/`failed`), `attempts`, `responseCode` (`null` means every attempt failed to connect at all, not just a non-2xx), `nextRetryAt`, `deliveredAt`. Cursor-paginated, `limit` 1–100 (default 20). | | `POST` | [`/webhooks/{id}/deliveries/{deliveryId}/retry`](https://api.klappay.com/#tag/webhooks/POST/webhooks/{id}/deliveries/{deliveryId}/retry) | `webhooks:write` | Immediately attempts redelivery, regardless of the normal retry schedule or whether retries were already exhausted. Returns `202` and doesn't wait for the attempt to finish — poll `GET .../deliveries` for the outcome. | Both endpoints and the underlying management methods are scoped by `environment` the same way delivery is: a `test`-key call can only see or act on `test`-environment (or legacy `null`-environment) webhooks, never a tenant's `live` ones, and vice versa — the same rule that governs which webhook an event is delivered to also governs who can manage it. `listDeliveries()`/`GET /webhooks/{id}/deliveries` use the same shared `{ limit, cursor }` → `{ data, nextCursor, hasMore }` cursor-pagination shape as every other list endpoint in the API — see [Charges — Listing and pagination](/charges#listing-charges) for the pattern in full; always pass the previous response's `nextCursor` back verbatim rather than constructing one, and stop once `hasMore` is `false`. `listAllDeliveries()` pages through automatically if you'd rather not manage the cursor yourself. ### Delivery mechanics, briefly `POST` to your `url`, `Content-Type: application/json`, an 8s timeout per attempt, no redirect following. A 2xx response marks the delivery `delivered`; anything else — including a timeout or connection error — schedules a retry on a fixed schedule: `0, 5min, 30min, 2h, 24h` after the first attempt, 5 attempts total spanning roughly 24 hours. After the 5th failed attempt the delivery becomes `failed` and stops retrying automatically — `webhook.delivery_failed` fires at that point if you're subscribed to it, and `POST .../retry` is how you push it again manually once your endpoint is back. ## Testing Trigger any `payments`-category event on a `test`-environment charge without a real on-chain transfer — see [Sandbox](/sandbox) for the full mechanism (`POST /sandbox/charges/{id}/trigger`, or the typed `klap.sandbox.confirm()`/`.partiallyPay()`/etc. wrappers). For receiving those triggered events on `localhost` without deploying a public endpoint or registering a real webhook, [`GET /webhooks/listen`](https://api.klappay.com/#tag/webhooks/GET/webhooks/listen) opens a live Server-Sent Events stream of everything your organization dispatches — this is what `@klappay/cli`'s `klap listen --forward-to` consumes; see [Real-time status](/realtime) for the stream mechanics. To exercise just your handler's signature verification and payload parsing with no login at all, `klap webhooks trigger ` signs a fake webhook and delivers it straight to your local URL (full details at [cli.klappay.com](https://cli.klappay.com)). ## See also * [Charges](/charges) — the `Charge` shape carried by every `charge.*` event's `data`, and the cursor-pagination pattern deliveries share. * [Sandbox](/sandbox) — triggering any charge event without a real on-chain transfer. * [Errors](/errors) — the full error code reference, including `InvalidWebhookSignatureError`/`WebhookTimestampToleranceError` and the REST error codes referenced above. * [Real-time status (SSE)](/realtime) — `GET /webhooks/listen`'s stream mechanics, shared with the charge- and distribution-event streams. --- --- url: https://docs.klappay.com/sandbox.md --- # Sandbox Test your integration end to end — any charge state transition, any webhook, any partial/over/underpayment scenario — without waiting for a real on-chain transfer or a real block time. The testnets `test`-environment charges actually settle on still take real block time to confirm a real transfer; sandbox triggers skip that entirely, pushing a charge straight to the target state and dispatching the exact webhook/SSE update a real payment would have produced. Scoping is enforced on **both** sides of the request, not just one: the API key must be `test`-environment (`klap_test_...`) *and* carry the `sandbox:trigger` scope, and the charge being triggered must itself be `test`-environment. A `live` key gets `403 forbidden` outright — sandbox triggers aren't reachable from `live` at all, scope or no scope. A `test` key missing the scope gets `403 insufficient_scope`. A `test` key that's otherwise entitled but pointed at a `live` charge (not possible within one organization's own charges, but the check exists regardless) gets `403 charge_not_test_environment`. Nothing here ever touches the blockchain: `charge.settled`/`charge.settlement_failed` simulate the payout outcome directly instead of calling the real distribution path, so a sandbox run never spends real gas, even on testnet. ## Triggering an event One REST primitive backs everything: `POST /sandbox/charges/{id}/trigger`, which can push a `test` charge into **any** state transition. The matching webhook fires and any open SSE stream updates exactly as it would for a real payment — your handler code can't tell the difference from the outside. ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/sandbox/charges/ch_abc123/trigger \ -H "Authorization: Bearer klap_test_..." \ -H "Content-Type: application/json" \ -d '{ "event": "charge.confirmed" }' ``` ```ts [Node.js] 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) ``` ::: [→ Try `POST /sandbox/charges/{id}/trigger` in the API Playground](https://api.klappay.com/#tag/sandbox/POST/sandbox/charges/{id}/trigger) The response is the full updated `Charge` object — same shape as `GET /charges/{id}` — so you can assert on `status`, `amountReceived`, etc. directly off the trigger call instead of re-fetching. `event` is required, `amount` is optional and only meaningful for two of the seven triggerable events: | Method | REST `event` | `amount` | |---|---|---| | `klap.sandbox.confirm(chargeId)` | `charge.confirmed` | — full payment | | `klap.sandbox.partiallyPay(chargeId, amount?)` | `charge.partially_paid` | optional, must be less than the charge amount — defaults to half of it | | `klap.sandbox.overpay(chargeId, amount?)` | `charge.overpaid` | optional, must be greater than the charge amount — defaults to 1.5x it | | `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.trigger(chargeId, event, amount?)` | any of the above | the general form the named methods call internally | When `amount` applies, it's bound by the same ceiling as everywhere else in the API (up to 6 decimal places, `0 < amount ≤ 999_999_999_999`, exclusive of zero) — passing an out-of-range or wrong-direction amount (e.g. more than the charge total for `partially_paid`, or less than it for `overpaid`) is `400 invalid_amount`, not a silently-clamped value. It's ignored for every other event. Every event also has a real precondition on the charge's *current* state (e.g. `charge.underpaid` requires the charge to already be `partially_paid`) — triggering one out of order is `422 invalid_trigger_state`, not a silent no-op. `charge.overpaid` isn't a standalone outcome — it always dispatches `charge.confirmed` alongside it, matching what a real overpayment does 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, and there's no trigger for `charge.created` (a charge already exists by the time you have an id to trigger against) or for `charge.escrow_released`/ `charge.escrow_refunded` — those are reached by actually calling [release or refund](/charges#releasing-or-refunding-an-escrow) on a `test`-environment escrow charge. A charge id that doesn't exist (or belongs to a different organization) is `404 charge_not_found`. On the types side, the request body is `SandboxTriggerSchema` / `TriggerableChargeEvent` (`@klappay/types`) — `event` is derived with `.exclude(['charge.created', 'charge.escrow_released', 'charge.escrow_refunded'])` from the same charge-event enum [Webhooks](/webhooks) documents, not a second hand-maintained list, so a future charge event added there is automatically included here with no second place to remember to update: ```ts import { SandboxTriggerSchema, type TriggerableChargeEvent } from '@klappay/types' SandboxTriggerSchema.parse({ event: 'charge.confirmed' }) SandboxTriggerSchema.parse({ event: 'charge.partially_paid', amount: 20 }) ``` `POST /sandbox/charges/{id}/trigger` is the only sandbox trigger. ## A full integration test Combine a trigger with `charge.waitFor()` to test your own webhook-handling code end-to-end, for any event, with no real money and no 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) ``` Note the `Promise.all` shape in the first example: `waitFor()` has to be armed *before* (or at the same instant as) the trigger fires, since it's listening for a state change that could otherwise happen before the listener is attached. See [Charges](/charges#observing-a-charge-until-it-resolves) for `waitFor()`/`waitForConfirmation()`/`waitForSettlement()` in depth — same wait engine, SSE-first with a polling fallback, used here and against real payments alike. ## Testing your webhook handler without deploying anything Pair sandbox triggers 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, computed the same way a production delivery's would be. See [`@klappay/cli`](https://www.npmjs.com/package/@klappay/cli)'s own README for the full forwarding mechanism (it also has `klap webhooks trigger `, which signs and delivers a fake webhook locally without logging in, and `klap charges watch `, which tails a charge's status; both documented at [cli.klappay.com](https://cli.klappay.com)), and [Webhooks](/webhooks) for verifying that signature in your handler — sandbox-triggered deliveries aren't exempt from signature verification, which is the point: your handler is exercised exactly as it would be in production, deployed or not. The same triggers are available to an AI assistant through the [MCP server](/mcp)'s `sandbox_trigger` tool ("mark that charge as confirmed, then show me its timeline") — test environment only; it's never registered against a live key. ## See also * [Charges](/charges) — the state machine sandbox triggers move a charge through, and `waitFor()`/`waitForConfirmation()`. * [Webhooks](/webhooks) — the full event vocabulary `event` draws from, and how to verify what a trigger dispatches as a real, signed webhook delivery. --- --- url: https://docs.klappay.com/metrics.md --- # Metrics Ad-hoc analytics over your own organization's payment data — pick a resource (`charges`/`transactions`/`distributions`), an aggregation, optional `groupBy` (including a single time-bucketed entry), and filters. The response rows are shaped by that query, not a fixed report — the same spirit as a log/observability platform's query API (Datadog, Honeycomb, Axiom), not a free-form query language. Every filterable/groupable/ aggregatable field is an explicit, typed literal enum — `MetricsQuerySchema` in `@klappay/types` — checked both by TypeScript at compile time and by the server at request time, so a query can only ever touch a field the contract explicitly declares queryable. ## Why this is a live query, not a snapshot report Metrics are computed live at request time from your charges, transactions, and distributions — there's no pre-aggregated snapshot behind this endpoint, and no second copy of the data that can drift from the source. The tradeoff is query cost instead of staleness — every request re-scans the underlying rows for your `dateRange`, which is exactly why `dateRange` is required and capped (see below) instead of optional-defaulting-to- unbounded: nothing here can silently become an unscoped table scan. `amount` vs `amountReceived` matters here specifically: when aggregating revenue, read `amountReceived` (the real cumulative amount actually received on-chain) rather than `amount` (the requested target) — an overpay, or a charge paid in installments across more than one transfer, only reports true revenue through `amountReceived`. `feePercent` follows the same "frozen at the source" discipline — it's set once on the charge at creation and never rewritten, so aggregating it always reflects what a charge's fee actually was at the time, immune to a fee change made next month silently rewriting last month's numbers. See [Charges](/charges) for the full state machine behind `amount`/`amountReceived`. ## Querying — `POST /metrics/query` ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/metrics/query \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "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" }] }' ``` ```ts [Node.js] 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: 2, truncated: false } ``` ::: [→ Try `POST /metrics/query` in the API Playground](https://api.klappay.com/#tag/metrics/POST/metrics/query) `klap.metrics.query()` — also available standalone as `createMetricsClient` from `@klappay/node/metrics` (see [Tree-shaking](/sdk/tree-shaking)) — is a thin wrapper: it doesn't pre-validate before sending, so a malformed query comes back as a `400 validation_error`/`invalid_field` `KlapApiError` from the server, exactly as it would calling the REST endpoint directly. `environment` must match the calling key's own environment — a `test` key can never query `live` data or vice versa, regardless of what `resource`/`filters` say; a mismatch is `422 environment_mismatch`. Rate limited to 60 requests/min per organization across all of `/v1/metrics/*` — tighter than the general `/v1/*` limit, since this can be an expensive aggregate query; over that is `429 rate_limited`. A key also needs the right **scope**: `metrics:read` covers every resource, or one of the narrower `metrics:charges:read` / `metrics:transactions:read` / `metrics:distributions:read` scoped to just that one `resource` — see [Authentication](/authentication) for the full scope list. A key missing the scope this query needs gets `403 insufficient_scope`, distinct from `401 invalid_api_key` (key itself invalid/revoked). The request type is `MetricsQueryRequest` (`@klappay/types`) — a discriminated union on `resource`, so TypeScript narrows which dimension/ metric/date fields are valid the instant you set `resource`. `groupBy`, `filters`, and `limit` are all optional there (`MetricsQuerySchema` defaults them to `[]`/`[]`/`100`); `MetricsQuery` (`z.infer<>`) is the post-parse shape where those are always present — the same `*Request`/ already-parsed split `CreateChargeSchema` follows (see [Charges](/charges)). ## Query shape, field by field ### `resource` and its fields One row per underlying record: `charges` is one row per charge, `transactions` is one row per detected on-chain transfer (a charge paid in installments has more than one — see [Charges](/charges)' `causedTransition` timeline events), `distributions` is one row per payout attempt for one `(token, network)` pair a charge settled across. | Resource | Dimension fields (filter/groupBy) | Numeric fields (`sum`/`avg`/`min`/`max`) | Date fields | |---|---|---|---| | `charges` | `status`, `source`, `apiKeyId`, `currency`, `isOverpaid`, `externalRef` | `amount`, `amountReceived`, `feePercent` | `createdAt`, `confirmedAt`, `lastActivityAt`, `expiresAt` | | `transactions` | `network`, `token`, `source`, `causedTransition` | `amount` | `detectedAt` | | `distributions` | `status`, `network`, `token`, `distributorAddress` | `attempts` | `createdAt`, `processingStartedAt`, `completedAt` | Every field name is also exported as its own literal-union schema/type (`ChargesQueryFieldSchema`/`ChargesQueryField`, `TransactionsMetricField`, etc.) from `@klappay/types` — import these directly for full autocomplete instead of typing the string by hand. A few fields are worth knowing the shape of before you query them: * **`confirmedAt`** (charges) is `null` until a charge reaches `confirmed` — a `dateRange`/`date_bucket` on it implicitly excludes every charge that never confirmed. `expiresAt` is always present (set at creation), useful for e.g. finding charges expiring soon. * **`causedTransition`** (transactions) is `true` only for the transfer(s) that actually flipped the charge's `status` — a charge paid in installments can legitimately have more than one `true` row; filtering/ grouping on it excludes no-op duplicate transfers detected on the same address. * **`distributorAddress`** (distributions) is the on-chain address that actually called `distribute()` for a `completed` distribution — Klap's address if Klap settled it, the keeper's address if a third party did, `null` for every non-`completed` status. * **`processingStartedAt`** (distributions) is `null` until the first payout attempt, then overwritten on every retry — it reflects the *latest* attempt's start, not the first. `completedAt` is `null` until `status` reaches `completed`, so a `dateRange`/`date_bucket` on it implicitly excludes every distribution still pending/processing/failed. ### `dateRange` — required, and capped Every query needs a `dateRange`: `{ field, from, to }`, `field` one of that resource's date fields above. Not optional-defaulting-to-unbounded on purpose — it's what keeps a query from scanning your entire history. The span can't exceed `MAX_METRICS_QUERY_DATE_RANGE_DAYS` (366 days), and `from` must be before `to` — a `superRefine` on `MetricsQuerySchema` rejects anything wider, enforced server-side regardless of what the SDK does client-side. ### `groupBy` Up to `METRICS_QUERY_MAX_GROUP_BY` (3) entries, each either: * `{ type: 'field', field: }` * `{ type: 'date_bucket', field: , granularity }` — at most one of these per query. `granularity` is `'day' | 'week' | 'month' | 'year'`. Buckets are calendar-aligned and computed in UTC, so a day boundary is midnight UTC and a week starts on Monday. ### `metrics` At least one, up to `METRICS_QUERY_MAX_METRICS` (10): `{ aggregation, field?, alias? }`. `aggregation` is `'count' | 'sum' | 'avg' | 'min' | 'max'`. `field` is required unless `aggregation` is `'count'` (rejected otherwise), and must be one of the resource's numeric fields. `alias` names the output column — omit it and the column is named `${aggregation}` for `count`, or `${aggregation}_${field}` otherwise (e.g. `sum_amount`). Every alias in one query 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_]*$` — letters, digits, underscores, not starting with a digit. ### `filters` Up to `METRICS_QUERY_MAX_FILTERS` (20): `{ field, operator, value }`. `field` is one of that resource's **dimension** fields, not its numeric fields — filtering `amount > 100` isn't supported today, only grouping/ aggregating on it. `operator` is `'eq' | 'neq' | 'in' | 'gt' | 'gte' | 'lt' | 'lte'` — `in` expects an array value (max 50 entries), every other operator a single scalar. ### `orderBy` and `limit` `orderBy` (optional): `{ key, direction }`, where `key` is an output column name from this same query (a `groupBy` field name, or a metric's `alias`/default name) — must match `^[a-zA-Z_][a-zA-Z0-9_]*$`, same pattern as `alias` above, though every real output column name already satisfies it, so this only ever rejects a value that could never have been a real column to begin with. Omit `orderBy` and you get ascending-by-bucket order for a date-bucketed query, or implementation- defined (not guaranteed stable) order otherwise. `limit` (default 100, max `METRICS_QUERY_MAX_ROW_LIMIT` = 1000) caps rows returned — if more rows matched, the response's `meta.truncated` is `true` and `data` holds only the first `limit`. Narrow the query (a tighter `dateRange`, an added filter, a coarser `date_bucket` granularity) instead of just raising `limit`. ## Response shape ```ts interface MetricsQueryResult { data: Array> meta: { resource: 'charges' | 'transactions' | 'distributions' environment: 'live' | 'test' rowCount: number truncated: boolean } } ``` `data` rows are shaped entirely by your query — keys are whatever your `groupBy` fields and metric aliases resolved to, values are `string | number | boolean | null`. There's no fixed report shape to fall back on: a query with `groupBy: [{ type: 'field', field: 'source' }]` and two `metrics` entries returns one row per distinct `source` value with those two metric columns; a `date_bucket` query returns one row per bucket that had at least one matching underlying record (no zero-filled gaps for buckets with no data — pad those client-side if you need a continuous series). ## Errors | Status | `error.code` | Meaning | |---|---|---| | `400` | `validation_error` / `invalid_field` | Malformed query, or a `filters`/`groupBy`/`metrics` value that doesn't match its field's declared type | | `401` | `missing_api_key` / `invalid_api_key` | No key, or a key that's invalid/revoked | | `403` | `insufficient_scope` | Key is missing `metrics:read` and the matching `metrics:{resource}:read` scope | | `422` | `environment_mismatch` | Requested `environment` doesn't match the calling key's own environment | | `429` | `rate_limited` | Over 60 requests/min per organization on `/v1/metrics/*` | Every error response shares the same `{ error: { code, message, param? } }` envelope — see [Errors](/errors) for the full catalog and the `KlapApiError` shape the Node SDK throws. ## Access control Every valid API key gets unrestricted access to your organization's data — there's no member/role-based sub-scoping within an organization, and no organization id to pass anywhere. A key can only ever see data attributed to your organization, never another's, and only its own `environment` (`live`/`test`) — this is a structural limit of the query engine, not a filter you can opt out of, which is also why cross-organization analysis (cohorts, platform-wide rollups) isn't something this endpoint can ever answer. ## What it can't answer yet Deliberately out of scope today — worth knowing before you reach for a workaround: * **Cross-resource joins** — e.g. "sum of `Charge.amount` for charges whose distribution ultimately failed" needs a join between `charges` and `distributions` beyond what a single-resource query can express. * **Activation funnels / time-to-first-X** — "time between two different resources' first row" isn't expressible as a `groupBy`/aggregate over one resource; it needs its own purpose-built query. * **Cross-organization analysis** — cohort retention, platform-wide payer activity. Every query is scoped to one organization by design (see Access control above); this is platform-side analysis, not a merchant-facing feature. * **Median/percentile aggregations** — only `count`/`sum`/`avg`/`min`/ `max` exist today; time-to-payment percentiles need a genuinely different computation, not a config change. ## See also * [Charges](/charges) — the underlying `Charge` resource this endpoint aggregates, its full state machine, and the `amount`/`amountReceived` distinction that matters for revenue metrics. * [Distributions](/distributions) — the payout/settlement resource behind the `distributions` metrics resource, and what `status`/`attempts` mean there. * [Errors](/errors) — the full `error.code` catalog and the `KlapApiError` shape every SDK method throws. --- --- url: https://docs.klappay.com/distributions.md --- # Distributions A **distribution** is the payout leg of a charge — the on-chain step where funds already confirmed as received get moved out to the merchant. Every charge address is a split contract (0xSplits or an equivalent, depending on the network — see below); once a payment is detected, Klap calls that split's `distribute()` function to route the balance to its configured recipients (the merchant, plus Klap's own fee recipient). "Pending" means a split has a confirmed payout sitting at its address but `distribute()` hasn't been called yet; "settled" means it has, and the funds have actually moved — the same transition reflected on the charge itself as `settlementStatus: 'completed'` (see [Charges](/charges)). Which contract that actually is depends on the distribution's `network`: the official 0xSplits v2.2 deployment on every EVM network (`base`, `optimism`, `polygon`, `ethereum`, `arbitrum`, `avalanche`, `bnb`, `arc`), and a separate, non-EVM contract on `tron`. Branch on `NETWORK_FAMILIES` from `@klappay/types` (`'evm-official'` or `'tron'`) rather than assuming one ABI or address format for every network — see [Networks & tokens](/networks#network). One Arc-specific caveat if you run your own keeper: USDC is Arc's native gas token, so call `distribute()` with USDC's ERC-20 address, not the native-token sentinel (`0xEeee…`). A native-sentinel call still pays every recipient correctly, but Klap may not attribute that payout to the distribution, so it can stay pending and the charge's `settlementStatus` may never reach `'completed'`. The reason this needs its own resource at all: the split's `distribute()` is **permissionless** — anyone can call it, not just Klap, and whoever does earns a small `distributorFeePercent` cut of the balance for doing so. Klap itself races to claim every split automatically, typically well inside the 5-minute grace period before it's even discoverable here, but during that window a third-party keeper can beat it and claim the reward instead. `GET /distributions/pending` and `GET /distributions/pending/events` are the feed that makes currently-claimable splits discoverable. **This resource is for keepers/bots, not a typical merchant integration** — ignore it entirely unless you're specifically building or running one. If you only care about *your own* payout status as a merchant, track it on the charge itself via `settlementStatus`/ `waitForSettlement()`, not here — see [Charges](/charges). ## Listing pending distributions ::: code-group ```bash [cURL] curl https://api.klappay.com/v1/distributions/pending?limit=20 \ -H "Authorization: Bearer klap_live_..." ``` ```ts [Node.js] const page = await klap.distributions.list({ limit: 20 }) // page.data, page.nextCursor, page.hasMore ``` ::: [→ Try `GET /distributions/pending` in the API Playground](https://api.klappay.com/#tag/distributions/GET/distributions/pending) `GET /distributions/pending` — query params `limit` (1–100, default 20\) and `cursor`. Rate limited to 20 requests/min per organization, same as the events endpoint below (both share the `/v1/distributions/*` bucket) — a keeper polling this on a tight loop instead of using the event stream will hit `429 rate_limited` quickly, which is itself a nudge toward the stream being the right primary transport and this endpoint the bootstrap/backfill path. Cursor-paginated, same shape and semantics as every other list endpoint (`GET /charges`, etc.) — pass the previous response's `nextCursor` back verbatim as `cursor` to fetch the next page (never construct or parse it yourself, its shape isn't part of the public contract), and stop once `hasMore` is `false`. There's no fixed row cap: during an anomalous backlog (e.g. a pile-up of still-claimable rows) keep following `nextCursor` instead of assuming everything fits in one page. Page through everything automatically with `listAll()`: ```ts for await (const d of klap.distributions.listAll()) { console.log(d.splitAddress, d.network, d.token, d.estimatedRewardAmount) } ``` A malformed or expired `cursor` is `400 invalid_cursor`, not a silent empty page. ### The `PendingDistribution` shape Each entry (`PendingDistributionSchema`/`PendingDistribution` in `@klappay/types`) is a snapshot of one split, in the calling key's own environment, with a confirmed payout still inside its 5-minute grace period: ```ts import { PendingDistributionSchema, type PendingDistribution } from '@klappay/types' const distribution: PendingDistribution = PendingDistributionSchema.parse({ splitAddress: '0x1111111111111111111111111111111111111111', network: 'base', token: 'USDC', recipients: [ { address: '0x2222222222222222222222222222222222222222', percentAllocation: 99 }, { address: '0x3333333333333333333333333333333333333333', percentAllocation: 1 }, ], distributorFeePercent: 0.1, estimatedRewardAmount: 0.001, availableSince: '2026-07-28T23:45:52.382Z', graceEndsAt: '2026-07-28T23:50:52.382Z', }) ``` | Field | Notes | |---|---| | `splitAddress` | The on-chain split address to call `distribute()` on — a `0x` address on EVM networks (including `arc`), a Base58 `T...` address on `tron`. | | `network`/`token` | Which chain and stablecoin the split holds — same `network`/`token` enums as everywhere else in the API; see [Networks & tokens](/networks) for the current support matrix, including the BNB Chain caveat (its `USDC` and `USDT` are Binance-Peg with 18 decimals, not native Circle/Tether deployments — read the split's token decimals from `getTokenDeployment`, not a hardcoded 6). | | `recipients` | The **exact** array to pass to `distribute()` — the split contract only stores a hash of the recipient config on-chain, so the caller must supply the identical array to prove it matches. Always present, never a partial/reconstructed version. | | `distributorFeePercent` | Cut of the split balance paid to whoever calls `distribute()` first (e.g. `0.1` = 0.1%). Frozen at charge creation — same value for every distribution today, but not guaranteed to stay a constant forever. | | `estimatedRewardAmount` | An **estimate**, from the amount Klap detected on-chain — not a live balance read. Always read the split's real balance yourself before submitting a transaction, the same way Klap does before its own call; a stale estimate is harmless, never a reason to skip that check. | | `availableSince` | When this distribution entered its grace period. | | `graceEndsAt` | When Klap itself may claim it. Calling `distribute()` after this timestamp is still possible but increasingly likely to lose the race. | The response envelope (`PaginatedPendingDistributionsSchema`): `{ data: PendingDistribution[], hasMore: boolean, nextCursor: string | null }`. ## Live stream of pending distributions ::: code-group ```bash [cURL] curl https://api.klappay.com/v1/distributions/pending/events \ -H "Authorization: Bearer klap_live_..." \ -H "Accept: text/event-stream" ``` ```ts [Node.js] 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) } } ``` ::: [→ Try `GET /distributions/pending/events` in the API Playground](https://api.klappay.com/#tag/distributions/GET/distributions/pending/events) `GET /distributions/pending/events` — Server-Sent Events, scoped to the calling key's own environment. See [Real-time (SSE)](/realtime) for the general stream mechanics (heartbeat, close conditions, why SSE over polling) shared with `GET /charges/{id}/events` and `GET /webhooks/listen` — this section covers only what's specific to distributions. Rate limited to 20 requests/min per organization for the initial connection (same bucket as the listing endpoint above); once connected, a separate cap of 503 `sse_capacity` applies if too many concurrent streams are already open — see [Real-time](/realtime)'s "Limits" section for the per-key concurrent-stream ceiling this shares with every other SSE endpoint. Each event (`PendingDistributionEventSchema`) is a discriminated union on `type`: * **`distribution.available`** — a new distribution entered its grace period, or re-entered it after a failed claim attempt. Carries the full `distribution` (`PendingDistribution`, fully typed). * **`distribution.claimed`** — no longer claimable, settled by anyone including Klap itself. Carries only the `splitAddress` — and fires the moment a claim is *started*, not only once it finishes, so a keeper stops racing an attempt already in flight. If that attempt then fails and is rescheduled, `distribution.available` fires again with a refreshed `graceEndsAt`. ```ts import { PendingDistributionEventSchema, type PendingDistributionEvent } from '@klappay/types' const event: PendingDistributionEvent = PendingDistributionEventSchema.parse( JSON.parse(sseEventData), ) ``` **Ordering matters.** By default the stream sends no initial snapshot — open it *before* calling `list()`/`GET /distributions/pending`, then treat both that snapshot response and every event received (before or after it resolves) as an idempotent add/remove against one local `Map`. Opening the stream *after* the snapshot call instead leaves a real, if small, gap where a delta between the two requests is never delivered. ### Self-contained connection with `limit` Pass `?limit=` (validated by `ListenPendingDistributionsQuerySchema`, up to `PAGINATION_LIMIT_MAX`/100, default `0`) to skip the connect-then-list dance above — the server sends up to that many currently-claimable distributions as synthetic `distribution.available` events right after connecting, then continues with live deltas, all over one connection: ```ts import { ListenPendingDistributionsQuerySchema } from '@klappay/types' // GET /v1/distributions/pending/events?limit=50 ListenPendingDistributionsQuerySchema.parse({ limit: '50' }) // { limit: 50 } ``` ```ts for await (const event of klap.distributions.streamPending(undefined, 50)) { // ... } ``` There's no ordering footgun here either — the subscription opens before the snapshot query runs internally, so nothing published in between is lost. But this snapshot isn't a full page: no cursor, so if more than `limit` are claimable at connect time the excess is simply not sent. Reach for `list()`/`listAll()` directly instead if you need a complete listing during an anomalous backlog. Omitting `limit` (or passing `0`) keeps the original two-step behavior above unchanged. ## Authentication and environment scoping Both endpoints require the same API key used everywhere else (`Authorization: Bearer klap_live_...`/`klap_test_...`; `401 missing_api_key`/`invalid_api_key` otherwise). A `test` key only ever sees `test` distributions, `live` only `live` — there's no `environment` field on either schema above, since the connection is already scoped by whichever key opened it. ## SDK access `klap.distributions` — also available standalone as `createDistributionsClient` from `@klappay/node/distributions` (see [Tree-shaking](/sdk/tree-shaking)). Requires an `apiKey`. ## See also * [Charges](/charges) — `settlementStatus`/`waitForSettlement()`, the per-charge view of this same settle-the-payout process, for tracking your own payout as a merchant instead of running a keeper. * [Real-time (SSE)](/realtime) — the streaming mechanism `GET /distributions/pending/events` shares with charge status updates and webhook delivery: heartbeat, close conditions, and the per-key concurrent-stream limit. --- --- url: https://docs.klappay.com/recipients.md --- # Recipients Trusted, pre-registered payout addresses — the only thing a charge's [`splitRecipients`](/charges#splitrecipients) can route a slice of a payment to. ## Overview A recipient is an address — EVM (`0x...`, including Arc) or TRON (`T...`) — that you register once, ahead of time, so a charge can reference it by `id` instead of by raw address. Recipients are pre-registered so a leaked `charges:write` key can never route a slice of a payment to an unvetted address: `charges:write` alone can never do that. Registering a new destination needs the separate `recipients:write` scope, and using an already-registered one in a split needs `charges:split_write` on top of `charges:write`. A single key is never issued both (see [Authentication](/authentication#scopes)). A recipient's `id` (`rc_...`) is what a split references; the `address` is what ends up on-chain. Both are scoped to the key's organization and environment — a `test` recipient can't be used from a `live` key. ## Registering a recipient ::: code-group ```bash [cURL] curl -X POST https://api.klappay.com/v1/recipients \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "address": "0x1111111111111111111111111111111111111111", "label": "sales rep" }' ``` ```ts [Node.js] import { createRecipientsClient } from '@klappay/node/recipients' const recipients = createRecipientsClient({ baseUrl: '...', apiKey: '...' }) const recipient = await recipients.create({ address: '0x1111111111111111111111111111111111111111', label: 'sales rep', // optional, your own bookkeeping — never interpreted }) console.log(recipient.id) // rc_... — this is what a charge split references ``` ::: [→ Try `POST /recipients` in the API Playground](https://api.klappay.com/#tag/recipients/POST/recipients) Requires `recipients:write`. `label` is optional, 1–64 characters, and never interpreted. `create()` is an **idempotent upsert** keyed on `address`: registering an address that's already known just updates its `label` and, if it had been revoked, un-revokes it — so you can call it again without first checking whether the recipient exists. It also **resets `payout` to `false`** every time, whatever it was before, so `recipients:write` can never grant *or preserve* a payout approval by itself (see [`payout`](#payout-eligibility-for-an-api-key) below). The response is a `Recipient`: ```json { "id": "rc_abc123", "environment": "live", "address": "0x1111111111111111111111111111111111111111", "label": "sales rep", "payout": false, "createdAt": "2026-01-01T00:00:00.000Z" } ``` ## Listing recipients ::: code-group ```bash [cURL] curl "https://api.klappay.com/v1/recipients?limit=20" \ -H "Authorization: Bearer klap_live_..." ``` ```ts [Node.js] const page = await recipients.list({ limit: 20 }) // page.data, page.nextCursor, page.hasMore for await (const recipient of recipients.listAll()) { console.log(recipient.id) } ``` ::: [→ Try `GET /recipients` in the API Playground](https://api.klappay.com/#tag/recipients/GET/recipients) Requires `recipients:read`. Returns your organization's **non-revoked** recipients for the calling key's environment, newest first, with the same cursor pagination as [`GET /charges`](/charges#listing-charges): `limit` is 1–100 (default 20), and `{ data, nextCursor, hasMore }` comes back — feed `nextCursor` in as `cursor` until `hasMore` is `false`. An environment with no registered recipients gets an empty `data` array, not an error. `listAll()` is an async generator that walks every page for you. ## Revoking a recipient ::: code-group ```bash [cURL] curl -X DELETE https://api.klappay.com/v1/recipients/rc_abc123 \ -H "Authorization: Bearer klap_live_..." ``` ```ts [Node.js] await recipients.revoke('rc_abc123') ``` ::: [→ Try `DELETE /recipients/{id}` in the API Playground](https://api.klappay.com/#tag/recipients/DELETE/recipients/{id}) Requires `recipients:write` and answers `204` with no body. A revoked recipient can no longer be referenced by a *new* split. Charges that already referenced it are **unaffected**: the address was resolved and frozen into the charge at creation, it isn't a live reference to the `Recipient`. Revoking is **not idempotent**. A second call fails with `404 recipient_not_found` — the same error, deliberately indistinguishable from, an id that never existed. Don't treat a repeated `revoke()` as a safe no-op: ```ts import { KlapApiError } from '@klappay/node' try { await recipients.revoke('rc_abc123') } 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 } } ``` A recipient currently flagged `payout: true` can only be revoked with `recipients:manage_payout`, not plain `recipients:write`. A `recipients:write`-only key attempting it gets the same `404 recipient_not_found` as for a nonexistent id, so it can't even confirm that such a recipient exists. See below for why this is gated. ## `payout`: eligibility for an API key `recipient.payout` is unrelated to using a recipient in a split — every non-revoked recipient is already usable there. It controls something narrower: whether the address is *eligible to become an API key's own `payoutAddress`*, the destination that merchant's own charges settle to. ::: code-group ```bash [cURL] curl -X PATCH https://api.klappay.com/v1/recipients/rc_abc123 \ -H "Authorization: Bearer klap_live_..." \ -H "Content-Type: application/json" \ -d '{ "payout": true }' ``` ```ts [Node.js] await recipients.setPayout('rc_abc123', true) // and to turn it back off: await recipients.setPayout('rc_abc123', false) ``` ::: [→ Try `PATCH /recipients/{id}` in the API Playground](https://api.klappay.com/#tag/recipients/PATCH/recipients/{id}) Requires `recipients:manage_payout`, deliberately stricter than `recipients:write`. It's meant to be held only by a key that has already gone through its own out-of-band approval for that specific action — never a merchant-facing or third-party integration key. Two consequences follow from keeping "usable in a split" separate from "payout-eligible": * **Revoking a `payout: true` recipient invalidates every API key whose `payoutAddress` matches it**, on that key's very next request (`401 payout_address_revoked`). It's gated on `payout: true` and not on any revoked recipient because a `payoutAddress` is public (it's the first entry in the on-chain split): if the weaker `recipients:write` were enough, one integration key could permanently lock out another by registering and then revoking its address. That's also why revoking such a recipient needs `recipients:manage_payout`. * **Setting `payout: true` does not unset it on any other recipient.** There's no single-payout-target invariant: several recipients can hold `payout: true` at once, and flagging a new one has no effect on the others. If you expect "set payout here" to behave like a single default payment method, that's wrong here; clear the old one yourself with an explicit `setPayout(oldId, false)`. ## Using a recipient in a charge split Pass the recipient's `id` — never its address — in the charge's `splitRecipients`: ```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: '0x1111...1111', percent: 10, label: 'sales rep' }] ``` Request and response differ 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 went without a second lookup. The full semantics — `percent` is of your own net share, at most 5 entries, frozen at creation, `charges:split_write` required — live in [`splitRecipients`](/charges#splitrecipients). A `recipientId` that doesn't resolve (wrong id, wrong environment, or revoked) fails the whole `POST /charges` with `422 recipient_not_found_in_split`. ## Error codes | Code | Status | Meaning | |---|---|---| | `recipient_not_found` | 404 | No such recipient in your organization and environment, or it exists but is payout-eligible and the key lacks `recipients:manage_payout` to revoke it. Also what a second `DELETE` returns. | | `recipient_not_found_in_split` | 422 | A `splitRecipients[].recipientId` on `POST /charges` didn't resolve: wrong id, wrong environment, or revoked. | | `insufficient_scope` | 403 | The key lacks the `recipients:read`/`recipients:write`/`recipients:manage_payout` scope the action needs. | | `conflicting_api_key_scopes` | 403 | The key holds both `charges:split_write` and a recipient-registration scope, which is never allowed; rejected before the route runs. | See [Errors](/errors) for the shared error envelope. ## Types ```ts import { CreateRecipientSchema, RecipientSchema, PaginatedRecipientsSchema, SetRecipientPayoutSchema, } from '@klappay/types' CreateRecipientSchema.parse({ address: '0x1111111111111111111111111111111111111111', label: 'supplier', }) SetRecipientPayoutSchema.parse({ payout: true }) ``` `CreateRecipientSchema` / `CreateRecipientInput` is the body of `POST /recipients` (`CreateRecipientRequest` is the pre-parse type) — `address` is an EVM `0x...` or TRON `T...` address, `label` an optional 1–64 character string. `RecipientSchema` / `Recipient` is the shape shown above, and `PaginatedRecipientsSchema` / `PaginatedRecipients` is `GET /recipients`'s `{ data, nextCursor, hasMore }` response. `SetRecipientPayoutSchema` / `SetRecipientPayoutInput` is `PATCH /recipients/{id}`'s `{ payout: boolean }` body. On the charge side, `SplitRecipientInputSchema` (request: `recipientId`, `percent`, optional `label`) and `SplitRecipientSchema` (response: resolved `address`, `percent`, optional `label`) are documented under [`splitRecipients`](/charges#splitrecipients). ## Standalone client `klap.recipients` is also available on its own, without the full client, for a smaller bundle: ```ts import { createRecipientsClient } from '@klappay/node/recipients' const recipients = createRecipientsClient({ baseUrl: '...', apiKey: '...' }) ``` `baseUrl` and `apiKey` are optional and fall back to `KLAP_BASE_URL` and `KLAP_RECIPIENTS_API_KEY` if omitted (see [Getting started](/getting-started)). See [Tree-shaking](/sdk/tree-shaking) for the rest of the subpaths. ## See also * [Charges](/charges#splitrecipients) — `splitRecipients` request and response semantics. * [Authentication](/authentication#scopes) — the `recipients:*` and `charges:split_write` scopes, and why they're never combined on one key. * [Errors](/errors) — the error envelope and `KlapApiError`. --- --- url: https://docs.klappay.com/networks.md --- # Networks & tokens Which `(token, network)` pairs a given API key's environment can accept right now — and why you should always read that live instead of hardcoding it. ## Overview `acceptedPayments` on a charge is a list of `(token, network)` pairs, but not every pair that *typechecks* actually exists on-chain for your key. Coverage is asymmetric on purpose: some tokens have no testnet deployment at all, some networks have no `test` environment whatsoever, and `live`/`test` don't move in lockstep — a network can be wired up on testnet well before it goes live, or the reverse. Treating `Token` and `Network` as a clean cross product and hardcoding all 18 combinations (2 tokens × 9 networks) client-side will eventually hand a payer a pair that `POST /charges` then rejects with `422 token_not_supported`. `GET /networks` exists to close that gap. It's not a separate, maybe-stale list somebody remembers to update — it's read straight from the exact same `TOKEN_ADDRESSES` lookup that `POST /charges` validates `acceptedPayments` against at creation time. That makes the guarantee exact in both directions: a pair this endpoint lists is always safe to submit, and a pair it doesn't list is always rejected. Build a payment-method picker (which chains/tokens to show a payer) by calling this at render time instead of shipping a static matrix that drifts the moment a new network or token comes online. One more reason to prefer this over hardcoding: it's scoped to *your key's own* `environment`. A `live` key and a `test` key against the same account can see genuinely different matrices (see the gotchas below), so there's no single "the matrix" to bake into a client bundle even if you wanted to — it's inherently per-environment. Also worth knowing: unlike almost every other authenticated route, `GET /networks` skips the scope check entirely — any valid, unrevoked, environment- matched key can call it, `charges:read` or not, because the response is identical for every key in that environment and there's nothing tenant-specific in it to protect. ## Getting the current matrix ::: code-group ```bash [cURL] curl https://api.klappay.com/v1/networks \ -H "Authorization: Bearer klap_live_..." ``` ```ts [Node.js] const capabilities = await klap.networks.get() // { acceptedPayments: [ // { token: 'USDC', network: 'base' }, // { token: 'USDC', network: 'optimism' }, // { token: 'USDT', network: 'base' }, // ... // ] } ``` ::: [→ Try `GET /networks` in the API Playground](https://api.klappay.com/#tag/networks/GET/networks) `GET /networks` takes no parameters — the only input is which key you authenticate with, since that's what determines the `environment` being read. `klap.networks` is also available standalone as `createNetworksClient` from `@klappay/node/networks` (see [Tree-shaking](/sdk/tree-shaking)); it requires only an `apiKey`, no other client setup. Response shape (`CapabilitiesSchema`/`Capabilities` in `@klappay/types`): ```ts { acceptedPayments: { token: Token; network: Network }[] } ``` Every entry is a pair actually configured for the calling key's `environment` right now — not the full type-level set of values `Token`/`Network` can hold (see below), which is a wider, aspirational list that includes combinations with no real deployment yet. ## Tokens and networks reference ### `Token` `TokenSchema`/`Token` — currently `'USDC' | 'USDT'`. This is today's supported list, not a permanent ceiling — more tokens are expected to be added over time as new networks and assets come online. Support depends on both `network` and `environment`, and coverage isn't symmetric: * **`live`** has both `USDC` and `USDT` on `base`, `optimism`, `polygon`, `ethereum`, `arbitrum`, `avalanche`, and `bnb`; `arc` has `USDC` only (Circle's own chain has no `USDT` deployment). On `bnb`, both tokens are Binance-Peg, not Circle/Tether deployments (see below) — they're still listed under `USDC`/`USDT` because that's what those symbols mean on BNB Chain in practice, just with a different trust model. * **`test`** coverage varies per network, for two unrelated reasons: * `base`, `optimism`, and `ethereum` each have a `test` environment with `USDC` only — none has an official Sepolia `USDT`, because Tether simply doesn't issue one there. A token-level gap. `arc` likewise has `USDC` on its testnet. * `arbitrum`, `polygon`, and `avalanche` have no `test` environment at all yet, for *any* token — 0xSplits (the split-factory infrastructure Klap's charge addresses are built on) hasn't deployed on Arbitrum Sepolia, and has no Polygon or Avalanche Fuji testnet support at all. A network-level gap, not a token-level one — it's not that `USDC` is missing there, it's that nothing is. * `bnb` has no `test` environment either, same root cause: no BNB testnet support in 0xSplits. An unconfigured `(token, network, environment)` combination is rejected with `422 token_not_supported` at charge creation — never silently accepted and left to strand a payer's funds. **`USDC` and `USDT` on `bnb` are Binance-Peg tokens, not Circle or Tether deployments, and both use 18 on-chain decimals.** Payments on BNB Chain are supported, and a charge can accept both pairs like any other network's. On `USDC`: Circle issues no native USDC on BNB Chain at all — it's absent from both Circle's own address list and its CCTP-supported chain list. The address behind `bnb`'s `USDC` entry is a Binance-custodied, 1:1-pegged BEP-20 token instead: Binance locks real USDC (or equivalent collateral) in its own wallet and mints this against it. Real liquidity and adoption — it genuinely is what "USDC" means on BNB Chain in practice — but a centralized-custody trust model, unlike every other `USDC`/`USDT` entry in this matrix, which is verified directly against its actual issuer. Accepted at the payer's own risk — Klap doesn't verify or guarantee Binance's collateral backing it. `USDT` on `bnb` is the same story: the configured contract is also a Binance-Peg asset with 18 decimals, not Tether's own issuance, so don't present either BNB entry as direct Circle or Tether issuance in your UI. Token symbols alone never identify a contract, issuer, or decimal count. **`USDC` on `arc` isn't a separately-deployed contract.** It's Arc's native gas asset, exposed through a fixed system address with an optional ERC-20 interface (the same address on mainnet and testnet). That interface reports 6 decimals, which is what `getTokenDeployment` returns, even though the native balance itself has 18. One more naming quirk, cosmetic but worth knowing if you're cross-checking on a block explorer: on `arbitrum`, the `USDT` contract is real and Tether-backed, but reports its on-chain name/`symbol()` as `"USD₮0"` (Tether's "USDT0" cross-chain standard, migrated in place in January 2025). Verification is always done by contract address, never by `symbol()`, so this doesn't affect anything functionally — but don't mistake "USD₮0" on Arbiscan for a different, unsupported token. Resolve a deployment's contract address **and** its decimals together with `getTokenDeployment(token, network, environment)` from `@klappay/types` (also in `@klappay/types/constants`), which returns `{ address, decimals }` or `undefined` when that combination isn't configured. Every deployment outside BNB Chain uses 6 decimals; both BNB Chain contracts use 18. `TOKEN_DECIMALS` (`6`) is still exported for source compatibility but is deprecated and unsafe for general token conversion — it's wrong for `bnb`. Read `deployment.decimals` for payment preparation, amount formatting, and raw-value validation. `TOKEN_ADDRESSES` is likewise a legacy address-only view derived from the same data — a present address alone doesn't mean a payment method is on offer, and test and live metadata must never fall back to each other, since they're different chains. **`tron` isn't accepting payments right now, in either environment.** It's still a valid `Network` value (so it typechecks, and filtering or reading by it works), but `GET /networks` doesn't list any TRON pair and `POST /charges` rejects one with `422 token_not_supported`. When it's enabled it will simply appear in `GET /networks` — another reason to build the picker from that response instead of from the `Network` type. TRON addresses aren't `0x`-prefixed: they're Base58 (`T...`). ### `Network` `NetworkSchema`/`Network` — `'base' | 'optimism' | 'polygon' | 'ethereum' | 'arbitrum' | 'avalanche' | 'bnb' | 'tron' | 'arc'`. Every one of these values is valid for reading/filtering (e.g. `ListChargesInput.network`) regardless of whether it's fully wired for writes. A network that's dropped is removed from the type entirely rather than left behind as a deprecated value. `OPERATIONAL_NETWORKS` — the narrower list of networks actually wired end-to-end today (`base`, `arbitrum`, `optimism`, `polygon`, `ethereum`, `avalanche`, `bnb`, `arc`, `tron`), which right now happens to equal every `Network` value. `POST /charges` enforces this at creation: a `network` outside this list is rejected with `400 validation_error` naming the network and what's currently supported, not silently accepted and left unroutable. This isn't guaranteed to stay in lockstep with `Network` — the day a genuinely new chain is added to the type before its wiring lands, `OPERATIONAL_NETWORKS` will again be the narrower one. Being in `OPERATIONAL_NETWORKS` only means the schema accepts the network; it doesn't promise any pair on it is payable in a given environment (`tron` is in the list but currently isn't, and several networks lack a `test` environment entirely — see the gaps above). `GET /networks` is the answer to that question. `EVM_NETWORKS` — every `Network` value except `tron`, which isn't EVM (Arc is, and shares the `'evm-official'` split family below). Use this to guard any logic that assumes an EVM-style `0x` address/RPC; escrow, for instance, needs every accepted network to be in it. `NETWORK_FAMILIES` (`Record`) — which split-address family a network belongs to. A charge predicts one split address up front and reuses it on every network it accepts, which is only safe when they all derive it through the same split factory. Every EVM network, Arc included, is `'evm-official'` (0xSplits' official contracts, same factory on every chain), so they can be mixed freely in one charge. `tron` has its own factory, so `acceptedPayments` can't mix it with an EVM network — that's rejected with `400 validation_error`. `@klappay/types` 6.0.0 removed `'arc'` from this union; code that switches on the family should drop that branch. `NETWORK_LABELS` — display names (`'Base'`, `'Optimism'`, ...) for UI use. `NETWORK_EXPLORERS` — each network's block explorer base URL (e.g. `https://basescan.org`), for building a direct transaction link yourself: `${NETWORK_EXPLORERS[network]}/tx/${charge.txHash}`. ### Known gaps at a glance | Network | `live` | `test` | |---|---|---| | `base` | USDC, USDT | USDC only (no Sepolia USDT) | | `optimism` | USDC, USDT | USDC only (no Sepolia USDT) | | `ethereum` | USDC, USDT | USDC only (no Sepolia USDT) | | `arbitrum` | USDC, USDT | none (no 0xSplits on Arbitrum Sepolia) | | `polygon` | USDC, USDT | none (no 0xSplits testnet support) | | `avalanche` | USDC, USDT | none (no 0xSplits Fuji support) | | `bnb` | USDC, USDT (both Binance-Peg, 18 decimals) | none (no 0xSplits BNB testnet support) | | `arc` | USDC only (no USDT on Arc) | USDC | | `tron` | none (not accepting payments right now) | none | This table is a snapshot for orientation only — it's exactly the kind of hardcoded matrix this page argues against relying on. Always confirm against `GET /networks` for your own key's environment before shipping a payment-method picker; the day a new network/token combination lands, this table goes stale and the live endpoint doesn't. ## See also * [Charges](/charges) — `acceptedPayments` at charge creation is validated against this exact same matrix; `POST /charges` and `GET /networks` share one lookup, so a pair accepted here can never be a pair rejected there. * [Authentication](/authentication) — `GET /networks` is one of the few routes that skips the scope check; still requires a valid, unrevoked, environment-matched key. --- --- url: https://docs.klappay.com/guides/checkout-flow.md --- # Build a checkout flow The five pieces from [Charges](/charges), [Webhooks](/webhooks), and [How it works](/how-it-works), assembled into the flow most integrations actually build: create a charge, send the payer to pay it, and mark the order paid once it resolves — without polling. ## What the payer sees Using Klap's hosted checkout (`charge.checkoutUrl`) means you don't build any of this UI yourself. It's a mockup below, not a live screenshot, but it's the real layout: the amount, a method picker if the charge accepts more than one `(token, network)` pair, the QR code for whichever one is selected, and a status badge that updates live over the same SSE stream covered in [Real-time status](/realtime) — no refresh needed once the payer sends the transfer. If `checkoutUrl` comes back `null`, or you'd rather build your own UI, skip straight to building your own UI from `address`/ `acceptedPayments` — steps 2 onward below don't change. ## 1. Create the charge Tie it to your own order with `externalRef`, and set `redirectUrl` so the payer lands back on your site once it resolves: ```ts const charge = await klap.charges.create({ amount: order.total, acceptedPayments: [{ token: 'USDC', network: 'base' }], expiresIn: 1800, // 30 minutes externalRef: order.id, // your own id — this is what ties the webhook back to this order redirectUrl: `https://your-site.com/orders/${order.id}/thank-you`, }) await db.orders.update(order.id, { chargeId: charge.id, status: 'awaiting_payment' }) ``` ## 2. Send the payer to checkout ```ts res.redirect(charge.checkoutUrl) ``` That's the entire frontend for this flow — the checkout page, the QR code, the live status, all served by Klap. ## 3. Register a webhook (once, not per-charge) A one-time setup step, not something you do per order — see [Webhooks](/webhooks) for the full subscription options: ```ts await klap.webhooks.create({ url: 'https://your-site.com/webhooks/klap', events: ['charge.confirmed', 'charge.expired', 'charge.underpaid'], }) ``` `charge.confirmed` is "the payer paid" — usually the signal to fulfill the order. If you specifically need "the money is in my wallet," not just "the payer paid," subscribe to `charge.settled` instead (or in addition) — see [How it works](/how-it-works#settlement-settlementstatus) for why those are two different moments. ## 4. Handle the webhook Always verify the signature — this endpoint is a public URL: ```ts app.post('/webhooks/klap', (req, res) => { try { const event = klap.webhooks.constructEvent( req.rawBody, req.headers['x-klappay-signature'], process.env.KLAP_WEBHOOK_SECRET, ) if (event.event === 'charge.confirmed') { // event.data.externalRef is the order.id you set in step 1 db.orders.update(event.data.externalRef, { status: 'paid' }) } else if (event.event === 'charge.expired' || event.event === 'charge.underpaid') { db.orders.update(event.data.externalRef, { status: 'payment_failed' }) } res.sendStatus(200) } catch { res.sendStatus(400) // invalid signature — reject, don't process } }) ``` Delivery is at-least-once — this handler needs to be safe to run twice on the same `event.id` (an upsert-style `update`, like above, already is; an operation like "increment a counter" would need its own dedupe). See [Webhooks](/webhooks) for the full delivery/retry mechanics. ### No public endpoint yet? If you're building this locally and don't have a public URL to register, skip webhooks for now and `await charge.waitForConfirmation()` in the same request/job that created the charge instead — see [Charges](/charges#observing-a-charge-until-it-resolves). Switch to the webhook once you deploy somewhere reachable; the two aren't mutually exclusive; production integrations often use both: webhooks for correctness (never miss an event, even across a restart) and `waitForConfirmation()`/`waitFor()` where you already have an open request/job you're waiting on. ## Testing before going live Run this whole flow with a `test` key — real testnet, no real money — and use [Sandbox](/sandbox) to force a charge straight to `confirmed`, `underpaid`, or `expired` on demand, instead of waiting for real transfers, so you can verify the webhook handler and both branches of the order-status update above without round-tripping actual on-chain activity. ## See also * [How it works](/how-it-works) — the model behind `checkoutUrl` and the confirmed/settled distinction. * [Charges](/charges) — the full resource, including `waitForSettlement()`. * [Webhooks](/webhooks) — the full event vocabulary and signature scheme. * [Sandbox](/sandbox) — triggering every event this guide's webhook handler needs to cover. --- --- url: https://docs.klappay.com/realtime.md --- # Real-time (SSE) ## Overview Two endpoints push state changes live over Server-Sent Events instead of making you poll for them: `GET /v1/charges/{id}/events` (see [Charges](/charges)) and `GET /v1/distributions/pending/events` (see [Distributions](/distributions)). A third, `GET /v1/webhooks/listen`, streams your organization's whole webhook-delivery feed and is documented on [Webhooks](/webhooks) — all three share the exact mechanics on this page, each just projecting a different payload onto the same stream (a `Charge`, a pending distribution, a webhook delivery). All three require the same `Authorization: Bearer ` auth as the rest of `/v1/*`. The reason to reach for this over polling isn't just convenience — it's latency and load. A poll loop has a floor: whatever interval you pick, you're paying for requests that mostly come back unchanged, and you're always up to one interval late learning about the one that didn't. SSE has neither problem, because the stream is driven by the exact same code path that triggers every other side effect of a status change (webhook dispatch, etc.) — there's no separate timer generating these events, so you find out the moment the server does, and an idle charge costs nothing beyond one open connection. That includes changes that originate outside the request that opened the stream, such as a charge expiring on a schedule or a distribution settling: changes from any source reach your open stream exactly like one triggered by an inline API call would. ## Consuming a stream This is the raw mechanism — what `waitForConfirmation()`, `waitForSettlement()`, `waitFor()` (see [Charges](/charges)), and `streamPending()` (see [Distributions](/distributions)) do under the hood in the Node.js SDK. Reach for those typed wrappers first; read on if you're consuming the stream directly — a non-Node client, or custom logic beyond what the wait-helpers cover. ::: code-group ```bash [cURL] curl https://api.klappay.com/v1/charges/ch_abc123/events \ -H "Authorization: Bearer klap_live_..." \ -H "Accept: text/event-stream" ``` ```ts [Node.js] // What waitForConfirmation()/waitFor() do internally, minus the // polling fallback and typed resolve/reject logic layered on top. const res = await fetch('https://api.klappay.com/v1/charges/ch_abc123/events', { headers: { Authorization: `Bearer ${process.env.KLAP_API_KEY}`, Accept: 'text/event-stream', }, }) const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let boundary: number while ((boundary = buffer.indexOf('\n\n')) !== -1) { const frame = buffer.slice(0, boundary) buffer = buffer.slice(boundary + 2) if (frame.startsWith(':')) continue // heartbeat comment — not a payload const data = frame .split('\n') .find((line) => line.startsWith('data: ')) ?.slice('data: '.length) if (!data) continue const charge = JSON.parse(data) console.log(charge.status, charge.settlementStatus) } } ``` ::: Every frame is a plain `event: ` / `data: ` pair — `event: charge` carrying a full `Charge` on the charges endpoint, `event: distribution` carrying a discriminated-union payload on the distributions endpoint (see [Distributions](/distributions) for its exact shape and the `?limit=` connect-time snapshot option). Any standard `EventSource`-compatible client can consume either. ### `event: confirmation_progress` The charges stream carries a second event type alongside `event: charge`. Once a transfer has been detected but hasn't yet reached its network's required confirmation depth, `event: confirmation_progress` fires with `{ network, blocksSeen, blocksRequired, percent }` — enough to render a real progress bar during the wait instead of an indeterminate spinner. It stops once the transfer is credited, which `event: charge` announces on its own. This exists only on the live stream. It is not part of `GET /v1/charges/{id}`, so a client on the SDK's polling fallback never sees it — treat it as an enhancement to the waiting experience, never as the signal that decides whether a charge was paid. The same payload is also returned by [`POST /charges/{id}/check`](/charges#forcing-an-on-chain-re-check) as `confirmationProgress`, which is how a poll-only client can still approximate it. In the Node.js SDK there are two ways in. The wait helpers take an `onConfirmationProgress` callback: ```ts await charge.waitForConfirmation({ onConfirmationProgress: (p) => console.log(`${p.network}: ${p.percent}%`), }) ``` Or consume both event types on one connection with `watchEvents()`, discriminating the union with the exported type guards: ```ts 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) } } ``` `watch()` is the same stream filtered down to `Charge` updates only — reach for `watchEvents()` when you want the progress events too. **Browsers' built-in `EventSource` can't set the `Authorization` header this endpoint requires** — that's why the example above uses `fetch` with a manually-parsed body instead of `new EventSource(...)`. If you need this in browser code, consume the stream from your own backend and relay it to the frontend over a connection you control (another SSE stream, a WebSocket, whatever fits your stack) rather than pointing a browser `EventSource` at the Klap API directly. ## Stream lifecycle 1. **Connect** — the current state is sent immediately as the first event, before waiting for anything to change. (Distributions is the one exception by default — see [Distributions](/distributions) for why its stream sends no initial snapshot unless you pass `?limit=`.) 2. **Push** — every subsequent change is sent as it happens, with no fixed interval to wait out. 3. **Heartbeat** — a `: ping` comment-only line every 30s, solely to keep the connection alive through proxies and load balancers that time out idle connections. It carries no data — consuming code should skip comment lines (anything starting with `:`), not treat them as a payload, exactly like the `if (frame.startsWith(':')) continue` above. 4. **Close** — the stream ends on whichever comes first: * the resource reaches a terminal state — for a charge, `expired`, or `confirmed`/`underpaid` with `settlementStatus` resolved (see [Charges](/charges)); for a distribution, it's removed from the feed once claimed, not the stream itself closing; * a belt-and-suspenders timeout past the resource's own natural deadline (a charge's `expiresAt` plus a short grace window), in case a terminal-state event is ever missed; * the client disconnects. There's no `Last-Event-ID` replay on reconnect — a dropped and reopened stream starts from "current state as of now," not a gap-filled replay of whatever happened while you were disconnected. For charges this is self-healing, because reconnecting re-sends the current state as the first event per the **Connect** step above; for a delta-only feed like distributions, that's exactly why [Distributions](/distributions) recommends treating both the list snapshot and every event as an idempotent add/remove against one local map, rather than trusting the stream never to have a gap. ## Limitations **Backpressure.** Each API key may hold at most 20 concurrent SSE streams across every authenticated SSE endpoint (`/charges/{id}/events`, `/distributions/pending/events`, `/webhooks/listen` alike), so one noisy key can't crowd out the rest of your integration. Past that cap, a new connection attempt gets `503 sse_capacity` rather than queueing. This is independent of the general `/v1/*` rate limiter, which caps request *rate*, not concurrent open *connections*, and wouldn't catch a backlog of idle streams at all. Sizing follows from the intended use: one stream per resource a caller is actively watching in real time, not a way to hold open thousands of idle long-lived subscriptions. If your integration needs to watch many resources at once (bulk reconciliation, an internal dashboard), prefer listing with a cursor/`since` filter instead and reserve SSE for the handful a user is actively waiting on. **No sticky sessions.** Any connection receives every update, so there is no reconnect-to-a-specific-host workaround and no sticky-session requirement on your load balancer. **Polling fallback.** If a stream can't be opened at all, or drops and a reconnect doesn't come back cleanly, that's a permanently missed window under the no-replay behavior described above — which is why the Node.js SDK doesn't rely on the stream alone. `waitForConfirmation()` and friends open the stream first but transparently fall back to polling `GET /charges/{id}` for the remainder of the timeout budget if the stream fails, with backoff — see [Charges](/charges) for the exact `pollIntervalMs`/`timeoutMs` semantics. If you're consuming the raw stream yourself outside the SDK, you're responsible for the equivalent fallback: treat a stream that won't (re)connect as a signal to poll the resource directly, not as a reason to retry the stream indefinitely. ## See also * [Charges](/charges) — `waitForConfirmation()`, `waitForSettlement()`, and `waitFor()`, the typed Node.js SDK wrappers around `/charges/{id}/events`. * [Distributions](/distributions) — `streamPending()`, the equivalent wrapper around `/distributions/pending/events`, plus the connect-time `?limit=` snapshot option and the idempotent add/remove pattern for its delta-only feed. --- --- url: https://docs.klappay.com/errors.md --- # Errors Every non-2xx REST response, and every SDK method that can fail, uses one consistent shape — this page is the cross-cutting reference for both, since every resource can fail this way. ## REST API ### The error envelope ```json { "error": { "code": "validation_error", "message": "amount must be positive", "param": "amount" } } ``` * `code` — a stable, machine-readable identifier. Safe to branch on in code. It's a plain `string`, not a fixed enum — the set of codes is per-endpoint, and the [API Playground](https://api.klappay.com/) is the authoritative source for exactly which codes a given endpoint can return (every response in its spec names the real code(s)). * `message` — human-readable, safe to log or show a developer. Not meant to be shown to end users verbatim. * `param` — which request field the error refers to, when applicable (omitted for errors that aren't about one specific field). ### Codes you'll actually see Pulled straight from the live OpenAPI spec, grouped by what triggers them — not an exhaustive list of every endpoint, but the shapes that repeat across the API: | Code | When | |---|---| | `validation_error` | Request body/query failed schema validation (bad shape, out-of-range value). | | `missing_api_key` / `invalid_api_key` | No `Authorization` header, or the key doesn't parse/verify. | | `insufficient_scope` | The key is valid but missing the scope the endpoint needs (e.g. `charges:write`) — see [Authentication](/authentication). | | `invalid_cursor` | A pagination `cursor` that wasn't returned verbatim from a previous response. | | `rate_limited` | Exceeded a rate limit (e.g. 20 req/min on `/distributions/*`, 60 req/min on `/metrics/*`). | | `charge_not_found` / `webhook_not_found` / `delivery_not_found` | The `{id}` in the path doesn't exist (or doesn't belong to your organization). | | `recipient_not_found` | A `DELETE`/`PATCH /recipients/{id}` on an id that doesn't exist for your organization and environment — or one that's payout-eligible when the key lacks `recipients:manage_payout`. A second `DELETE` returns it too — see [Recipients](/recipients#revoking-a-recipient). | | `recipient_not_found_in_split` | A `splitRecipients[].recipientId` on `POST /charges` didn't resolve (wrong id, wrong environment, or revoked) — see [`splitRecipients`](/charges#splitrecipients). | | `conflicting_api_key_scopes` | The key holds `charges:split_write` together with `recipients:write`/`recipients:manage_payout`, which is never allowed — see [Authentication](/authentication#scopes). | | `swap_test_environment_unsupported` / `swap_unavailable` / `swap_quote_failed` | `POST /charges/{id}/quote`: `test` charges can't swap at all (422), swap isn't available on the deployment (503), or the upstream quote failed, often for liquidity (503, safe to retry) — see [swap-to-pay](/charges#paying-with-another-token-swap-to-pay). | | `token_not_supported` | An `acceptedPayments` pair has no deployed contract for your key's `environment` — see [Networks & tokens](/networks). | | `idempotency_key_reused` | Same `idempotencyKey`, different request body — see [Charges](/charges). | | `charge_not_payable` | Charge is already `confirmed`/`expired`/`underpaid` — no longer accepting payment. | | `escrow_already_released` / `escrow_already_refunded` | An [escrow charge](/charges#releasing-or-refunding-an-escrow) already moved its balance. Release and refund are mutually exclusive and each charge gets exactly one of them. | | `payment_pair_required` / `payment_pair_not_accepted` | QR code request needs `token`/`network` disambiguation (charge accepts more than one pair) or named a pair the charge doesn't accept. | | `environment_mismatch` | A `live` key querying `test` data (or vice versa) via a filter param. | | `invalid_webhook_url` / `insecure_webhook_url` / `unresolvable_webhook_url` / `unsafe_webhook_url` | The webhook `url` isn't a valid HTTPS URL resolving to a public address — Klap refuses to register a webhook pointed at `localhost`/private IP ranges. | | `webhook_limit_reached` | Hit the per-organization cap on active webhooks. | | `invalid_trigger_state` | Sandbox: the charge isn't in a state that event can fire from. | | `charge_not_test_environment` | Sandbox: trying to trigger an event on a `live`-environment charge (only `test` charges can be simulated). | | `rpc_unavailable` / `sse_capacity` | Transient infrastructure error (blockchain RPC unreachable, too many concurrent SSE streams) — safe to retry shortly, not a request problem. | ### Rate limits Limiters stack — a request can be rejected by whichever one it hits first — all fixed per-minute windows: * **100 requests/minute per source IP**, on every `/v1/*` request — including unauthenticated ones like `GET /health`. Keyed off the request's IP, not the API key, so multiple keys called from behind the same IP (a shared NAT, a proxy) share this budget. * **100 requests/minute per organization**, also per IP-limit-independent, for every authenticated route (`/charges`, `/webhooks`, `/sandbox`, `/distributions`, `/networks`, `/metrics`, `/recipients`) — this is the one that actually matters once you're past initial integration, since it follows your tenant regardless of which key or IP is calling. * **`/distributions/*` additionally gets a tighter 20 requests/minute, and `/metrics/*` 60 requests/minute**, both per organization — so they're the first thing you'll hit if you're polling either route on a tight loop, well before the 100/min organization limit above even comes into play. There's **no `X-RateLimit-Remaining`-style header** on any response today — nothing to poll to see how close you are before hitting `429`. The only signal is the `Retry-After` header (seconds) on a `429 rate_limited` response itself, so build retry/backoff around that rather than trying to stay under a budget you can't observe in advance. A batch job hitting `/metrics/query` or `/distributions/pending` in a tight loop is the most likely place to hit this in practice — pace those deliberately rather than firing requests back-to-back. ## Node.js SDK 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. ### `KlapApiError` Thrown for any non-2xx response from the API — wraps the envelope above one-to-one. ```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 code, e.g. 'validation_error' console.log(err.message) // human-readable, not for end users console.log(err.param) // which field, when applicable } } ``` ### Errors from `waitForConfirmation()` / `waitForSettlement()` These reject instead of resolving with a charge you'd have to inspect — see [Charges](/charges) for the full behavior. | Error | Thrown when | |---|---| | `ChargeExpiredError` | `waitForConfirmation()` — `status` reached `expired` (nobody paid before `expiresAt`). | | `ChargeUnderpaidError` | `waitForConfirmation()` — `status` reached `underpaid` (partial payment, then `expiresAt` passed). | | `SettlementFailedError` | `waitForSettlement()` — `settlementStatus` reached `failed` (retries exhausted; rare, contact support). | | `WaitTimeoutError` | Either method — `timeoutMs` elapsed before a terminal state was reached. | Each carries a `chargeId` property; `WaitTimeoutError` also carries the configured `timeoutMs`. ```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, check again later */ } else throw err } ``` ### Webhook verification errors * **`InvalidWebhookSignatureError`** — thrown by `klap.webhooks.constructEvent()` when the signature doesn't match. * **`WebhookTimestampToleranceError`** — thrown 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) and `toleranceSeconds`. See [Webhooks](/webhooks)'s "Verifying and parsing an inbound webhook". ### `MissingCredentialError` Thrown immediately, client-side, when you call a method that needs an `apiKey` you didn't provide to `createClient()` — pass it there, or call `klap.setApiKey()` first. Never reaches the network. ## Health check `GET /health` — unauthenticated, for uptime monitoring. `status` mirrors the HTTP status code (`error` and `503` together when the database check fails), so a plain status-code-only check still catches an outage without inspecting the JSON body. ```bash curl https://api.klappay.com/v1/health ``` [→ Try `GET /health` in the API Playground](https://api.klappay.com/#tag/health/GET/health) ```json { "status": "ok", "version": "0.1.0", "timestamp": "2026-08-11T12:00:00.000Z", "db": "ok" } ``` Build monitoring on the HTTP status code (or `status`/`db`). The response may carry additional operational fields beyond these; they're not part of the integration contract, so don't alert or branch on them. ## Types `ErrorPayloadSchema`/`ErrorPayload` and `HealthSchema`/`Health` from `@klappay/types` mirror the shapes above exactly — import them if you want to type a caught error or a health-check response without redeclaring the shape yourself. ## See also * [Charges](/charges) — the typed `waitFor*` rejections in context. * [Webhooks](/webhooks) — signature verification and replay protection. * [Authentication](/authentication) — scopes, and what `insufficient_scope` means for a given key. --- --- url: https://docs.klappay.com/sdk/tree-shaking.md --- # Tree-shaking and minimal bundles The `@klappay/node` 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) ``` See [Webhooks](/webhooks) for the full verification flow, including `constructWebhookEvent` and replay-protection details. --- --- url: https://docs.klappay.com/mcp.md --- # AI assistants (MCP) ## Overview [`@klappay/mcp`](https://www.npmjs.com/package/@klappay/mcp) is an open-source [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI assistant — Claude Code, Claude Desktop, Cursor, or any MCP client — call the Klap API on your behalf: look up a charge and its timeline, check which webhook deliveries failed, run a metrics query, or create a test charge and push it through the sandbox. It runs **on your own machine**, started by the MCP client over stdio with `npx`. Nothing is hosted by Klap: the server talks straight to `https://api.klappay.com/v1` with your own API key, exactly like an integration built on [`@klappay/node`](/getting-started) would — every tool is a thin wrapper around one SDK method, so the same [scopes](/authentication#scopes), rate limits and [errors](/errors) apply. This is different from [`llms.txt`](/getting-started#for-llms-and-agents): `llms.txt` lets an agent *read* these docs; the MCP server lets it *act* on your account. The source is at [github.com/klappay/klap-mcp](https://github.com/klappay/klap-mcp) (MIT), and its own reference lives at [mcp.klappay.com](https://mcp.klappay.com). ## Install Requires Node.js 24+. Store the key once with the [Klap CLI](https://cli.klappay.com) so it lives in `~/.klap/config.json` (created with `0600` permissions) instead of in your MCP client's config file, which is often synced, shared or committed: ```sh npx @klappay/cli login --api-key - --base-url https://api.klappay.com/v1 ``` Paste a `klap_test_...` key on stdin and press Ctrl-D — `--api-key -` keeps it out of your shell history. A live key can be stored the same way later; test and live keys sit in separate slots of the same file. Then add **one server per environment**. Keeping test and live as separate servers means the assistant always knows which one it's talking to, and you can remove live without touching test. ::: code-group ```sh [Claude Code] claude mcp add klap-test -- npx -y @klappay/mcp@1.0.0 claude mcp add klap-live -e KLAP_ENV=live -- npx -y @klappay/mcp@1.0.0 ``` ```json [Claude Desktop / Cursor] { "mcpServers": { "klap-test": { "command": "npx", "args": ["-y", "@klappay/mcp@1.0.0"] }, "klap-live": { "command": "npx", "args": ["-y", "@klappay/mcp@1.0.0"], "env": { "KLAP_ENV": "live" } } } } ``` ::: Claude Desktop reads `claude_desktop_config.json`, Cursor reads `~/.cursor/mcp.json` — same shape. Pin an exact version as above rather than `@latest`: the server runs with your API key, so upgrade it on purpose, after reading its changelog. On startup the server prints one line to the client's MCP log — `klap-mcp: test → api.klappay.com` — and never the key. If it refuses to start, the same log has a one-line reason (see [Configuration](#configuration)). Then ask things like: * "Which Klap environment are you connected to?" * "Create a 25 USD test charge payable in USDC on Base, expiring in 15 minutes." * "Mark that charge as confirmed in the sandbox, then show me its timeline." * "Which of my webhooks had failed deliveries this week?" ## Test vs. live | | `test` (default) | `live` | `live` + `KLAP_MCP_ALLOW_LIVE_WRITES=1` | |---|---|---|---| | Read tools | yes | yes | yes | | `charges_create`, `charges_check`, `webhooks_retry_delivery` | yes | no | yes | | `sandbox_trigger` | yes | no | no | **Live is read-only by default.** Write tools aren't hidden behind a prompt or a confirmation — in live they're simply never registered, so an assistant can't call them whatever it's told (including by text it reads back from the API, like a charge's `metadata` or a webhook URL). Only add `KLAP_MCP_ALLOW_LIVE_WRITES=1` (exactly `1`) if you really want the assistant creating live charges or re-sending live webhook deliveries. A dedicated key with only the [scopes](/authentication#scopes) the assistant needs — e.g. `charges:read` + `webhooks:read` + `metrics:read` for a live, read-only server — caps what it can do at the API itself, independently of the server's own rules. ## Tools Every result is JSON carrying an `environment` field (`test`/`live`). API responses are parsed through the published [`@klappay/types`](/getting-started) schemas before the assistant sees them: unknown fields are dropped, and a response that doesn't match becomes an `unexpected_response` error instead of being passed through. | Tool | SDK method | REST | Notes | |---|---|---|---| | `klap_status` | — | — | Returns `{ environment, host }`. No API call. | | `charges_get` | `charges.get(id)` | `GET /charges/{id}` | `metadata` is left out unless `includeMetadata: true` — it's free-form merchant data that may hold customer details. See [Charges](/charges). | | `charges_timeline` | `charges.getTimeline(id)` | `GET /charges/{id}/timeline` | See [Audit trail](/charges#audit-trail). | | `webhooks_list` | `webhooks.list()` | `GET /webhooks` | Each `url` is reduced to origin + path (credentials, query string and fragment removed). Secrets are never returned, only the `hint`. | | `webhooks_list_deliveries` | `webhooks.listDeliveries(id, page)` | `GET /webhooks/{id}/deliveries` | `limit` 1–100 (default 20), `cursor` from the previous page. | | `networks_get` | `networks.get()` | `GET /networks` | The `(token, network)` pairs this environment accepts. See [Networks & tokens](/networks). | | `metrics_query` | `metrics.query(query)` | `POST /metrics/query` | Takes a `MetricsQuery`. See [Metrics](/metrics). | | `charges_create` | `charges.create(input)` | `POST /charges` | `CreateCharge` without `escrow` and `redirectUrl`. Pass `idempotencyKey` to make a retry safe — without it, every call creates a new charge. | | `charges_check` | `charges.check(id, input?)` | `POST /charges/{id}/check` | Optional `txHash` + `network`. See [Forcing an on-chain re-check](/charges#forcing-an-on-chain-re-check). | | `webhooks_retry_delivery` | `webhooks.retryDelivery(id, deliveryId)` | `POST /webhooks/{id}/deliveries/{deliveryId}/retry` | The receiving endpoint processes that event again. | | `sandbox_trigger` | `sandbox.trigger(id, event, amount?)` | `POST /sandbox/charges/{id}/trigger` | `charge.confirmed`, `.partially_paid`, `.overpaid`, `.expired`, `.underpaid`, `.settled` or `.settlement_failed`. See [Sandbox](/sandbox). | Ids are validated before any request is sent (`ch_...`, `wh_...`, `ev_...`). A failed call returns `isError: true` with the API's own `code`/`status`/`message` — the same codes listed in [Errors](/errors). ## Not exposed on purpose | Not available | Why | |---|---| | Escrow release / refund | They move funds out of a charge — that stays a deliberate human action. See [Releasing or refunding an escrow](/charges#releasing-or-refunding-an-escrow). | | Webhook create / delete / rotate secret | Pointing deliveries elsewhere, or rotating a secret, can silently break or redirect your integration. | | Recipient changes | They decide where money goes. See [Recipients](/recipients). | | Live event streams | A long-lived [SSE stream](/realtime) doesn't fit a request/response tool. | | QR codes and swap quotes | Payer-facing features with no use to an assistant. | For any of these, use the [Dashboard](https://app.klappay.com), the [CLI](https://cli.klappay.com) or the SDK directly. ## Configuration | Variable | Default | Meaning | |---|---|---| | `KLAP_ENV` | `test` | `test` or `live`, exactly. Anything else refuses to start. | | `KLAP_API_KEY` | unset | Use this key instead of `~/.klap/config.json`. Requires `KLAP_BASE_URL`; its prefix must match `KLAP_ENV`. | | `KLAP_BASE_URL` | stored base URL | Required with `KLAP_API_KEY`. With a stored key it must equal the stored base URL. | | `KLAP_MCP_ALLOW_LIVE_WRITES` | unset | Exactly `1` registers the live write tools. No effect in `test`. | The server refuses to start, rather than guess, whenever the setup is ambiguous: a `klap_live_` key with `KLAP_ENV` unset, a stored key paired with a different `KLAP_BASE_URL` (a stored key is only ever sent to the host it was stored with), or a plain `http://` base URL on anything but `localhost`. Unlike the CLI, it never picks live on its own — if only a live key is stored, set `KLAP_ENV=live`. Passing `KLAP_API_KEY` + `KLAP_BASE_URL` directly in the client's `env` works for CI or a throwaway setup, but puts the key in that config file; prefer `klap login` on a workstation. The full list of refusals and how to fix each is at [mcp.klappay.com/configuration](https://mcp.klappay.com/configuration). ## See also * [Authentication](/authentication) — key format, scopes, rotation and revocation; revoke the key the server uses the same way as any other. * [Sandbox](/sandbox) — what `sandbox_trigger` does to a charge, and which events can't be triggered. * [mcp.klappay.com](https://mcp.klappay.com) — the server's own docs, with [`llms-full.txt`](https://mcp.klappay.com/llms-full.txt).