---
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).