--- url: https://api.klappay.com/types/getting-started.md --- # Getting started ## Install ```bash npm install @klappay/types zod ``` `zod` is a peer expectation, not bundled — every schema exported here *is* a `zod` schema, so if you already depend on any of them directly you already have it. ## What this package actually is Two things, exported side by side for every resource: * A **Zod schema** (`ChargeSchema`, `CreateChargeSchema`, `WebhookPayloadSchema`, ...) — use it to validate data at runtime, not just to satisfy the type checker. * An **inferred TypeScript type** for the same shape (`Charge`, `CreateChargeInput`, ...), exported alongside its schema. Both come from the same source — the schema is the source of truth, the type is `z.infer` on top of it. There is no separate hand-written type anywhere in this package that could drift from its schema. A request-body/query schema that has a default or transform (e.g. `CreateChargeSchema.mode`, `ListChargesSchema.limit`) also exports a `*Request` type — `z.input` instead of `z.infer`, i.e. the shape *before* Zod applies defaults/transforms. Use the `*Input` type for a value you already parsed (every field a default fills in is guaranteed present); use `*Request` for a value you're building to send (those same fields are optional, since the schema fills them in for you). `CreateChargeInput`/`CreateChargeRequest`, `ListChargesInput`/`ListChargesRequest`, and their equivalents on every other list endpoint are the concrete examples. This package has **no HTTP client, no fetch, no networking of any kind** — it only describes shapes. If you want a client that actually calls the API for you (with typed errors, retries, real-time polling helpers), an official SDK is built on top of this package — available at [github.com/klappay](https://github.com/klappay); see the live API reference for details. Use `@klappay/types` directly when you're calling the API yourself (a different language's types, a codegen step, validating a webhook payload in a framework the SDK doesn't fit) and just want the contracts. ## Typing an API response ```ts import type { Charge } from '@klappay/types' async function getCharge(id: string): Promise { const res = await fetch(`${API_BASE_URL}/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) // payload.data is `unknown` here — this schema only validates the // envelope (id/event/createdAt). See webhooks.md for how to narrow // `data` per event with TypedWebhookPayload. res.sendStatus(200) }) ``` This package only validates *shape* — it never verifies the `X-Klappay-Signature` HMAC header on a webhook request. Do that yourself (or use an official SDK's `constructEvent()`, which does both at once — available at [github.com/klappay](https://github.com/klappay), see the live API reference for details) before trusting the payload came from Klappay. ## Finding the type you need Every exported schema/type lives in exactly one topic file below — if you're not sure where something is, it's one of these: | Doc | Covers | |---|---| | [`charges.md`](./charges.md) | `Charge`, `CreateChargeInput`, `ListChargesInput`, `PaginatedCharges`, `ChargeStatusEvent`, plus the read-only `TimelineEvent` view. Also covers the shared `PaginationQuerySchema`/`paginatedSchema()` primitives every list endpoint is built from | | [`webhooks.md`](./webhooks.md) | Every `WebhookEventType`, category grouping, `CreateWebhookInput`, `Webhook`, `WebhookPayload`, `TypedWebhookPayload`, `WebhookDelivery` | | [`sandbox.md`](./sandbox.md) | `SandboxTriggerInput`, `TriggerableChargeEvent` | | [`tokens-and-networks.md`](./tokens-and-networks.md) | `Token`, `Network`, `TOKEN_ADDRESSES`, `EVM_NETWORKS`, and every other chain/token constant | | [`errors-and-health.md`](./errors-and-health.md) | `ErrorPayload` (every non-2xx response shape) and `Health` | ## Where to go next Read this package's own `README.md` first if you haven't — it covers install and the two usage patterns (typing vs. runtime validation) at a glance. This `docs/` folder is for finding a *specific* schema quickly once you already know roughly what you're looking for. ## For LLMs and agents This site (built from these same files with VitePress) publishes [`llms.txt`](/llms.txt) — a link index of every doc page — and [`llms-full.txt`](/llms-full.txt) — the full content of every doc page concatenated into one plain-text file. Point an agent, RAG pipeline, or MCP server at either as a lightweight way to give it this package's whole documentation without scraping HTML. Both regenerate on every deploy, so they never drift from what's on this page. The live API itself is separately machine- readable too — /v1/openapi.json is the same OpenAPI document the Scalar reference renders. Every link above is deliberately relative, not a hardcoded `api.klappay.com` URL — this same file is served on more than one domain (production and stage), and a relative link resolves against whichever one the page is actually being viewed on. `[text](/llms.txt)`-style markdown links get VitePress's own `base` (`/types/`) prepended automatically, which is what's wanted for `llms.txt`/`llms-full.txt` (they live under `/types/`) — but `/v1/openapi.json` does *not* live under `/types/`, so it's a raw HTML `` tag instead, specifically to bypass that automatic prepending (markdown link syntax has no way to opt out of it). `llms.txt`/ `llms-full.txt`'s own *generated* internal links are the one place an absolute domain is unavoidable (an agent that only fetched the raw text file has no page origin to resolve a relative link against) — that one comes from `PUBLIC_URL` at Docker build time, see `docs-types/.vitepress/config.mts` and `docs/operations.md`. --- --- url: https://api.klappay.com/types/charges.md --- # Charges The core resource. Everything here is exported from `charges.ts` unless noted otherwise. ## Creating a charge `CreateChargeSchema` / `CreateChargeInput` — the body of `POST /v1/charges`. `CreateChargeInput` is the post-parse shape, where `currency` is always present (the schema defaults it) — type a pre-parse request object as `CreateChargeRequest` instead, where it's optional: ```ts import { CreateChargeSchema, type CreateChargeRequest } from '@klappay/types' const request: CreateChargeRequest = { amount: 49.9, expiresIn: 1800, acceptedPayments: [ { token: 'USDC', network: 'base' }, { token: 'USDC', network: 'optimism' }, { token: 'USDT', network: 'base' }, ], } CreateChargeSchema.parse(request) // throws on anything invalid ``` Fields worth knowing about: * `amount` — required, `.positive()`, up to `CHARGE_AMOUNT_MAX`. Every charge has a real target amount — there's no "pay what you want" mode anymore. With `feePayer: 'payer'` (below), this is *your* desired net amount, not the total the payer ends up sending — see `feePayer`. * `feePayer` (`ChargeFeePayerSchema`, `'merchant' | 'payer'`, defaults to `'merchant'`) — who covers Klappay's fee. `'merchant'`: `amount` is exactly what you set it to, and the fee comes out of your own payout (you net `amount * (1 - feePercent / 100)`). `'payer'`: `amount` is grossed up at creation time so that, after the same fee deduction, you still net the value you passed in — the response's `amount` reflects the larger, fee-inclusive total, and `merchantAmount` echoes back what you originally asked for. Frozen at creation, and included in the idempotency-key hash — see `docs/payments.md`'s "Who pays: `feePayer`" section in the main repo for the exact gross-up formula. * `acceptedPayments` — `AcceptedPaymentSchema[]` (`{ token: Token, network: Network }`), at least one entry, up to `CHARGE_ACCEPTED_PAYMENTS_MAX` (`18` — 2 tokens × 9 operational networks, the actual ceiling of distinct pairs that can exist today, not an arbitrary round number). The payer can pay with *any* pair in this list — the on-chain address is identical no matter which one they use (0xSplits addresses are chain-agnostic), so offering more than one is just a matter of listing more pairs, not generating more addresses. Duplicate pairs in the array are rejected with `400 validation_error` at the schema level. Each pair's `network` must be in `OPERATIONAL_NETWORKS`, and the exact `(token, network)` combination must have metadata in `TOKEN_DEPLOYMENTS` for your key's `environment` and pass `isPaymentDeploymentEnabled` — an unconfigured or disabled pair anywhere in the array rejects the *whole* request with `422 token_not_supported`, naming the first bad index (e.g. `param: "acceptedPayments[1]"`), not silently dropped. Not every token is deployed on every `network`/`environment` — `USDT` has no `test` deployment on `base`, `optimism`, or `ethereum` (Tether doesn't issue an official Sepolia one, unlike Circle's `USDC`); `arbitrum`, `polygon`, and `avalanche` have no `test` environment at all yet, for either token (0xSplits itself has no Arbitrum Sepolia deployment and no Polygon or Avalanche Fuji testnet support); `bnb` has no `test` environment either, and both of its cataloged live contracts are Binance-Peg assets with 18 decimals — see [`tokens-and-networks.md`](./tokens-and-networks.md) for deployment metadata. Call `GET /v1/networks` to get the live matrix for your environment instead of hardcoding it client-side — it reads the exact same deployment metadata and availability policy as this validation. * `currency` — always `"USD"` today (`z.literal('USD')`), the only supported value. Present as a field (not hardcoded) so a second currency can be added later without a breaking shape change. * `expiresIn` — required, seconds the charge stays open: `CHARGE_EXPIRES_IN_MIN_SECONDS` (60) to `CHARGE_EXPIRES_IN_MAX_SECONDS` (3600 — 60 minutes). Sized off real on-chain confirmation latency across every chain Klappay supports — Ethereum is the slowest at roughly 15 minutes for a safe confirmation margin, so 60 minutes leaves comfortable headroom on top of that for payer-side delay (gas spikes, wallet friction). Cannot be extended or shortened after creation. * `idempotencyKey` — scoped to your tenant. Replaying the same key **with the exact same request body** returns the *original* charge unchanged instead of creating a duplicate — the safe way to retry a request after a timeout. Reusing a key with a *different* body (a different `amount`, say) is a `409 idempotency_key_reused` error, not a silent return of the original charge — an idempotency key means "retry this exact request," not "look up a charge by this string." * `externalRef` — an opaque id from your own system (e.g. an order id), echoed back on the charge and every webhook payload. Not interpreted by Klappay. * `source` — a free-form label for what created this charge (e.g. `"checkout"`, `"invoice"`) if you create charges from more than one flow. Not a fixed enum on purpose — use whatever values make sense to you. * `metadata` — arbitrary key/value data, returned as-is on every read. Put anything you want here — none of it is validated, and Klappay never interprets it. **One key is reserved: `metadata.klappay`.** Its format is defined and enforced by Klappay (`KlappayCheckoutMetadataSchema`, `checkout-metadata.ts`), not by you — if you set it, it must match that schema exactly, or the *entire* `POST /v1/charges` request is rejected with `400 validation_error`, same as getting any other field wrong. Every other key in `metadata` is unaffected by this — only `klappay` itself is checked. Today `metadata.klappay.products` is the one thing it carries: up to `CHECKOUT_PRODUCTS_MAX` (20) items (`name`, optional `quantity`, optional `imageUrl`) shown on Klappay's hosted checkout page so the payer knows what they're buying. Purely informational — never validated against `amount`, never used by any payment or distribution logic. `imageUrl` must be `http(s)` and is only ever fetched by the payer's own browser, never by Klappay's servers. * `redirectUrl` — where to send the payer once the charge resolves, if you use Klappay's hosted checkout page (see `checkoutUrl` below). Ignored for direct API integrations. Optional, and only validated as a well-formed URL — Klappay never inspects what's at that destination. * `splitRecipients` — optional, up to `CHARGE_SPLIT_RECIPIENTS_MAX` (5) extra recipients to route a slice of this charge to (e.g. a supplier, or whoever closed the sale), each `{ recipientId, percent, label? }`. **Not a raw address** — `recipientId` must reference a `Recipient` you already registered for your organization/environment via `POST /v1/recipients` (see `recipients.md`), and using this field at all requires the `charges:split_write` scope in addition to `charges:write` (see `api-keys.md`). A `recipientId` that doesn't resolve — wrong id, wrong environment, or revoked — is rejected with `422 recipient_not_found_in_split`, identically in every case, never a distinct error that would confirm it exists elsewhere. **`percent` is of *your own* net share (`100 - feePercent`), not of the charge's gross `amount`** — the one genuinely non-obvious unit here. Klappay's fee is computed on the gross amount first and is never diluted by how you split what's left; see `docs/payments.md`'s "Settling the payout" section for the exact math. The sum of every `percent` must fit within `100 - feePercent` or the request is rejected with `422 split_recipients_exceed_available_percent`. No recipient may resolve to Klappay's treasury address (`422 split_recipient_is_treasury_address`) or your own payout address (`422 split_recipient_is_payout_address`) — the latter isn't just redundant, it's rejected by the underlying 0xSplits split contract itself if it ever reached one, so it's caught here instead, before the charge is even created. Frozen at creation exactly like everything else that shapes the split address — including against a recipient revoked in the moments between resolving it and the charge actually being persisted. **Token issuer restrictions.** Before the charge address is predicted, every address the charge would pay — your payout address, each `splitRecipients` entry, and Klappay's fee treasury — is checked against each accepted pair's own token contract (USDC's `isBlacklisted`). If the token issuer restricts one of them from *receiving* on a pair, the request fails with `422 recipient_transfer_restricted` (`param: 'acceptedPayments'`), and the message names only the affected `TOKEN on network` pairs — a multi-network charge where only one pair is affected tells you exactly which pair to drop. Your payout address is named as "your payout address", a split recipient by its `recipientId` (never its raw address); a restriction on Klappay's own treasury is reported only as the pair being "currently unavailable for this charge". USDT's contracts only ever check the *sender* of a transfer, so a USDT pair is never rejected this way. A check that can't be answered within 1 second (RPC failure, or a network where the token has no such function) never blocks the charge. To see this ahead of time, call `GET /v1/recipients/{id}/transferability` (see `recipients.md`). Why these restrictions exist, who sets them, and what happens to a payout when an address is restricted after payment is covered in [token restrictions](./token-restrictions.md). `CHARGE_AMOUNT_MAX` (`999_999_999_999`) is exported too — it's the real ceiling `amount` is validated against, matching the `Decimal(18, 6)` column holding the commercial charge target. The target retains six fractional digits. Received amounts preserve up to eighteen fractional digits; they are not rounded to the target's precision before comparison. Use the exact string fields described below when consuming those values. ## The `Charge` shape `ChargeSchema` / `Charge` is what every read (`GET /v1/charges/{id}`, `GET /v1/charges`, and every webhook's `data` for a `charge.*` event) returns. A few fields that aren't self-explanatory: * `amount` — the total the payer must send as a legacy JSON number, set at creation, never `null` and never changes after creation. With `feePayer: 'payer'` this already includes Klappay's fee — see `merchantAmount` for what you'll actually net. This numeric field can lose precision. * `amountExact` — the same commercial target as a decimal string in currency units, with up to six fractional digits and no scientific notation. Prefer this field for arithmetic and payment preparation. * `feePayer` — echoes what was set (or defaulted to `'merchant'`) at creation. * `feePercent` — Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%), including any escrow surcharge. Frozen at creation. * `feeAmount` — `amount * feePercent / 100`, in `currency` units. Computed on every read, not stored — never drifts from `amount`/ `feePercent`. * `merchantAmount` — `amount - feeAmount`, what you actually net once the payout settles. The same formula regardless of `feePayer` — that field only affects what `amount` was set to in the first place, not how this is computed. * `acceptedPayments` — echoes exactly what was configured at creation; never changes afterward, regardless of which pair actually ends up paid. * `swapAlternatives` (`SwapAlternative[]`, `{ token, network }`) — non-stablecoin cryptocurrencies the payer can pay with instead, via swap-to-pay (see below), derived from `acceptedPayments`' own networks and recomputed on every read. Empty on a `test`-environment charge or when this deployment has no swap-to-pay configured. * `paidWith` (`AcceptedPayment[]`) — every distinct `(token, network)` pair that has actually contributed a credited transfer so far. Empty until the first transfer arrives — not `null`; a charge that's never been paid returns `paidWith: []`. Every accepted pair sums: a charge configured with `acceptedPayments: [USDC/base, USDT/base]` can be confirmed by $9 in USDC plus $1 in USDT, and `paidWith` would then hold both pairs. Only a transfer on a pair that's actually in `acceptedPayments` is credited — one on any other pair is still recorded (visible in the charge's timeline) but never counted toward `amountReceived`. * `amountReceived` — cumulative amount received on-chain as a legacy JSON number. `null` until the first transfer arrives. Can lose precision; prefer `amountReceivedExact` for calculations. * `amountReceivedExact` — exact cumulative received amount in currency units, with up to eighteen fractional digits and no scientific notation. `null` until a transfer arrives. Can exceed `amountExact`. * `paymentUnavailable` — when `true`, pause payment instructions and fulfillment for this charge. Its existing business status remains visible, but payment processing is unavailable; do not request another transfer or rely on the monetary/status fields to fulfill until it is available again. The exact amount fields and this flag are optional for compatibility with older responses. * `isOverpaid` — `true` once `amountReceived` ends up greater than `amount`. Klappay never auto-refunds the difference — the charge address is an immutable split with the payer never a recipient, and Klappay never custodies funds to refund from — this field is how you detect an overpayment happened so you can decide what to do about it yourself. * `status` (`ChargeStatusSchema`) — payment progress from the payer's side only: `pending` → `partially_paid`/`confirmed` → (if it never fully pays) `expired`/`underpaid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. Never reflects whether the *merchant* actually got paid — that's `settlementStatus`. * `settlementStatus` (`SettlementStatusSchema`, nullable) — a **separate** step from `status`. `status: confirmed` only means the payment was detected on-chain; `settlementStatus: completed` means the merchant's wallet actually has the funds. `null` on the parent `Charge` means no payout has been attempted yet. * `settledAt` (nullable) — when `settlementStatus` first reached `completed`, mirroring `confirmedAt`'s role for `status`. `null` while `settlementStatus` is `pending`/`failed`/`null`. * `environment` (`EnvironmentSchema`) — `live` or `test`, matching the API key used to create it. `live` uses the selected network's mainnet; `test` uses its configured testnet — real on-chain activity, just never real money. * `address` — the on-chain address the payer sends funds to, identical no matter which `acceptedPayments` pair they use (0xSplits addresses are chain-agnostic — CREATE2 with no `chainId` in the derivation). Unique per charge, predicted at creation — funds sent here go directly to the merchant; Klappay never custodies them. For an escrow-configured charge (see below), this is a predicted Safe address instead — still non-custodial, but the funds sit locked there until `escrow .releaserAddress` releases them, not immediately available to the merchant. * `escrow` (nullable) — present only when this charge was created with `escrow` — see below. * `lastActivityAt` — when a transfer was last credited toward this charge, or `createdAt` if none has arrived yet. * `redirectUrl` (nullable) — echoes whatever was set at creation, or `null` if you didn't set one. * `checkoutUrl` (nullable) — link to Klappay's hosted checkout page for this charge. `null` if this deployment has no hosted checkout configured — build your own payment UI from `address`/ `acceptedPayments` in that case. * `splitRecipients` — echoes whatever extra split recipients were set at creation, empty array if none. ## Escrow — `escrow.ts` `EscrowConfigSchema` (`{ releaserAddress? }`, used via the `escrow` field on `CreateChargeSchema`) locks a charge's funds in a dedicated, non-custodial Safe instead of the usual split address. Only `releaserAddress` (or, if omitted, your API key's own `payoutAddress`) can ever move them, by signing the exact transfer Klappay then executes. Klappay never holds a key with any authority over the escrow: the Safe contract itself verifies the signature on-chain before anything moves, and Klappay only pays the gas. ### Releasing or refunding, step by step A release sends the escrow's entire balance to the charge's split address, where the normal distribution pays out the merchant/platform shares exactly as for a non-escrow charge. A refund sends the entire balance back to the payer instead. Both work the same way: 1. **Prepare.** `GET /v1/charges/{id}/release/prepare` (or `/refund/prepare`) returns `EscrowSafeTransactionSchema`: the exact Safe transaction Klappay will execute, with `destination`, `tokenAddress`, `amount` (in the token's smallest unit), `chainId`, `safeAddress`, its EIP-712 `typedData` and `safeTxHash`. Preparing is read-only and never moves funds. 2. **Check and sign.** Compare `destination`, `tokenAddress`, `amount`, `chainId` and `safeAddress` with your own records, then sign `typedData` with the releaser's key — any EIP-712 signer works (viem, ethers, a KMS). No Safe SDK and no RPC access are needed. 3. **Submit.** Send the signature to `POST /v1/charges/{id}/release` (or `/refund`) as `{ signature }` — `ReleaseEscrowRequestSchema` / `RefundEscrowRequestSchema`. ```ts import { toSignableEscrowTypedData } from '@klappay/types' import { privateKeyToAccount } from 'viem/accounts' const releaser = privateKeyToAccount(process.env.ESCROW_RELEASER_KEY as `0x${string}`) const headers = { Authorization: `Bearer ${process.env.KLAP_API_KEY}` } const base = `https://api.klappay.com/v1/charges/${chargeId}` const prepared = await fetch(`${base}/release/prepare`, { headers }).then((r) => r.json()) const sameAddress = (a: string, b: string) => a.toLowerCase() === b.toLowerCase() const message = prepared.typedData.message if (!sameAddress(prepared.safeAddress, order.escrowAddress)) throw new Error('unexpected Safe') if (prepared.chainId !== order.chainId) throw new Error('unexpected chain') if (!sameAddress(prepared.tokenAddress, order.tokenAddress)) throw new Error('unexpected token') if (!sameAddress(message.to, prepared.tokenAddress)) throw new Error('call is not to the token') if (message.operation !== 0 || message.value !== '0') throw new Error('unexpected call type') if (message.gasPrice !== '0' || message.safeTxGas !== '0' || message.baseGas !== '0') throw new Error('unexpected gas refund') if (BigInt(prepared.amount) < order.paidAmountInBaseUnits) throw new Error('amount below what was paid') if (BigInt(prepared.amount) > order.paidAmountInBaseUnits * 2n) throw new Error('amount far above what was paid') const signature = await releaser.signTypedData(toSignableEscrowTypedData(prepared.typedData)) const charge = await fetch(`${base}/release`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ signature }), }).then((r) => r.json()) ``` `order` here is your own record of the escrow, saved when the charge was created and paid: the Safe address (the charge's `address`), the chain and token it was paid in, and the amount received in the token's smallest unit. The `amount` check is a bound, not an equality, since `amount` is the Safe's live balance and includes any overpayment; pick bounds that fit your business. For a release, `destination` is the charge's split address, which only Klappay derives — the other checks are what you can verify on your own. For a refund, also compare `destination` with the buyer wallet you expect the money to go back to, and refuse to sign if it differs (see below). `message.data` encodes `transfer(destination, amount)` on the token; the hash you sign commits to it. `typedData` is plain JSON (numeric fields as decimal strings). `toSignableEscrowTypedData` validates it and returns it with `bigint` numbers and literal field types, which is what viem's typings expect; it always uses the canonical `SafeTx` struct (`ESCROW_SAFE_TX_TYPES`), never whatever field list the response carried. Plain JavaScript can pass `prepared.typedData` to `signTypedData` directly. The `amount` is the escrow's live balance at prepare time, so a prepared transaction is only valid while that balance is unchanged: if more funds arrive before you submit, the signature no longer matches and the call fails with `400 escrow_invalid_signature`. Prepare again and re-sign. A refund's `destination` is the sender of the charge's most recently credited transfer, which isn't always the buyer: an exchange withdrawal arrives from the exchange's own wallet, a swap from a router contract, and a charge paid by several wallets refunds everything to the last one. Check it before signing a refund. ### The releaser key Generate a dedicated key for this, used for nothing else: ```ts import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' const privateKey = generatePrivateKey() const releaserAddress = privateKeyToAccount(privateKey).address ``` Store `privateKey` in a secrets manager and pass `releaserAddress` as `escrow.releaserAddress` when creating charges. For live funds, prefer a KMS or MPC signer that never exposes the raw key (AWS KMS, GCP KMS, Turnkey, Fireblocks…) — anything that can sign EIP-712 typed data with a secp256k1 key works. Know what this key can do before using escrow: * **A leaked key can drain every open escrow it releases.** It's the Safe's sole owner, so whoever holds it can move the funds directly on-chain, without going through Klappay at all. Klappay's API checks don't apply to transactions sent straight to the Safe. * **A lost key locks every open escrow it releases, permanently.** The releaser is fixed in the Safe's address at charge creation and can't be rotated for an existing charge; there is no recovery path. New charges can use a new releaser. * **It must be a plain externally-owned account.** A smart-contract wallet, a multisig, or an exchange deposit address can't produce the ECDSA signature Klappay submits, so funds in such an escrow stay locked. This includes the default: if you omit `releaserAddress`, the API key's `payoutAddress` becomes the releaser, so only omit it when that address is a key you control and can sign with. ### Availability **Requires every accepted network to be EVM** — the custody mechanism is Gnosis Safe, which only runs on EVM chains. `arc` is fully supported (confirmed live on-chain in both `live` and `test`); `tron` can never support it, since TRON isn't EVM at all (TVM, not EVM bytecode) — see `docs/payments.md`'s escrow section in the main repo for the full verification trail. A release or refund only supports a charge paid in a single token/network pair; one paid across more than one pair fails with `422 escrow_multiple_payment_pairs`. If Klappay can't rebuild the escrow Safe at the charge's `address`, prepare and submit both refuse with `422 escrow_safe_mismatch` rather than build anything — not transient; contact support (the releaser can still move the funds directly on the Safe). Release and refund are mutually exclusive: an escrow is released or refunded once, never both, and `escrow.releasedAt`/`escrow.refundedAt` on `Charge` record which one happened. ## Listing charges `ListChargesSchema` / `ListChargesInput` — the query params for `GET /v1/charges`. Supports filtering by `status`/`environment`/`isOverpaid`, and `since` (filters on `createdAt`, not on when the status last changed — matters if you're polling as a fallback for missed webhooks: use a `since` window at least as wide as your `expiresIn` plus a safety margin, since a charge can still change status any time between creation and `expiresAt` — pair polling with `GET /v1/charges/{id}/events` for live updates instead of relying on `since` alone). `token`/`network` filters are also supported, but filter on `paidWith` — "charges actually paid in X" — not on `acceptedPayments`; filtering by "charges that accept X" isn't supported yet. Cursor-paginated via `limit` (1–100, default 20) and `cursor`. Building a request object rather than a query string? Type it as `ListChargesRequest`, not `ListChargesInput` — `limit` is optional there (the schema defaults it to 20), and `isOverpaid` is the pre-transform `'true' | 'false'` string the wire actually carries, not the post-transform `boolean` `ListChargesInput` has. `PaginatedChargesSchema` / `PaginatedCharges` is the response shape: `{ data: Charge[], nextCursor: string | null, hasMore: boolean }`. ### The shared cursor pagination pattern Every list endpoint in the API (`GET /v1/charges`, `GET /v1/webhooks/{id}/deliveries`) shares this exact same `{ limit, cursor }` → `{ data, nextCursor, hasMore }` shape, built from one pair of generic helpers in `pagination.ts`: `PaginationQuerySchema` (the `{ limit, cursor }` query params) and `paginatedSchema(itemSchema)` (wraps any item schema into the response envelope) — `ListChargesSchema`/`PaginatedChargesSchema` above are just `PaginationQuerySchema`/`paginatedSchema(ChargeSchema)` composed with charge-specific filters, and `ListWebhookDeliveriesSchema` (and its `Paginated*Schema` response counterpart, in `webhooks.ts`) follows the same pattern with no filters of its own — it also exports a `*Request` counterpart (`ListWebhookDeliveriesRequest`, plus the bare `PaginationQueryRequest`) where `limit` is optional, for the same pre-parse-vs-post-parse reason as `CreateChargeRequest` above. `cursor` is always opaque — never construct or parse one yourself; pass the previous response's `nextCursor` back verbatim to get the next page, and stop once `hasMore` is `false` (`nextCursor` is `null` at that point). `GET /v1/webhooks` and `GET /v1/charges/{id}/timeline` deliberately stay unpaginated — the former is hard-capped at 20 active webhooks per tenant, the latter is scoped to one charge's own events, both naturally small. ## Live status without polling `GET /v1/charges/{id}/events` (API-key authenticated) streams the full `Charge` object over Server-Sent Events every time `status`/ `settlementStatus` changes, starting with the current state immediately on connect (no separate schema needed — it's the same `ChargeSchema` above). It also streams a second event kind, `event: confirmation_progress`, carrying `ConfirmationProgressSchema` (`network`, `blocksSeen`, `blocksRequired`, `percent`) whenever a real on-chain transfer has been detected but hasn't yet reached its network's minimum confirmation depth (see the `klap-core` repo's `docs/payments.md` for why that delay exists). Use it to render a progress bar while a payment finalizes — the next `event: charge` (with an updated `status`) is the "done" signal, not a `percent` of 100 (which this never reports, by construction: once a transfer is deep enough it's credited immediately instead). ```ts import { ConfirmationProgressSchema } from '@klappay/types' // event: confirmation_progress // data: { "network": "base", "blocksSeen": 6, "blocksRequired": 15, "percent": 40 } ConfirmationProgressSchema.parse(JSON.parse(data)) ``` ## Triggering an on-demand check — `charge-check.ts` `POST /v1/charges/{id}/check` re-verifies a charge's on-chain payment state immediately instead of waiting for Klappay's own background reconciliation pass (a backstop that otherwise catches a missed webhook within roughly a minute). Useful right after your own detection of a payer action — a checkout "I paid" button, a wallet-connected payment — to shorten the wait instead of just polling. It never trusts anything you tell it: by default it re-scans the charge's address for recent transfers, the same lookup the background job runs. `CheckChargeRequestSchema` (both fields optional, and only ever together) lets you speed this up further if you already have the transaction hash — e.g. right after a swap-to-pay or wallet-connect transaction is sent: ```ts import { CheckChargeRequestSchema } from '@klappay/types' CheckChargeRequestSchema.parse({ txHash: '0x1234567890123456789012345678901234567890123456789012345678901234', network: 'base', }) ``` Passing `txHash`/`network` verifies that one transaction directly (a single RPC call) instead of scanning a block range — faster and cheaper, but the amount credited always comes from what that transaction actually contains on-chain, never from anything implied by sending it. `network` must be one of the charge's own `acceptedPayments` networks, or `422 payment_pair_not_accepted`. Rate-limited to once every 10 seconds per charge (`429 rate_limited`), shared across every caller of that charge — prefer the SSE stream above to observe the result instead of polling this endpoint repeatedly. `CheckChargeResponseSchema` is `ChargeSchema` plus four extra fields. Three of them are on-chain evidence of who paid; Klappay reports them and leaves the decision of who the payer "is" to you. * `transactionSender` is the `txHash` transaction's own sender, i.e. who actually signed and submitted it. For a wallet that signs its own transactions that's the payer, swap-to-pay included (where the credited transfer's own `from` is the router/pool, not the payer). It is **not** the payer when someone else submits the transaction: a gas-sponsoring relayer (an EIP-7702 smart account, e.g. MetaMask's) or an ERC-4337 bundler. * `tokenSenders` lists the checksummed `from` of each paying transfer — a Transfer emitted by an accepted token's own contract, sent to the charge's address (mints excluded). It covers the relayer case: the tokens left the wallet even though the relayer signed. Transfer events from any other contract are ignored, since anyone can emit one. For a swap-to-pay it holds the router, not the payer. * `userOperationSenders` lists the ERC-4337 account whose own successful user operation produced a paying transfer, read from `UserOperationEvent`s emitted by the official EntryPoint contracts (v0.6 through v0.9). A transfer is attributed only to the operation whose execution it happened in, so other accounts sharing the same bundle are never listed. This covers a smart account paying directly or through a swap. A wallet appearing in any of the three is evidence it paid. A transaction settling several parties at once can still put more than one address in `tokenSenders` if more than one of them paid this charge in it, so pair the evidence with your own per-`txHash` uniqueness. One case none of them covers: an EIP-7702 wallet behind a relayer that swaps from native ETH, whose outgoing value moves through an internal call that leaves no log. All three are populated only when `txHash`/`network` was passed and that transaction actually paid this charge — on a still-open (or expired) charge, its receipt has to contain a Transfer of an accepted token to the charge's address (even one not deep enough to credit yet); on an already-`confirmed`/ `underpaid` charge, it has to be a transfer already credited to it on that network. Otherwise `transactionSender` is `null` and both lists are `[]` — no hint, a not-found/reverted transaction, or a successful transaction that never paid this charge — so a public `txHash` can't be used to claim someone else's payment. This makes it safe to call `/check` again with the same hint after the SSE stream reports `confirmed`, to learn the payer evidence the stream itself doesn't carry. `confirmationProgress` is the same `ConfirmationProgress` object the SSE stream above can push — present when this check found a real transfer that isn't deep enough yet, `null` otherwise (nothing found, the charge is already terminal, or the transfer was already credited). A terminal charge is never re-scanned; it costs at most one receipt read, and only when the hint is a transfer already credited to it. ## Audit trail — `timeline.ts` `TimelineEventSchema` / `TimelineEvent` is the shape of each entry in a charge's timeline (`GET /v1/charges/{id}/timeline`) — a read-only, assembled-at-request-time view, not a separately stored table. `type` (`TimelineEventTypeSchema`) is one of `charge.created`/`charge.expired`/ `transaction.detected`/`split.distributed`/ `webhook.dispatched`/`webhook.delivered`/`webhook.failed`; which other fields are present depends on `type` (e.g. `txHash`/`amount`/`source`/`token`/`network`/ `causedTransition` only apply to `transaction.detected`, and `token`/ `network` are also present on `split.distributed`). `TransactionSourceSchema` / `TransactionSource` (`'contract_watcher' | 'tron_watcher' | 'reconciliation_job' | 'sandbox'`) says *how* a transfer was detected — `contract_watcher` is the real-time path for EVM networks (a WebSocket log subscription), `tron_watcher` is TRON's own real-time path (a ZeroMQ event subscription against a self-hosted node — TRON has no WebSocket log subscription), and `reconciliation_job` covers the polling fallback for both. `token`/`network` on a `transaction.detected` event show which `acceptedPayments` pair that specific transfer used — useful since a charge can accept more than one pair, and every transfer on an accepted pair is credited (`causedTransition: true` marks specifically the transfer that flipped the charge's `status`, not every credited one — a charge paid in installments across two pairs has more than one credited `transaction.detected` event with `causedTransition: false`). A `split.distributed` event's own `token`/`network` say which pair that particular settlement was for — a charge settled across more than one pair emits one `split.distributed` event per pair, each independently. ## Discovering what you can accept — `capabilities.ts` `CapabilitiesSchema` / `Capabilities` (`{ acceptedPayments: AcceptedPayment[] }`) is the response shape of `GET /v1/networks` — the live `(token, network)` matrix your API key's `environment` can accept right now. It uses the same deployment metadata and availability policy as `POST /v1/charges`. A cataloged address can remain readable while payments on it are disabled — both BNB pairs went through exactly that state from 2026-09-14 to 2026-09-27. Availability can change between requests, so also handle `422 token_not_supported` when submitting a charge. Use it to build a payment-method picker instead of hardcoding the matrix client-side — it changes as new networks/tokens come online, with no code change needed on your end to pick that up. ## Payment QR codes — `GET /v1/charges/{id}/qrcode` Returns a scannable QR code for a charge as a raw SVG image, generated on demand — nothing is stored or hosted. It encodes an [EIP-681](https://eips.ethereum.org/EIPS/eip-681) payment URI (`ethereum:@/transfer?address=&uint256=`), which a wallet that supports EIP-681 scanning (e.g. Trust Wallet, imToken) uses to prefill the recipient, token, and amount — a wallet without that support falls back to a manual `charge.address` copy, no worse than not having a QR at all. `uint256` is always present — every charge has a real target amount. The URI requests only the unpaid balance, calculated from exact stored values using the selected token's decimals. If the remainder is smaller than one atomic unit of that token, the instruction rounds upward to the smallest unit covering the balance. Received transfers themselves are credited exactly, without rounding up. `GetChargeQrCodeQuerySchema` (`{ token?, network? }`) selects which accepted `(token, network)` pair to encode — required only when `acceptedPayments` (see "Creating a charge" above) has more than one pair; the endpoint auto-picks the only pair when there's just one. Only available while the charge can still be paid — a `confirmed`/ `expired`/`underpaid` charge returns `409 charge_not_payable` instead, since a QR for it would direct funds nowhere useful. A charge with `paymentUnavailable: true` also cannot provide payment instructions. ## Paying with a different cryptocurrency — `POST /v1/charges/{id}/quote` A charge only ever accepts the stablecoins in its own `acceptedPayments` (`USDC`/`USDT`) — but a payer often holds something else instead. `swapAlternatives` on the charge lists every `(token, network)` pair Klappay trusts as swap input, e.g. `{ token: 'ETH', network: 'base' }` for a charge accepting USDC on Base. Pass an entry's `token`/`network` straight through as `CreateSwapQuoteSchema`'s `inputToken`/`inputNetwork`, along with the payer's own `takerAddress`: ```ts const quote = await klappay.post(`/v1/charges/${charge.id}/quote`, { inputToken: 'ETH', inputNetwork: 'base', takerAddress: payerWalletAddress, }) ``` `SwapQuoteSchema` comes back with `inputAmount` (the ceiling the payer needs available — any unused surplus is refunded automatically, in the same transaction), `outputAmount` (the remaining balance rounded upward to the smallest representable output-token unit when necessary), `fees` (`klappayFee` and 0x's own separate `zeroExFee`, both already reflected in `inputAmount`), and `transaction` — pass that straight to the payer's wallet (e.g. viem/ethers `sendTransaction`). `permit2` is always in the response — non-null only for an ERC-20 input like `BTC`/`LINK`/`ARB`/`OP`/`CBETH` (never a network's own native currency) — and when non-null needs its own EIP-712 signature appended to `transaction.data` first; see the field's own description for the exact mechanics. Klappay never touches the payer's private key or custodies the input currency; the swap's output settles directly into the charge's own `address`, so it's detected exactly like any other transfer. `inputAmountExact` and `outputAmountExact` expose those amounts as decimal strings in their respective token units, with up to eighteen fractional digits and no scientific notation. `fees.klappayFeeExact` and `fees.zeroExFeeExact` do the same for fees in output-token units; the latter is `null` when no 0x fee applies. These fields are optional for older-response compatibility; prefer them over numeric counterparts for arithmetic. A charge with `paymentUnavailable: true` cannot be quoted. Empty/unavailable in two cases: `swapAlternatives` is always `[]` on a `test`-environment charge, and the quote endpoint rejects one with `422 swap_test_environment_unsupported` — the underlying swap provider has no testnet support at all. And if this Klappay deployment hasn't configured swap-to-pay, `swapAlternatives` is `[]` everywhere and the endpoint returns `503 swap_unavailable`. ## See also * [`webhooks.md`](./webhooks.md) — the full `charge.*` event vocabulary and the `WebhookPayload` envelope these events arrive in. * [`sandbox.md`](./sandbox.md) — triggering any charge state transition without a real on-chain transfer. --- --- url: https://api.klappay.com/types/recipients.md --- # Recipients Exported from `recipients.ts`. A `Recipient` is a trusted, pre-registered payout destination — the thing a charge's `splitRecipients` entries reference by `recipientId` instead of a raw address (see `charges.md`). ## Why registration instead of a raw address An earlier version of `splitRecipients` accepted a raw address directly in the charge-creation request — any `charges:write` key could redirect a slice of a payout to an address nobody had verified. Recipients close that: an address has to be registered once, ahead of time, before it can ever receive money through a split. ## Registering a recipient ```ts import { CreateRecipientSchema } from '@klappay/types' CreateRecipientSchema.parse({ address: '0x1111111111111111111111111111111111111111', label: 'supplier', // optional, your own bookkeeping — never interpreted }) ``` `POST /v1/recipients` — requires the `recipients:write` scope. Idempotent: registering an address already known to your organization/environment updates its `label` (and revives it if it was previously revoked) instead of creating a duplicate row. The response is a `Recipient`: ```ts import { RecipientSchema } from '@klappay/types' RecipientSchema.parse({ id: 'rc_abc123', // this, not the address, is what splitRecipients[].recipientId references environment: 'live', address: '0x1111111111111111111111111111111111111111', label: 'supplier', payout: false, createdAt: '2026-01-01T00:00:00.000Z', }) ``` ## Using one in a split Once registered, reference it by id — never send the address itself: ```ts { amount: 100, acceptedPayments: [{ token: 'USDC', network: 'base' }], splitRecipients: [{ recipientId: 'rc_abc123', percent: 10 }], } ``` `GET /v1/recipients` (`recipients:read` scope) lists your organization's non-revoked recipients for the calling key's environment, newest first, with the same cursor-based `?limit`/`?cursor` pagination as `GET /v1/charges` (`data`/`nextCursor`/`hasMore`). `DELETE /v1/recipients/{id}` (`recipients:write` scope) revokes one — it can no longer be referenced in a *new* split, and (if it currently has `payout: true`) revoking it also invalidates any API key whose own `payoutAddress` matches it, on that key's very next request. See the `payout` section below for why revoking a `payout: true` recipient needs a stricter scope than registering one does. Charges that already referenced a since-revoked recipient are unaffected — the resolved address is frozen into the charge at creation time, not a live reference to the `Recipient` row. ## `payout` — a separate, stricter claim `recipients:write` alone never makes a recipient eligible to become an API key's own `payoutAddress` — only being *referenceable in a split*. Elevating a recipient to `payout: true` requires the separate `recipients:manage_payout` scope: ```ts import { SetRecipientPayoutSchema } from '@klappay/types' SetRecipientPayoutSchema.parse({ payout: true }) ``` `PATCH /v1/recipients/{id}` — meant to be held only by a key that already went through its own out-of-band approval for this specific action (e.g. Dashboard's own internal key, after session + owner role + email confirmation on its side), never a merchant-facing or third-party integration key. See `api-keys.md`'s scope table for the full rationale, including why `charges:split_write` can never be combined with `recipients:write`/`recipients:manage_payout` on the same key. Two consequences of the split between "referenceable in a split" and "payout-eligible": * Registering (or re-registering) a recipient always resets `payout` back to `false` — `recipients:write` can never grant *or preserve* a prior payout approval by itself. * Revoking a recipient that currently has `payout: true` requires `recipients:manage_payout`, not just `recipients:write` — otherwise a lower-trust key could disable any other API key in the organization by revoking a recipient it didn't have the authority to elevate in the first place. A `recipients:write`-only attempt against a payout-eligible recipient gets `404 recipient_not_found`, identical to a genuinely nonexistent id — never a distinct signal that confirms one exists. ## Checking token issuer restrictions — `GET /v1/recipients/{id}/transferability` Some stablecoin issuers can restrict an address on-chain: USDC's contracts refuse any transfer to *or* from a blacklisted address, USDT's refuse transfers *from* one. Klappay reads that status straight from each token contract's own view function (`isBlacklisted` for USDC, `isBlackListed`/`isBlocked` for USDT) — never from a Klappay list or a third-party sanctions source — and reports it per pair. The full picture (why issuers do this, and what happens at each step of a charge) is in [token restrictions](./token-restrictions.md). ```ts import { RecipientTransferabilitySchema } from '@klappay/types' RecipientTransferabilitySchema.parse({ recipientId: 'rc_abc123', address: '0x1111111111111111111111111111111111111111', pairs: [ { token: 'USDC', network: 'ethereum', receive: 'restricted', send: 'restricted' }, { token: 'USDT', network: 'ethereum', receive: 'allowed', send: 'restricted' }, { token: 'USDT', network: 'base', receive: 'allowed', send: 'unknown' }, ], }) ``` `recipients:read` scope; `404 recipient_not_found` for an id that isn't a non-revoked recipient of your organization in the calling key's environment. `pairs` covers every token/network pair currently enabled for payments in that environment that matches the address family (EVM pairs for an `0x...` address; empty for a TRON address today). Each `TransferRestrictionStatus` is: * `allowed` — the token contract doesn't restrict this address in this direction. * `restricted` — the issuer currently restricts it, so the chain itself would refuse that transfer. * `unknown` — no answer: the token has no such function on that network (USDT on Base/Optimism, both tokens on BNB), or the read failed or took over 1 second. Klappay never blocks anything on `unknown`. `receive` is what `POST /v1/charges` gates on (`422 recipient_transfer_restricted`). `send` never blocks a charge — the address can still be paid — but funds it receives may stay stuck in it until the issuer lifts the restriction, which is worth telling the address's owner about. USDT's `receive` is always `allowed` for a configured pair, because its contracts never check the recipient. Results are cached for up to 5 minutes (an `unknown` for 30 seconds), so a change made by the issuer can take that long to show. Rate limited to 20 requests/min per tenant, on top of the general per-tenant limit. ## Error codes | Code | Status | Meaning | |---|---|---| | `recipient_not_found` | 404 | No recipient with that id for your org/environment — or it exists but is payout-eligible and this key lacks `recipients:manage_payout` to revoke it | | `recipient_transfer_restricted` | 422 | On `POST /v1/charges`: the token issuer restricts an address the charge would pay from receiving on one or more accepted pairs — the message lists the pairs to drop | | `rate_limited` | 429 | More than 20 `GET /v1/recipients/{id}/transferability` calls in a minute for your organization | | `recipient_not_found_in_split` | 422 | A `splitRecipients[].recipientId` on `POST /v1/charges` didn't resolve — wrong id, wrong environment, or revoked | | `insufficient_scope` | 403 | Missing `recipients:read`/`recipients:write`/`recipients:manage_payout` for the action attempted | | `conflicting_api_key_scopes` | 403 | The key holds both `charges:split_write` and a recipients-registration scope — never allowed together, rejected before any route runs | See `docs/security.md` (klap-core) finding-50 for the full security rationale behind every rule on this page. --- --- url: https://api.klappay.com/types/token-restrictions.md --- # Token issuer restrictions Some stablecoin issuers can freeze an address on-chain. Circle can blacklist an address on USDC and Tether can blacklist one on USDT, usually because of a court order, a sanctions listing or a request from law enforcement. Once that happens, the token contract itself refuses the affected transfers. No wallet, app or payment provider can get around it, Klappay included. **Klappay doesn't decide which addresses are restricted.** It keeps no blocklist of its own and uses no third-party sanctions list. Every check on this page reads the token contract's own public status, so Klappay only refuses what the blockchain would refuse anyway, and does it early enough that money doesn't get stuck. ## What each token restricts | Token | Issuer function | Restricted address can receive? | Restricted address can send? | |---|---|---|---| | USDC | `isBlacklisted` | No | No | | USDT on Ethereum | `isBlackListed` | Yes | No | | USDT on Polygon, Arbitrum, Avalanche (USDT0) | `isBlocked` | Yes | No | Some tokens have no public function to ask: USDT on Base and Optimism, and both tokens on BNB Chain. Their issuer can still freeze addresses, but Klappay has no way to see it ahead of time, so these pairs are always reported as `unknown`. ## What happens at each step | Step | What Klappay does | What you see | |---|---|---| | Registering a recipient | Nothing is blocked. | Call `GET /v1/recipients/{id}/transferability` to see the status per pair and warn the address's owner early. See [recipients](./recipients.md). | | Creating a charge | Checks whether every address the charge pays (your payout address, each split recipient, Klappay's fee treasury) can **receive** on each accepted pair. | `422 recipient_transfer_restricted`, listing only the affected `TOKEN on network` pairs. Drop them from `acceptedPayments` and retry. See [charges](./charges.md). | | Paying | Klappay never checks the payer. | If the payer's own address is restricted, their wallet's transfer fails on-chain and the charge simply stays unpaid. | | Paying out | Before distributing, checks that the split can send and that every recipient can receive. | If an address became restricted after the charge was created, the payout is **held**: the funds stay in the charge's split contract, the charge keeps its status (`confirmed` or `underpaid`) with `settlementStatus: pending`, and no webhook is sent. Klappay checks again every hour and pays out normally if the issuer lifts the restriction. See [distributions](./distributions.md). | A USDT recipient whose address is restricted is still paid, because USDT never checks who receives. The money then sits in that address until the issuer lifts the restriction. The transferability endpoint shows this as `receive: allowed, send: restricted`, which is worth telling the address's owner. ## When Klappay can't tell Each check reads the token contract live, with a 1 second limit. If the read fails, times out, or the token has no function to ask, the result is `unknown`, and `unknown` never blocks a charge or a payout. Klappay also never saves these results: a definite answer is cached in memory for up to 5 minutes and an `unknown` for 30 seconds, so a change made by the issuer can take that long to show. ## What to do as an integrator * Check a recipient with `GET /v1/recipients/{id}/transferability` when it's registered, not only when a charge fails. * On `422 recipient_transfer_restricted`, offer the payer the remaining pairs. For example, a USDC-restricted payout address can usually still be paid in USDT. * If a restricted address belongs to someone you work with, ask them for another address. Only the token issuer can lift a restriction; Klappay can't. * A paid charge that gets no `charge.settled` for hours may be a held payout. Contact Klappay support with the charge id. --- --- url: https://api.klappay.com/types/webhooks.md --- # Webhooks Everything here is exported from `@klappay/types`'s webhook-related modules (`webhook-events.ts`, `webhooks.ts`, `webhook-event-data.ts`) — import from the package root either way, this split is an internal implementation detail. ## Event types, and the two categories Every event Klappay can send is one `WebhookEventTypeSchema` value, built from two sub-enums (each independently exported, useful if you only ever care about one group): | Sub-schema | Category | Events | |---|---|---| | `ChargeWebhookEventTypeSchema` | `payments` | `charge.created`, `charge.partially_paid`, `charge.confirmed`, `charge.expired`, `charge.underpaid`, `charge.settled`, `charge.settlement_failed`, `charge.overpaid`, `charge.escrow_released` | | `WebhookDeliveryEventTypeSchema` | `webhooks` | `webhook.delivery_failed`, `webhook.delivery_recovered`, `webhook.endpoint_unhealthy` | `EVENT_CATEGORY_MAP` (`Record`) is the single source of truth for which category an event belongs to — generated from the two enums above, never hand-duplicated. Its inverse, `WEBHOOK_EVENT_CATEGORIES` (`Record`), gives you every event in a category, e.g. for building a subscription UI. Two events worth calling out specifically: * **`charge.confirmed` vs. `charge.settled`** — `confirmed` means the payment was *detected on-chain*; `settled` means the merchant's wallet *actually received* the funds, a separate, later step (see `settlementStatus` in [`charges.md`](./charges.md)). Subscribe to `confirmed` if you only need "will I get paid", or `settled` if you need "has the money actually arrived". * **`charge.overpaid`** fires *alongside* `charge.confirmed`/ `charge.partially_paid` whenever the cumulative amount received ends up above `amount` — it's an additional signal, not a replacement status. * **`charge.escrow_released`** fires once an escrow-configured charge's funds have moved from its Safe to the split address via `POST /v1/charges/{id}/release` — see [`charges.md`](./charges.md)'s escrow section. A normal `charge.settled` still follows once the split itself finishes distributing. Every event in the `payments` category carries the full `Charge` object as `data`. ## Subscribing — `CreateWebhookSchema` `POST /v1/webhooks`'s body. At least one of `events` or `eventCategories` is required (enforced by a `.refine()`, not just documentation). Type a pre-parse request object as `CreateWebhookRequest`, not `CreateWebhookInput` — `events`/`eventCategories`/`excludeEvents` are all optional there (the schema defaults each to `[]`): ```ts import { CreateWebhookSchema, type CreateWebhookRequest } from '@klappay/types' const request: CreateWebhookRequest = { url: 'https://example.com/webhooks/klap', eventCategories: ['payments'], } CreateWebhookSchema.parse(request) ``` * `events` — individual event types, or `"*"` (`WEBHOOK_EVENTS_WILDCARD`) for every event. * `eventCategories` — subscribe to a whole category at once (see the table above) — new events added to that category later arrive automatically, no subscription update needed. * `excludeEvents` — opt back out of specific events even under a `"*"` or category subscription. * `url` must be HTTPS and resolve to a public address — private/internal IPs are rejected server-side. `WebhookSchema` / `Webhook` is the response to creating one — critically, `secret` (the HMAC signing secret) is only ever present in **this** response. `WebhookListItemSchema` / `WebhookListItem` is what every subsequent list/read returns instead: the same shape minus `secret`, plus `hint` (a truncated, safe-to-display form, e.g. `whsec_...ab12`). `environment` (`'live' | 'test' | null`) is set automatically from whichever API key created the webhook — there's no request field for it, you can't choose or override it. `null` means the webhook was created before this field existed and receives every environment, same as before. Both `payments` and `webhooks`-category events carry an environment, and are only delivered to a webhook whose `environment` matches the event's own. Register a webhook with each of your `live` and `test` API keys if you want separate endpoints/handlers per environment — a `test`-key webhook never receives a real `live` charge event, and a `live`-key webhook never receives a sandbox-triggered one. ## The payload envelope `WebhookPayloadSchema` / `WebhookPayload` is the wire shape of every delivery: `{ id, event, createdAt, data }`. `data` is typed `unknown` here on purpose — its real shape depends on `event`, which a single flat schema can't express. `id` is also sent as the `X-Klappay-Delivery` header, distinct from the delivery-id-per-registered-webhook used internally. For the *typed*, narrowed version, use `TypedWebhookPayload` — a discriminated union over `event` (same pattern as Stripe's `Event.data.object`): ```ts import type { TypedWebhookPayload } from '@klappay/types' function handle(payload: TypedWebhookPayload) { if (payload.event === 'charge.confirmed') { payload.data.amountReceived // typed as Charge, no cast needed } else if (payload.event === 'webhook.endpoint_unhealthy') { payload.data.failureRatio // typed as WebhookHealthEventData } } ``` `WebhookEventDataMap` is the underlying `{ event: dataShape }` mapping `TypedWebhookPayload` is built from — every `charge.*` event carries a full `Charge`; every `webhook.*` delivery-health event carries a small, event-specific object (a plain TypeScript type, not a Zod schema, since it's never validated standalone, only as part of `TypedWebhookPayload`): | Event(s) | `data` shape | |---|---| | `webhook.delivery_failed` / `webhook.delivery_recovered` / `webhook.endpoint_unhealthy` | `WebhookHealthEventData` — `{ webhookId, url, failureRatio? }` (`failureRatio` only on `endpoint_unhealthy`) | ## Delivery health `WebhookDeliveryStatusSchema` / `WebhookDeliveryStatus` — `'pending' | 'delivered' | 'failed'` (`failed` means all 5 retry attempts over ~24h were exhausted; retry manually via `POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry`). `WebhookDeliverySchema` / `WebhookDelivery` is one entry from `GET /v1/webhooks/{id}/deliveries` — `responseCode: null` means every attempt failed to connect at all, not just a non-2xx response. `ListWebhookDeliveriesSchema`/`PaginatedWebhookDeliveriesSchema` are the query/response shapes for that endpoint — same shared cursor pagination as `GET /v1/charges`, see [`charges.md`](./charges.md#the-shared-cursor-pagination-pattern). `GET /v1/webhooks` itself (listing the webhooks, not their deliveries) stays unpaginated — it's hard-capped at 20 active webhooks per tenant. The `webhook.*` meta-events (`WebhookDeliveryEventTypeSchema`) let you monitor this without polling: `webhook.endpoint_unhealthy` fires once when a webhook's trailing-24h failure rate crosses 20%, `webhook.delivery_recovered` fires once when a delivery next succeeds afterward. ## See also * [`charges.md`](./charges.md) — the `Charge` shape carried by every `charge.*` event's `data`. * [`sandbox.md`](./sandbox.md) — `TriggerableChargeEvent`, the subset of charge events you can simulate without a real on-chain transfer. --- --- url: https://api.klappay.com/types/sandbox.md --- # Sandbox Exported from `sandbox.ts`. These are the request bodies for `POST /v1/sandbox/*` — simulating events without any real on-chain activity, only usable with a `test` API key. ## Triggering a charge event — `SandboxTriggerSchema` The body of `POST /v1/sandbox/charges/{id}/trigger`: ```ts import { SandboxTriggerSchema, type TriggerableChargeEvent } from '@klappay/types' SandboxTriggerSchema.parse({ event: 'charge.confirmed' }) SandboxTriggerSchema.parse({ event: 'charge.partially_paid', amount: 20 }) ``` `event` is a `TriggerableChargeEvent` (exported from `webhook-events.ts`, re-used here rather than re-declared) — every charge event that represents a payment-progress state transition, i.e. every charge event *except* `charge.created` (a charge already exists by the time you have an id to trigger against): ```ts export const TriggerableChargeEventSchema = ChargeWebhookEventTypeSchema.exclude([ 'charge.created', ]) ``` Derived with `.exclude()` from the same enum `webhook-events.ts` already defines, not a second hand-maintained list — a future charge event added there is automatically excluded-or-included correctly here with no second place to remember to update. `amount` applies to two events: `charge.partially_paid` (the amount to simulate as received so far — must be less than the charge's full amount, defaults to half of it if omitted) and `charge.overpaid` (the amount received — must be greater than the charge's full amount, defaults to 1.5x it if omitted). Ignored for every other event. Every triggerable event 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 a `422 invalid_trigger_state` response, not a silent no-op. This schema only validates the request shape; the state-machine precondition is enforced server-side. There is no sandbox trigger for a non-charge event — `POST /v1/sandbox/events/trigger`/`SandboxEventTriggerSchema` were removed along with the account/security event categories they existed to simulate (2026-08). `POST /v1/sandbox/charges/{id}/trigger` above is the only sandbox trigger endpoint left. ## See also * [`webhooks.md`](./webhooks.md) — the full event vocabulary this schema draws from. * An official SDK ([github.com/klappay](https://github.com/klappay)) offers ergonomic, named wrappers (`sandbox.confirm()`, `sandbox.expire()`, ...) built on top of this raw request shape, plus `charge.waitFor(event)` to await the result — see the live API reference for details. --- --- url: https://api.klappay.com/types/distributions.md --- # Distributions Exported from `distributions.ts`. These back `GET /v1/distributions/pending` and `GET /v1/distributions/pending/events` — the keeper feed for discovering splits with a settlement payout currently claimable via 0xSplits' permissionless `distribute()`. Not a typical merchant integration surface — this is for a bot/script calling `distribute()` directly to earn the (small) `distributorFeePercent` incentive. ## `PendingDistributionSchema` One entry in the `data` array `GET /v1/distributions/pending` returns: ```ts import { PendingDistributionSchema, type PendingDistribution } from '@klappay/types' const distribution: PendingDistribution = PendingDistributionSchema.parse({ splitAddress: '0x727cddb8a015f76c4e8a5c566c7e911761e268fe', network: 'base', token: 'USDC', recipients: [ { address: '0x6EadA5173e0ad685455cEFbe4B3df023c0A598A8', percentAllocation: 99.0991 }, { address: '0x44Ef725cA64CDAf48fEdb2AED2b156ad32537d41', percentAllocation: 0.9009 }, ], distributorFeePercent: 0.1, estimatedRewardAmount: 0.001, availableSince: '2026-07-28T23:45:52.382Z', graceEndsAt: '2026-07-28T23:50:52.382Z', }) ``` `recipients` is always the exact array to pass to `distribute()` — 0xSplits only stores a hash of the recipient config on-chain, so a caller must supply the identical array to prove it matches; there's no partial/reconstructed version. `estimatedRewardAmount` is an estimate from the amount Klappay detected on-chain, not a live balance read — always read the split's real balance yourself before submitting a transaction, the same way Klappay's own settlement worker does. `graceEndsAt` is when Klappay's own worker may claim it; calling `distribute()` after that point is still possible but increasingly likely to lose the race. Each entry of `recipients` is `PendingDistributionRecipientSchema` / `PendingDistributionRecipient` — `{ address, percentAllocation }`, exported separately so a caller building the `distribute()` argument list can type one recipient without destructuring the whole distribution. `percentAllocation` is a percentage, not a fraction (`99.0991` means 99.0991%), and the array is reproduced verbatim rather than normalized — it has to hash to exactly what the split contract stores. `SplitDistributionStatusSchema` / `SplitDistributionStatus` is a related but distinct enum: `'pending' | 'processing' | 'completed' | 'failed'`, the status of one payout attempt to the merchant for a single `(token, network)` pair. A charge settling across more than one pair has one per pair. `pending` is queued and unclaimed; `processing` means a distributor — Klappay's own worker, or anyone who raced to call `distribute()` first — has claimed it and is submitting the transaction; `completed` means the merchant's wallet has the funds; and `failed` means every automatic retry was exhausted. A distribution can also stay `pending` past its usual retry window when the token issuer restricts an address the payout needs (the split itself as sender, or a recipient that can't receive): Klappay holds it, rechecks hourly and pays out once the restriction is lifted, without sending a webhook. See [token restrictions](./token-restrictions.md). It describes the payout attempt, whereas the schemas above describe the work still waiting to be claimed. ### `PaginatedPendingDistributionsSchema` — the actual `GET /v1/distributions/pending` response ```ts import { PaginatedPendingDistributionsSchema, type PaginatedPendingDistributions } from '@klappay/types' const page: PaginatedPendingDistributions = PaginatedPendingDistributionsSchema.parse( await res.json(), ) // page.data: PendingDistribution[], page.hasMore: boolean, page.nextCursor: string | null ``` Cursor-paginated, same shape and semantics as every other list endpoint (e.g. `GET /v1/charges`) — pass `?limit=`/`?cursor=` (`PaginationQuerySchema`) to page through it. There's no fixed row cap anymore: during an anomalous backlog (e.g. a stalled RPC inflating the number of still-claimable rows), keep following `nextCursor` instead of only ever seeing however many rows fit in one response. ## `PendingDistributionEventSchema` The shape of each Server-Sent Event's `data:` line from `GET /v1/distributions/pending/events` — a discriminated union on `type`: ```ts import { PendingDistributionEventSchema, type PendingDistributionEvent } from '@klappay/types' const event: PendingDistributionEvent = PendingDistributionEventSchema.parse( JSON.parse(sseEventData), ) if (event.type === 'distribution.available') { tryClaim(event.distribution) // PendingDistribution, fully typed } else { cancelPendingAttempt(event.splitAddress) // already claimed by someone else, or by Klappay } ``` `distribution.claimed` fires the moment Klappay's own worker claims a row too — before it has actually finished calling `distribute()` — not only once settlement completes, so a keeper stops racing an attempt already in flight. If that attempt then fails and is scheduled for retry, `distribution.available` fires again with a refreshed `graceEndsAt`. By default the stream sends no initial snapshot — open it *before* calling `GET /v1/distributions/pending`, then treat both the snapshot response and every event received (before or after that call 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. ### `ListenPendingDistributionsQuerySchema` — a self-contained alternative ```ts import { ListenPendingDistributionsQuerySchema } from '@klappay/types' // GET /v1/distributions/pending/events?limit=50 ListenPendingDistributionsQuerySchema.parse({ limit: '50' }) // { limit: 50 } ``` Pass `?limit=` (up to `PAGINATION_LIMIT_MAX`, default `0`) and the connection sends up to that many currently-claimable distributions as synthetic `distribution.available` events immediately after connecting, before live deltas continue — one connection instead of the two-request dance above, with no ordering footgun (the subscription opens before the snapshot query runs, so nothing published in between is lost). It isn't a full page, though: if more than `limit` are claimable at connect time, the excess simply isn't sent — reach for the paginated `GET /v1/distributions/pending` directly 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_...`). A `test` key only ever sees `test` distributions, `live` only `live` — there is no `environment` field on either schema above, since the connection is already scoped by whichever key opened it. --- --- url: https://api.klappay.com/types/metrics-query.md --- # Metrics query Exported from `metrics.ts`. Backs `POST /v1/metrics/query` — an API-key-authenticated endpoint for ad-hoc analytics over your own tenant's payment data (no id in the path; the tenant is implied by the calling key, same as every other `/v1/*` route), in the same spirit as a log/observability platform's query API (Datadog, Honeycomb, Axiom): you describe what you want — resource, aggregation, groupBy, filters, date range — and get back rows shaped that way. Not raw SQL, not GraphQL — every queryable field is a typed literal enum, checked both by the TypeScript compiler and by the server, so a query can only ever touch a field this package explicitly declares queryable. ## `MetricsQuerySchema` A discriminated union on `resource` — the fields you can filter/group/ aggregate by depend on which resource you're querying, and TypeScript narrows accordingly the moment you set `resource`: ```ts import { MetricsQuerySchema, type MetricsQueryRequest } from '@klappay/types' const request: MetricsQueryRequest = { 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' }], } MetricsQuerySchema.parse(request) ``` `MetricsQueryRequest` (`z.input<>`) is the pre-parse shape — `groupBy`, `filters`, and `limit` are all optional there (the schema defaults them to `[]`/`[]`/`100`); use `MetricsQuery` (`z.infer<>`) for an already-parsed value where those are always present, same `*Request`/`*Input` split as `CreateChargeSchema` (see [`charges.md`](./charges.md)). ### `MetricsResourceSchema` — resources and their queryable fields `resource: 'charges' | 'transactions' | 'distributions'`. 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), `distributions` is one row per payout attempt for one `(token, network)` pair a charge settled across. Every field name below is also exported as its own literal-union schema/type — import these directly for full autocomplete instead of typing the string by hand: | Resource | Dimension fields (filter/groupBy) | Numeric fields (`sum`/`avg`/`min`/`max`) | Date fields | |---|---|---|---| | `charges` | `status`, `source`, `apiKeyId`, `currency`, `isOverpaid`, `externalRef`, `escrowReleaserAddress` — `ChargesQueryFieldSchema`/`ChargesQueryField` | `amount`, `amountReceived`, `feePercent`, `escrowFeePercent` — `ChargesMetricFieldSchema`/`ChargesMetricField` | `createdAt`, `confirmedAt`, `lastActivityAt`, `expiresAt`, `escrowReleasedAt` — `ChargesDateFieldSchema`/`ChargesDateField` | | `transactions` | `network`, `token`, `source`, `causedTransition` — `TransactionsQueryFieldSchema`/`TransactionsQueryField` | `amount` — `TransactionsMetricFieldSchema`/`TransactionsMetricField` | `detectedAt` — `TransactionsDateFieldSchema`/`TransactionsDateField` | | `distributions` | `status`, `network`, `token`, `distributorAddress` — `DistributionsQueryFieldSchema`/`DistributionsQueryField` | `attempts` — `DistributionsMetricFieldSchema`/`DistributionsMetricField` | `createdAt`, `processingStartedAt`, `completedAt` — `DistributionsDateFieldSchema`/`DistributionsDateField` | ```ts import { ChargesQueryFieldSchema, type ChargesQueryField } from '@klappay/types' ChargesQueryFieldSchema.options // ['status', 'source', 'apiKeyId', 'currency', 'isOverpaid', 'externalRef', 'escrowReleaserAddress'] ``` ### `dateRange` is required, and capped Every query needs a `dateRange` — `{ field, from, to }`, `field` one of that resource's date fields above. This isn't 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) — a `superRefine` on `MetricsQuerySchema` rejects anything wider, or `from >= to`. ### `groupBy` — `MetricsDateGranularitySchema` 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 `MetricsDateGranularitySchema`/ `MetricsDateGranularity`: `'day' | 'week' | 'month'`. ### `metrics` — `MetricsAggregationSchema` At least one, up to `METRICS_QUERY_MAX_METRICS` (10): `{ aggregation, field?, alias? }`. `aggregation` is `MetricsAggregationSchema`/`MetricsAggregation`: `'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 — if you omit it, 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`) in the same query, and (since it becomes a SQL column alias for date-bucketed queries) must match `^[a-zA-Z_][a-zA-Z0-9_]*$` — letters, digits, and underscores, not starting with a digit. ### `filters` — `MetricsFilterOperatorSchema` 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 yet, only grouping/aggregating on it). `operator` is `MetricsFilterOperatorSchema`/ `MetricsFilterOperator`: `'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. `limit` (default 100, max `METRICS_QUERY_MAX_ROW_LIMIT` = 1000) caps rows returned — if more rows matched, the response's `meta.truncated` is `true`. ## `MetricsQueryResultSchema` ```ts import { type MetricsQueryResult } from '@klappay/types' const result: MetricsQueryResult = { data: [ { createdAt: '2026-07-01', volume: 4820.5, count: 12 }, { createdAt: '2026-07-02', volume: 3110.0, count: 9 }, ], meta: { resource: 'charges', environment: 'live', rowCount: 2, truncated: false }, } ``` `data` rows are shaped by your query — keys are whatever your `groupBy` fields and metric aliases resolved to, values are `string | number | boolean | null`. That row type is exported on its own as `MetricsQueryResultRowSchema` / `MetricsQueryResultRow` (`Record`). Reach for it when you're typing a function that formats or renders a single row without carrying the surrounding `meta` — the result schema is defined in terms of it, so the two can't drift. It is deliberately an open record rather than a generated per-resource type: the key set depends on the query you sent, not on the resource, so there's nothing narrower to promise at compile time. ## Access control Every valid API key gets unrestricted access to its own tenant's data — there's no member/role-based sub-scoping within a tenant, since Core has no membership or role concept at all (account/organization management lives in a separate Dashboard service, see `docs/auth.md` in the main repo). A key can only ever see data attributed to its own `tenantId`, never another tenant's. ## See also * [`charges.md`](./charges.md) — the `*Request`/`*Input` split this schema also follows, and the underlying `Charge` fields this query engine reads. --- --- url: https://api.klappay.com/types/api-keys.md --- # API keys — scopes Exported from `api-key-scopes.ts`. Covers `ApiKeyScopeSchema` — the fixed set of permissions an API key can carry, independent of `tenantId` (whose data) and `environment` (`live`/`test`). ## `ApiKeyScopeSchema` ```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 ``` A flat, closed set of 14 strings — no wildcard, no named roles. A key missing a scope a route requires gets `403 insufficient_scope` on that route; a key with none of these scopes can still authenticate (a bad key is `401`, a key lacking permission is `403` — two different failure classes to branch on). | Scope | Grants | |---|---| | `charges:read` | `GET /v1/charges`, `GET /v1/charges/{id}`, `/timeline`, `/events`, `/qrcode` | | `charges:write` | `POST /v1/charges` | | `charges:split_write` | Required in addition to `charges:write` whenever a `POST /v1/charges` request includes `splitRecipients` — a key without it can create ordinary charges but never redirect any part of the payout, even to an already-registered recipient | | `webhooks:read` | `GET /v1/webhooks`, `GET /v1/webhooks/{id}/deliveries`, `GET /v1/webhooks/listen` | | `webhooks:write` | `POST /v1/webhooks`, `DELETE /v1/webhooks/{id}`, `POST /v1/webhooks/{id}/deliveries/{id}/retry` | | `webhooks:manage_secret` | `POST /v1/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 /v1/metrics/query`, every `resource` (`charges`, `transactions`, `distributions`) | | `metrics:charges:read` | `POST /v1/metrics/query` with `resource: 'charges'` only | | `metrics:transactions:read` | `POST /v1/metrics/query` with `resource: 'transactions'` only | | `metrics:distributions:read` | `POST /v1/metrics/query` with `resource: 'distributions'` only | | `sandbox:trigger` | `POST /v1/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 /v1/recipients`, `GET /v1/recipients/{id}/transferability` | | `recipients:write` | `POST /v1/recipients`, `DELETE /v1/recipients/{id}` — registers or revokes a recipient (an address eligible to be *referenced* by `recipientId` in a split); does not by itself make it eligible to become a key's `payoutAddress`, and revoking a recipient this scope registered has no effect on any existing key — that only happens for a recipient that separately went through `recipients:manage_payout` (see that scope). Deliberately weak in this respect: `recipients:write` is safe to hand to a lower-trust or third-party integration key precisely because it can never be used to disable another key | | `recipients:manage_payout` | `PATCH /v1/recipients/{id}` — the only way a recipient becomes eligible to be used as an API key's own `payoutAddress`. Deliberately separate and stricter than `recipients:write`; meant to be held only by a key that already went through its own out-of-band approval for this specific action (e.g. Dashboard's internal key, after session + owner role + email confirmation on its side) — never a merchant-facing or third-party integration key. Revoking a recipient that currently has `payout: true` has a second effect beyond blocking future splits: any existing API key whose own `payoutAddress` matches it stops authenticating entirely (`401 payout_address_revoked`) on its very next request. This is deliberately gated on `payout: true`, not any revoked recipient — `payoutAddress` is public (the first entry in the on-chain split), so gating it on the weaker `recipients:write` scope would let any integration key permanently deny any other key by registering-then-revoking its address | `metrics:read` and the three `metrics:{resource}: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 ever hold `charges:split_write` together with either recipients-registration scope (`CONFLICTING_SCOPE_PAIRS`, `findConflictingScopes`, exported from this same package). Core's `authMiddleware` rejects such a key outright (`403 conflicting_api_key_scopes`) before any route runs, regardless of whether the minting UI also refuses to issue one — the ability to *register* a recipient and the ability to *route a charge's split* to one must live on separate keys, or a single leak defeats the whole point of requiring recipients to be pre-registered. A key-minting UI should call `findConflictingScopes(requestedScopes)` itself and refuse the combination before ever calling Core, rather than relying on this check as the only line of defense. Two authenticated routes aren't gated by any scope at all — `GET /v1/networks` (the capabilities matrix, identical for every key in an environment, nothing tenant-specific to protect) and `GET /v1/distributions/pending`/`/pending/events` (a cross-tenant public feed by design — `distribute()` on 0xSplits is permissionless on-chain, so this endpoint exists for discoverability, not to grant a capability that isn't already open to anyone). Both still require a valid, unrevoked, environment-matched key — they just don't check `scopes`. --- --- url: https://api.klappay.com/types/tokens-and-networks.md --- # Tokens and networks Exported from `tokens.ts` and `networks.ts`. ## `Token` `TokenSchema`/`Token` — currently `'USDC' | 'USDT'`. Payment availability is resolved per token, network, and environment. `live` currently enables both tokens on Base, Optimism, Polygon, Ethereum, Arbitrum, Avalanche, and BNB Chain, USDC only on Arc, and USDT only on TRON. `test` has USDC on Base Sepolia, Optimism Sepolia, Ethereum Sepolia, and Arc Testnet, and USDT on TRON's Nile testnet — no other configured test deployments beyond those (BNB Chain has no `test` deployment at all — 0xSplits has no BNB testnet). An unconfigured or disabled pair is rejected with `422 token_not_supported`. Use `GET /v1/networks` for the current server's accepted matrix before creating a charge. Arc has no `USDT` deployment at all (Circle's own chain; only USDC/EURC/USYC are supported there) — `getTokenDeployment('USDT', 'arc', ...)` always returns `undefined`, same as querying any other never-configured combination. TRON is the mirror case — no `USDC` deployment researched/added there yet, only `USDT` (`TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` on mainnet, `TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf` on Nile testnet, both 6 decimals, both independently verified live against the real chains). Arc's `USDC` is also structurally different from every other network's: it's Arc's own native gas asset, not a separately-deployed contract — the address recorded in `TOKEN_DEPLOYMENTS` is a fixed system address exposing an "optional ERC-20 interface" over that native balance (identical on Arc mainnet and testnet). Its recorded `decimals` is `6`, matching that ERC-20 interface — **not** the native balance's own 18 decimals, which this package's `TokenDeployment` never exposes. ### Resolve the contract and its decimals together `TokenDeployment` is ``{ address: `0x${string}`; decimals: number }``. `TOKEN_DEPLOYMENTS` maps `Token` → `Network` → `Environment` to this metadata. Network and environment entries are optional: an absent entry means the combination is unknown, with no default contract or decimal count. `getTokenDeployment(token, network, environment)` returns that metadata or `undefined`. `isPaymentDeploymentEnabled(token, network, environment)` returns whether the cataloged deployment is enabled by this package's payment policy. The API's `GET /v1/networks` remains authoritative for the running server; clients can be on an older package version. ```ts import { getTokenDeployment, isPaymentDeploymentEnabled, } from '@klappay/types/constants' const deployment = getTokenDeployment('USDC', 'base', 'live') if (!deployment || !isPaymentDeploymentEnabled('USDC', 'base', 'live')) { throw new Error('Payment deployment unavailable') } const tokenContract = deployment.address const decimals = deployment.decimals ``` Every configured deployment outside BNB Chain uses 6 decimals. Both configured BNB Chain contracts use **18 decimals**: | Token | BNB Chain live contract | Decimals | Payments enabled | |---|---|---:|---| | USDC | `0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d` | 18 | Yes | | USDT | `0x55d398326f99059fF775485246999027B3197955` | 18 | Yes | Both addresses are Binance-Peg assets, with Binance custody/backing risk; neither entry should be represented as direct Circle or Tether issuance. The [BNB Chain token catalog](https://github.com/bnb-chain/mpp-sdk#tokens) identifies these addresses and their 18-decimal precision. Token symbols alone do not identify a contract, issuer, or decimal count. ### Compatibility exports ``TOKEN_ADDRESSES: Record>>>>`` is a compatibility address-only view **derived from `TOKEN_DEPLOYMENTS`**. An address being present is not sufficient to offer a payment method: resolve the metadata and check availability with `isPaymentDeploymentEnabled` separately. `TOKEN_DECIMALS` (`6`) is **deprecated and unsafe for general token conversion**. It remains exported for source compatibility with older consumers, but BNB's configured tokens do not use that precision. Migrate payment preparation, amount formatting, and raw-value validation to the resolved `TokenDeployment.decimals`. Removing this legacy export requires a future breaking release. Index `TOKEN_ADDRESSES[token][network]?.[environment]` and handle `undefined`. Test and live refer to different chains, so metadata must never fall back from one environment to the other. ## `Network` `NetworkSchema`/`Network` — `'base' | 'optimism' | 'polygon' | 'ethereum' | 'arbitrum' | 'avalanche' | 'bnb' | 'tron' | 'arc'`. This is the full type — every one of these values is a valid `Network` for reading/filtering (e.g. `ListChargesInput.network`). `solana` used to be part of this type but was removed entirely (not just left non-operational) once there was no near-term plan to wire it. `tron` and `arc` were onboarded at the type/schema level before either joined `OPERATIONAL_NETWORKS` — the deliberate "accepted but not yet operational" pattern the next genuinely new chain should reuse too. Both are operational now — see `OPERATIONAL_NETWORKS` below. `OPERATIONAL_NETWORKS` (`readonly ['base', 'arbitrum', 'optimism', 'polygon', 'ethereum', 'avalanche', 'bnb', 'arc', 'tron']`) and its matching `OperationalNetwork` type describe the networks wired into the code. `CreateChargeSchema.acceptedPayments` checks membership, but this list does not override per-deployment payment availability — a pair must also exist for the API key's environment and pass `isPaymentDeploymentEnabled`; otherwise creation rejects it with `422 token_not_supported`. `tron` accepts `POST /v1/charges` requests, but its actual self-hosted node infrastructure (`tron-nodes`) isn't deployed to a real VPS yet — a TRON charge fails cleanly with `503 rpc_unavailable` until it is (see `docs/payments.md`'s "TRON detection" section in the main `klap-core` docs). `NETWORK_FAMILIES` (`Record`) — which split-address family a network belongs to. A charge predicts one split address up front and reuses it across every network it accepts, which is only safe when they all resolve that address through the same split factory. `acceptedPayments` can only ever list networks from one family — mixing them is rejected with `400 validation_error`. Every officially-supported 0xSplits network is `'evm-official'` — Arc included, since 0xSplits deployed its official contracts there — so Arc can be mixed with any other EVM network in one charge. Tron has its own, temporary, self-hosted split factory instead, so it's never interchangeable with an `'evm-official'` network. `EVM_NETWORKS` (`readonly ['base', 'optimism', 'polygon', 'ethereum', 'arbitrum', 'avalanche', 'bnb', 'arc']`) and its matching `EvmNetwork` type. `tron` is the one `Network` value **not** in this list — everything else is EVM-based (Arc included; see `NETWORK_FAMILIES`). Use this to guard any logic that assumes an EVM-style address/RPC. `NETWORK_LABELS: Record` — display names (`'Base'`, `'Optimism'`, ...) for UI use. `NETWORK_EXPLORERS: Record` — each network's block explorer base URL (e.g. `https://basescan.org`), used to build a direct transaction link yourself from `Charge.txHash` (e.g. `${NETWORK_EXPLORERS[network]}/tx/${charge.txHash}`). `CHAIN_IDS: Record>>` — the numeric chain ID for each network/environment pair (e.g. `8453` for Base mainnet, `84532` for Base Sepolia) — the same IDs a wallet's `wallet_switchEthereumChain` or an RPC provider expects (EIP-155). `Partial` on `Environment` for the same reason as `TOKEN_ADDRESSES`: not every network has a `test` chain ID (only `base`, `optimism`, `ethereum`, and `arc` do — see `Token` above for why). ## Zero-dependency constants: `@klappay/types/constants` Every plain constant and resolver on this page (`TOKEN_DEPLOYMENTS`, `getTokenDeployment`, `isPaymentDeploymentEnabled`, `TOKEN_ADDRESSES`, `TOKEN_DECIMALS`, `ALT_TOKEN_ADDRESSES`, `ALT_TOKEN_DECIMALS`, `EVM_NETWORKS`, `OPERATIONAL_NETWORKS`, `NETWORK_FAMILIES`, `NETWORK_LABELS`, `NETWORK_EXPLORERS`, `CHAIN_IDS`) is also available from a second entry point, `@klappay/types/constants`, that carries zero Zod in its bundle — useful for a browser/wallet-side bundle that only needs the plain values and doesn't want to pay for the schema-validation code that ships with the main `@klappay/types` entry. Everything it exports is identical to (and stays in sync with) the same name imported from `@klappay/types` — the subpath is a smaller bundle of the same values, not a separate or alternate copy: ```ts import { CHAIN_IDS, ALT_TOKEN_ADDRESSES } from '@klappay/types/constants' ``` ## See also * [`charges.md`](./charges.md) — where `Token`/`Network` are actually used (`CreateChargeInput.acceptedPayments`, `Charge.acceptedPayments`/ `paidWith`), and `GET /v1/networks` (`CapabilitiesSchema`) — the live version of this same matrix, scoped to your API key's `environment`, rather than the full type-level listing on this page. --- --- url: https://api.klappay.com/types/errors-and-health.md --- # Errors and health Exported from `errors.ts` and `health.ts`. ## Error responses — `ErrorPayloadSchema` Every non-2xx response from the API uses this shape: ```ts import { ErrorPayloadSchema, type ErrorPayload } from '@klappay/types' const payload: ErrorPayload = { error: { code: 'validation_error', message: 'amount must be positive', param: 'amount', }, } ``` * `code` — a stable, machine-readable identifier (e.g. `validation_error`, `charge_not_found`, `invalid_trigger_state`). Safe to branch on in code. There's no single enum of every possible value here (it's a plain `string`, since the set is per-endpoint) — the live OpenAPI/Scalar reference is the authoritative source for which codes a specific endpoint can return: every response in its `responses` block names the actual code(s) in parentheses at the end of the description. * `message` — human-readable, safe to log or show a developer. Not meant for end users verbatim. * `param` — which request field the error refers to, when applicable (omitted for errors that aren't about a specific field). If you want a typed exception thrown automatically instead of parsing this shape yourself, an official SDK does that — available at [github.com/klappay](https://github.com/klappay); see the live API reference for details on the full list of error classes. ## `GET /v1/health` — `HealthSchema` `HealthSchema`/`Health` is the response shape of the public health-check endpoint. `status`/the HTTP status code both mirror `db`: `error` (and `503`) when the database connectivity check fails, so a plain status-code-only uptime check still catches a DB outage without needing to inspect the JSON body. The other fields are operational signals, not just "is the process alive": `pendingWebhooks` (deliveries still awaiting a successful attempt), `oldestPendingChargeAgeSeconds` (age of the oldest still-unpaid charge, `null` if none), and `lastContractWatcherEventAgeSeconds` (seconds since the last on-chain payment notification was received — a cheap proxy for "is payment detection still working", `null` if none have ever arrived). --- --- url: https://api.klappay.com/types/changelog.md --- # @klappay/types ## 6.3.0 ### Minor Changes * c69348f: Adds `RecipientTransferabilitySchema` (with `RecipientTransferabilityPairSchema` and `TransferRestrictionStatusSchema`), the response of the new `GET /v1/recipients/{id}/transferability` endpoint (`recipients:read`). For each token/network pair enabled in the calling key's environment, it reports what the token issuer's own contract says about the recipient's address. `receive` says whether it can be credited and `send` whether it can move funds out; each is `allowed`, `restricted` or `unknown`. The only source is the token contract's on-chain view function (USDC `isBlacklisted`, USDT `isBlackListed`/`isBlocked`). USDT only restricts senders, so its `receive` is always `allowed`. `POST /v1/charges` can now answer `422 recipient_transfer_restricted` (`param: acceptedPayments`) when the issuer restricts an address the charge pays from receiving on an accepted pair. The message lists only the affected pairs. ## 6.2.0 ### Minor Changes * 233f79f: Adds `EscrowSafeTransactionSchema` (and `EscrowTypedDataSchema`), the response of the new `GET /v1/charges/{id}/release/prepare` and `GET /v1/charges/{id}/refund/prepare` endpoints: the exact Safe transaction Klappay will execute for an escrow release or refund — destination, token, amount in base units, chain id, Safe nonce, its EIP-712 `typedData` and `safeTxHash` — so an integrator signs `typedData` with any EIP-712 signer instead of reconstructing the Safe transaction themselves. `toSignableEscrowTypedData` turns that JSON into the shape viem's typings expect (`bigint` numbers, literal field types from `ESCROW_SAFE_TX_TYPES`). `ReleaseEscrowRequestSchema`/`RefundEscrowRequestSchema` descriptions now point at the prepare endpoints, and `EscrowConfigSchema.releaserAddress` documents that the releaser must be an externally-owned account, can move escrowed funds directly on-chain, and can't be rotated for an existing charge. Escrow release, refund and prepare can now answer `422 escrow_safe_mismatch` when the escrow's Safe can't be rebuilt at the charge's address. ## 6.1.0 ### Minor Changes * a1f63ca: `CheckChargeResponseSchema` gains two required fields, `tokenSenders` and `userOperationSenders`, alongside `transactionSender`. They carry on-chain evidence of who paid when the payer didn't sign the transaction itself: the `from` of the paying accepted-token transfers (an EIP-7702 wallet behind a gas-sponsoring relayer), and the ERC-4337 account whose own user operation paid (a bundler-submitted transaction, swaps included). Both are `[]` whenever `transactionSender` is `null`. `transactionSender`'s description no longer claims it is always the payer's wallet. ## 6.0.1 ### Patch Changes * 795085e: Documents the narrowed `transactionSender` semantics on `CheckChargeResponseSchema`: it is now only populated when the hinted `txHash` actually paid the charge. On a still-open charge, the receipt must contain a Transfer of an accepted token to the charge's address. On an already-`confirmed`/`underpaid` charge, the hash must be a transfer already credited to it on that network — previously such a charge always returned `null`, which made a re-check after an SSE `confirmed` event unable to learn the sender. Also updates `confirmationProgress`'s description, since a terminal charge with a matching hint now makes one receipt read instead of none. Description-only change; no schema shape changes. ## 6.0.0 ### Major Changes * d4f77e4: Arc now settles through official 0xSplits v2.2, the same Push split factory every other EVM network uses, instead of a separate self-hosted split deployment. `NETWORK_FAMILIES.arc` is now `'evm-official'`, so an Arc charge's split address is the same one predicted for Base, Arbitrum and the other EVM networks, and `acceptedPayments` can now mix `arc` with any other EVM network. Mixing TRON with any EVM network (Arc included) is still rejected. **Breaking:** `'arc'` has been removed from the `NetworkFamily` union (and from `NETWORK_FAMILIES`' value type). Code that switches or branches on `NetworkFamily === 'arc'` must treat Arc as `'evm-official'` instead. ## 5.2.0 ### Minor Changes * 200aaeb: Re-enables `USDC`/`USDT` payments on BNB Chain (`live`) — `isPaymentDeploymentEnabled` no longer hardcodes `bnb`+`live` to `false`. A charge can now accept BNB the same as any other operational network, and `GET /v1/networks` lists it accordingly. This lifts the SEG-01 containment introduced 2026-09-14 after a decimals bug (BNB's contracts use 18 decimals; the previous payment-preparation path assumed 6) — that bug was fixed in the same release that added the containment; today's change only removes the policy block kept on top of the already-fixed decimals-handling path. Existing charges held by that containment (`Charge.paymentIntegrityHold`) are unaffected by this change — each still needs its own reviewed release through `POST /internal/payment-integrity/corrections` before its hold clears, regardless of this policy flip. See `docs/security.md#seg-01` and `docs/operations.md#seg-01-deployment-and-historical-review` for the full history and the release mechanism. Additive, non-breaking for any existing integration: a `bnb`+`USDC`/`USDT` `acceptedPayments` pair that was previously rejected with `422 token_not_supported` or `503 payment_temporarily_unavailable` is now accepted — no previously-valid request changes behavior. ## 5.1.1 ### Patch Changes * 6bf30ef: Fixes a bundle-size regression in `@klappay/types/constants` introduced by the Arc/TRON onboarding changesets: `tokens.constants.ts`'s new `tron` deployment (`TOKEN_DEPLOYMENTS.USDT.tron`) called `tronAddress()`, which lived in `addresses.ts` — the same file as the zod-based `SplitAddressSchema`. Importing that one pure function pulled the whole `zod` dependency into any consumer of the zero-zod `/constants` subpath, ballooning it from ~2.4KB to ~120KB once bundled (verified with an isolated `esbuild` probe importing only `EVM_NETWORKS`). Splits `addresses.ts` into `addresses.constants.ts` (the pure types/functions: `EvmAddress`, `TronAddress`, `SplitAddress`, `isEvmAddress`, `isTronAddress`, `isSplitAddress`, `tronAddress`, `addressesEqual` — no `zod` import) and `addresses.ts` (just `SplitAddressSchema`, re-exporting the constants file), mirroring the existing `tokens.ts`/`tokens.constants.ts` split. `tokens.constants.ts` now imports `tronAddress` from `addresses.constants` instead. No public API change — `addresses.ts`/the main `@klappay/types` export still re-export everything exactly as before; only the `/constants` subpath's actual bundled weight changes. ## 5.1.0 ### Minor Changes * 7ea60e6: Adds `arc` to `Network`/`EVM_NETWORKS`/`OPERATIONAL_NETWORKS`/`CHAIN_IDS` (`live` chain `5042`, `test` chain `5042002`) and its `USDC` deployment (`TOKEN_DEPLOYMENTS`/`TOKEN_ADDRESSES`, both environments) — Arc's own native gas asset, exposed through an optional ERC-20-compatible interface rather than a separately-deployed contract. Adds `cirBTC`, Circle's own BTC-backed token, as Arc's swap-to-pay trusted alt-token (`live` only, matching every other alt-token entry). Also adds `NETWORK_FAMILIES`, grouping every `Network` into `'evm-official' | 'tron' | 'arc'` — a charge predicts one split address up front and reuses it across every accepted network, which is only safe when every accepted network resolves that address through the same split factory. Arc runs its own, self-hosted 0xSplits fork (`@klappay/arc-splits`) with a different factory than every officially-supported network, so `AcceptedPaymentsSchema` now rejects (400 `validation_error`) a request whose `acceptedPayments` mixes networks across families. Escrow is also supported on Arc — Safe's canonical proxy-factory/singleton deployment was independently verified live against both `rpc.mainnet.arc.io` and `rpc.testnet.arc.network`. Additive, non-breaking: existing `Network` values, `TOKEN_ADDRESSES` entries, and previously-valid `acceptedPayments`/escrow combinations are unchanged — no prior request could have included `arc`, since it didn't exist in `Network` before this. ## 5.0.0 ### Major Changes * bef5535: Expand the swap-to-pay trusted alt-token set with established, deeply-liquid cryptocurrencies per network: `LINK` (Ethereum, Arbitrum, Optimism, Polygon, Base, Avalanche's bridged `LINK.e`), `ARB` (Arbitrum), `OP` (Optimism), `CBETH` (Base), and `BTC.b` filling Avalanche's previously-empty `BTC` slot. BNB Chain is intentionally untouched — its payment methods remain disabled by the SEG-01 containment, so there is no accepted-payment pair to swap into there yet. **Breaking**: `AltTokenSchema`'s `'MATIC'` literal is renamed to `'POL'`, matching Polygon's own 2024 native-currency rename — same asset, same 18 decimals, only the accepted string changed. Any integration reading `'MATIC'` off `swapAlternatives`/`CreateSwapQuoteSchema.inputToken`/`SwapQuoteSchema.inputToken` needs to switch to `'POL'`. ## 4.1.0 ### Minor Changes * 7114359: Add per-network/environment token deployment metadata and availability helpers. Preserve `TOKEN_ADDRESSES` as a derived compatibility view and the legacy `TOKEN_DECIMALS` export; integrations should use each deployment's own decimals instead. Both cataloged BNB Chain tokens have 18 decimals and their payment pairs are currently disabled. Add optional exact decimal-string amounts and fees to charge and swap responses, plus an optional charge payment-availability flag. Existing numeric fields remain available for compatibility. ## 4.0.0 ### Major Changes * db02385: Renames the `moralis_webhook` `TransactionSource` value to `contract_watcher`, and `HealthSchema.lastMoralisEventAgeSeconds` to `lastContractWatcherEventAgeSeconds` — Core's payment-detection push path moved from a third-party webhook (Moralis Streams) to a self-hosted on-chain event subscription (`contract-watcher.ts`), so the value/field names describing that path changed to match. No behavior change for the confirmation-depth/trust model itself: every detected transfer was always independently re-verified on-chain and re-derived against the current block, regardless of which path detected it first. Any integrator branching on `TransactionSource === 'moralis_webhook'` or reading `lastMoralisEventAgeSeconds` from `GET /v1/health` needs to update to the new names. ## 3.8.0 ### Minor Changes * 924abc3: Adds `ChargeFeePayerSchema` (`ChargeFeePayer`, `'merchant' | 'payer'`) and a `feePayer` field on `CreateChargeSchema`/`ChargeSchema` — set `feePayer: 'payer'` at creation to gross up `amount` so the payer covers Klappay's fee instead of it being deducted from your payout. `ChargeSchema` also gains `feePercent` (now public, was internal-only), `feeAmount`, and `merchantAmount` so a client can render a price breakdown without reimplementing the fee math itself. * 56385aa: Adds `ConfirmationProgressSchema` (`ConfirmationProgress`) — `{ network, blocksSeen, blocksRequired, percent }`, reporting how close an already-detected transfer is to its network's required confirmation depth before it's credited. `CheckChargeResponseSchema` now carries it as `confirmationProgress` (present while a detected transfer isn't deep enough yet, `null` otherwise), and `GET /v1/charges/{id}/events` streams the same shape as a new `event: confirmation_progress` SSE event alongside the existing `event: charge`. ## 3.7.0 ### Minor Changes * 1cb8f20: Add `RefundEscrowRequestSchema`, the `charge.escrow_refunded` webhook event, and `escrow.refundedAt` on `ChargeSchema` — support for `POST /v1/charges/{id}/refund`, which returns an escrow's full balance to the payer instead of the split address. ## 3.6.0 ### Minor Changes * ff085f9: `POST /v1/charges/{id}/check`'s response now carries `transactionSender` — the `txHash` transaction's own signer/sender, which stays the payer's real wallet even when the payment routes through a swap/aggregator, unlike the credited transfer's own `from`. Adds `CheckChargeResponseSchema` (`ChargeSchema` plus `transactionSender`), matching what `POST /internal/charges/{id}/check` already returned. ## 3.5.2 ### Patch Changes * d9dec69: `CreateChargeSchema.idempotencyKey`'s description now documents the `409 idempotency_key_reused` error a caller gets when reusing a key with a different request body, instead of only describing the successful-replay case. ## 3.5.1 ### Patch Changes * ec700f3: Update the escrow section of `docs/charges.md` — `escrow` is now enabled on `POST /v1/charges` (Core's creation guard was lifted after live end-to-end verification). No schema change. ## 3.5.0 ### Minor Changes * 703d59c: Add `charge.escrow_released` to `ChargeWebhookEventTypeSchema`, fired once an escrow-configured charge's funds move from its Safe to the split address via the newly-implemented `POST /v1/charges/{id}/release`. Also corrects `ReleaseEscrowRequestSchema`'s description, which previously (inaccurately) said the release amount was fixed at escrow creation — it's actually the escrow's live on-chain balance at release time, since the destination is fixed but the amount received can vary (underpay/overpay/installments). ## 3.4.0 ### Minor Changes * 16e5271: Add `escrowReleaserAddress`, `escrowFeePercent`, and `escrowReleasedAt` to the `charges` resource's queryable dimension, metric, and date fields in the metrics query schema (`ChargesQueryFieldSchema`, `ChargesMetricFieldSchema`, `ChargesDateFieldSchema`), so escrow-configured charges can be filtered and aggregated through `POST /v1/metrics/query`. ## 3.3.0 ### Minor Changes * cec1236: Adds `EscrowConfigSchema` (`escrow` field on `CreateChargeSchema`/`ChargeSchema`) and `ReleaseEscrowRequestSchema` — the request/response shapes for creating an escrow-configured charge, where funds lock in a dedicated non-custodial Safe until a signature from `escrow.releaserAddress` releases them. `releaserAddress` is optional and defaults to the API key's own `payoutAddress` when omitted. Not yet usable end to end: `POST /v1/charges` currently rejects any request carrying `escrow` with `503 escrow_unavailable`, since the release endpoint these schemas exist for hasn't shipped yet. The schemas are published ahead of that so integrators building against `@klappay/types` don't hit a moving target once it does. ## 3.2.0 ### Minor Changes * bedfcd7: Adds `CheckChargeRequestSchema` (`CheckChargeRequest`) — the optional `txHash`/`network` request body for the new `POST /v1/charges/{id}/check` on-demand payment check endpoint. Lets a caller that already has a transaction hash (e.g. right after a swap-to-pay or wallet-connect transaction is sent) point the check at that specific transaction directly instead of a broader block-range scan. Both fields are optional and only ever accepted together — omitting them falls back to the existing scan. ## 3.1.1 ### Patch Changes * 25f6b44: Fix `SwapQuoteSchema.permit2` rejecting a native-currency (`ETH`/`BNB`/`MATIC`/`AVAX`) swap quote with a `ZodError`. 0x's own API sends `permit2: null` explicitly for a native-currency sell, not an omitted key — `.optional()` only ever accepted `undefined`, so a genuinely valid quote crashed the route with a 500 before it ever reached a consumer. `permit2` is now `.nullish()`, accepting `null` (the real shape) alongside `undefined` for backwards compatibility. ## 3.1.0 ### Minor Changes * a2ca627: Add `CHAIN_IDS`, a canonical `Record>>` mapping each network/environment pair to its EVM chain ID (e.g. `8453` for Base mainnet, `84532` for Base Sepolia) — the same IDs a wallet's `wallet_switchEthereumChain` or an RPC provider expects. Also adds a second entry point, `@klappay/types/constants`, that re-exports every plain-object constant this package already had (`TOKEN_ADDRESSES`, `ALT_TOKEN_ADDRESSES`, `NETWORK_LABELS`, `NETWORK_EXPLORERS`, `EVM_NETWORKS`, `OPERATIONAL_NETWORKS`, `TOKEN_DECIMALS`, `ALT_TOKEN_DECIMALS`) plus the new `CHAIN_IDS`, with zero `zod` in its bundle — for a browser/wallet-side app that only needs the plain values and doesn't want to pay for the schema-validation code that ships with the main `@klappay/types` entry. Both are additive: the root `@klappay/types` entry point is unaffected — every existing import keeps working exactly as before, with identical values. ## 3.0.2 ### Patch Changes * 5f30729: Fixes `Charge.swapAlternatives`, shipped incomplete in the previous release: it was returning a flat list of tokens (e.g. `['ETH', 'BTC']`), which loses the network a charge accepting payment on more than one chain actually needs the payer to send on — a token like `BTC` also resolves to a materially different contract per network (`cbBTC` vs `WBTC`). It now returns `{ token, network }` pairs (`SwapAlternativeSchema`) — pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork` on `POST /v1/charges/{id}/quote`. `listAltTokensForNetworks` is renamed `listSwapAlternatives` to match, returning the same pairs. Also fixes: `Charge.swapAlternatives` is now always empty for a `test`-environment charge, even when swap-to-pay is configured and the charge's networks have a trusted alt-token — 0x, who powers `POST /v1/charges/{id}/quote` (klap-core), has no testnet support at all, confirmed live against the real API (it rejects every testnet chain id outright). That endpoint now also fails fast with `422 swap_test_environment_unsupported` for a test charge, before spending a rate-limit slot or an upstream 0x call on a request that could never succeed. ## 3.0.1 ### Patch Changes * 44a3c7c: Adds swap-to-pay support — a payer can now settle a charge with a non-stablecoin cryptocurrency, swapped into one of the charge's own `acceptedPayments` tokens via `POST /v1/charges/{id}/quote` (klap-core). `ChargeSchema.swapAlternatives` lists which cryptocurrencies are trusted as swap input for a given charge, derived from its `acceptedPayments` networks. New exports: `AltTokenSchema`/`AltToken` (`ETH`, `BNB`, `MATIC`, `AVAX`, `BTC`), `ALT_TOKEN_ADDRESSES`, `ALT_TOKEN_DECIMALS`, `listAltTokensForNetworks` (`alt-tokens.ts`), and `CreateSwapQuoteSchema`/`SwapQuoteSchema` (`swap.ts`). `BTC` is backed by `WBTC` (Ethereum, Arbitrum, Optimism, Polygon) or `cbBTC` (Base) — `SwapQuoteSchema.permit2` is present only for this kind of ERC-20 input, since it needs a Permit2 allowance signature that a network's own native currency (`ETH`/`BNB`/`MATIC`/`AVAX`) never does. `SwapQuoteSchema.fees` breaks out both Klappay's own swap fee and 0x's separate protocol fee (when it applies), so a client can show the payer exactly who's charging what — neither ever reduces `outputAmount`, the merchant's full amount owed. See `docs/payments.md` (klap-core)'s "Swap-to-pay" section for the full flow. ## 3.0.0 ### Major Changes * 0cb9c1d: **`splitRecipients` now references a pre-registered `Recipient` by id instead of a raw address — closes the same redirect risk the 2.0.0 `payoutAddress`-in-the-JWT change closed for the primary payout destination, reopened through this field.** See `docs/security.md`'s finding #50 (klap-core). **Breaking:** * `CreateChargeSchema.splitRecipients` entries are now `{ recipientId, percent, label? }` — `address` is no longer accepted. `SplitRecipientInputSchema` replaces the old input shape of `SplitRecipientSchema`; `SplitRecipientSchema` itself is now the response-only shape echoed on `ChargeSchema.splitRecipients` (still `{ address, percent, label? }`, since a merchant reading their own charge back needs the resolved address, not the id they submitted). * `POST /v1/charges` requires the new `charges:split_write` scope in addition to `charges:write` whenever `splitRecipients` is present — a key with only `charges:write` can no longer create a charge with a split at all, even one referencing an already-registered recipient. **Added:** * `POST /v1/recipients`, `GET /v1/recipients`, `PATCH /v1/recipients/{id}`, `DELETE /v1/recipients/{id}` — register, list, and revoke trusted split-recipient addresses. `CreateRecipientSchema`, `RecipientSchema`, `SetRecipientPayoutSchema`. * Four new `ApiKeyScope` values: `charges:split_write`, `recipients:read`, `recipients:write`, `recipients:manage_payout`. The last is deliberately stricter than `recipients:write` — it's the only way a recipient becomes eligible to be used as an API key's own `payoutAddress`, meant to be held only by a key that's already gone through its own out-of-band approval (e.g. Dashboard's internal key), never a merchant-facing or third-party integration key. * `CONFLICTING_SCOPE_PAIRS`/`findConflictingScopes` — `charges:split_write` can never be granted to the same key as `recipients:write`/`recipients:manage_payout`; Core rejects such a key outright (`403 conflicting_api_key_scopes`) on every request, not just at mint time. See `packages/types/docs/api-keys.md` and `docs/payments.md` (klap-core) for the full detail, including why `recipients:write` alone can never disable another key even though revoking a `payout: true` recipient does. Also declares `engines.node: ">=24"`, matching the rest of the monorepo (CI already builds/tests/publishes this package on Node 24). ## 2.0.4 ### Patch Changes * d7319e6: Fixes `CheckoutProductSchema`/`KlappayCheckoutMetadataSchema`/ `MetadataWithKlappaySchema` (`checkout-metadata.ts`) not being exported from the package's entry point — they existed and were correctly wired into `Charge.metadata`'s shape, but couldn't be imported by name. ## 2.0.3 ### Patch Changes * e13fd8e: Adds `KlappayCheckoutMetadataSchema` (`checkout-metadata.ts`) — validates the `klappay` key inside `Charge.metadata`, today carrying an optional `products` list (`name`, `quantity`, `imageUrl`) shown on Klappay's hosted checkout page. Every other key in `metadata` stays exactly as free-form and unvalidated as before. See `packages/types/docs/charges.md`. ## 2.0.2 ### Patch Changes * b6086dd: Adds `splitRecipients` to `CreateChargeSchema` — optional, up to 5 extra addresses (e.g. a supplier, or whoever closed the sale) to route a slice of a charge's split to. Each `percent` is of the merchant's own net share (`100 - feePercent`), never the gross `amount`. Echoed back on `ChargeSchema.splitRecipients`. See `packages/types/docs/charges.md`. ## 2.0.1 ### Patch Changes * e95240d: Adds `redirectUrl` to `CreateChargeSchema` — optional, where to send the payer once a charge resolves if you use Klappay's hosted checkout page. Echoed back on `ChargeSchema.redirectUrl`, alongside a new `ChargeSchema.checkoutUrl` (nullable — `null` if the deployment has no hosted checkout configured). See `packages/types/docs/charges.md`. ## 2.0.0 ### Major Changes * cc2697e: **Major surface reduction: Core now covers only charges, webhooks, distributions, networks, and metrics.** Account signup/login, organization/member management, API key issuance, and platform administration all moved to a separate Dashboard service. Charge cancellation and continuous/dormant charge modes are gone — every charge now resolves automatically on a short, fixed timeline. The public/redacted charge-lookup surface is gone — that integration is being built separately, outside this repo. In exchange, API keys gained granular scopes. **Removed:** * `auth.ts`, `users.ts`, `organization.ts`, `invitations.ts`, `api-keys.ts` — every `Signup`/`Login`/`AuthResponse`, `User`/`UserRole`, `Organization`, `ApiKey`/`CreateApiKeyInput`, invitation schema. * `public-charges.ts` — `PublicChargeSchema`/`PublicCharge`. `GET /v1/public/charges/{id}` and its SSE/QR-code siblings no longer exist. * Charge cancellation — `POST /v1/charges/{id}/cancel` is gone. `'canceled'` is removed from `ChargeStatusSchema`; `Charge.canceledAt` is removed. `'charge.canceled'`/`'charge.paid_after_cancel'` are removed from `ChargeWebhookEventTypeSchema` and `TimelineEventTypeSchema`; `ChargePaidAfterCancelData` is removed. * Continuous/dormant charge mode — `ChargeModeSchema`/`ChargeMode` and the `mode` field on `CreateChargeSchema`/`ChargeSchema` are gone. `ChargeSchema.pausedAt` is gone. `'charge.contribution_received'`/`'charge.contribution_settled'`/`'charge.paused'`/`'charge.reactivated'` are removed from `ChargeWebhookEventTypeSchema`. `ChargesQueryFieldSchema` (metrics query) drops `mode`. * `SandboxEventTriggerSchema`/`SandboxEventTriggerInput`/`NonChargeTriggerableEventSchema` — the generic non-charge sandbox trigger endpoint is gone. `SandboxTriggerSchema` (charge-event triggers) is unchanged. * `MetricsQueryScopeSchema`/`MetricsQueryScope` — `MetricsQueryResultSchema.meta` no longer has a `scope` field; there's no member-vs-org access split left to report. * `WebhookEventTypeSchema`'s `account`/`security` categories — `AccountWebhookEventTypeSchema`/`SecurityWebhookEventTypeSchema` and every `auth.*`/`member.*`/`payout_address.changed`/`api_key.*`/`webhook.created`/`webhook.deleted`/`webhook.secret_rotated`/`fee_tier.updated` event (plus their `*Data` types in `webhook-event-data.ts`) are gone. Only `payments` and `webhooks` categories remain — 11 events total, down from 36. `WebhookCategorySchema` is now `'payments' | 'webhooks'`. **Changed:** * `CreateChargeSchema.amount`/`.expiresIn` are no longer optional — every charge now has a real target amount and a real deadline. * `CHARGE_EXPIRES_IN_MAX_SECONDS` drops from 365 days to `3600` (60 minutes), sized off real on-chain confirmation latency across every chain Klappay supports. Min stays 60 seconds. * Business metrics querying moves from `POST /v1/organizations/{id}/metrics/query` (session-authenticated) to `POST /v1/metrics/query` (API-key-authenticated, no organization in the path) — every valid key gets access to its own tenant's data, no member/role sub-scoping. **Added:** * `ApiKeyScopeSchema`/`ApiKeyScope`/`API_KEY_SCOPES` — the fixed set of permissions an API key can carry (`charges:read`, `charges:write`, `webhooks:read`, `webhooks:write`, `webhooks:manage_secret`, `metrics:read`, `metrics:charges:read`, `metrics:transactions:read`, `metrics:distributions:read`, `sandbox:trigger`). * `MetricsQuerySchema` gains a `year` date-bucket granularity, `expiresAt` as a charges date field, `distributorAddress` as a distributions dimension field, and `processingStartedAt` as a distributions date field. ## 1.1.2 ### Patch Changes * aa3fe9f: **Simplified — public charge endpoints no longer require `?environment=`.** `GET /v1/public/charges/{id}`, its SSE sibling, and `GET /v1/public/charges/{id}/qrcode` now look a charge up by `id` alone — already a global primary key, not scoped by organization or environment, so there was nothing left to disambiguate. `environment` comes back as a field on the response instead of being a required input; callers who want to assert it matches an expectation do so themselves. `GetPublicChargeQuerySchema` and `GetPublicChargeQrCodeQuerySchema` are removed — the QR endpoint now takes the same `GetChargeQrCodeQuerySchema` (`{ token?, network? }`) the authenticated one already used. No real consumer of the removed schemas yet, hence `patch` rather than a version bump reflecting a breaking change. ## 1.1.1 ### Patch Changes * b6c5206: **Added — public, unauthenticated QR code:** `GET /v1/public/charges/{id}/qrcode` is the unauthenticated sibling of `GET /v1/charges/{id}/qrcode` — identical EIP-681 QR code, no API key required, new `GetPublicChargeQrCodeQuerySchema` (`GetPublicChargeQuerySchema` extended with the same `token`/`network` selectors the authenticated endpoint already has). Nothing new to redact: the QR only ever encodes `address`/`acceptedPayments`/`amount`, already exposed by `GET /v1/public/charges/{id}`. The authenticated endpoint stays — `klap-app`'s dashboard genuinely uses it today, and keeping that trusted, low-volume traffic off the public endpoint's rate limit (sized to absorb anonymous, potentially abusive traffic) is a real reason to keep both, not just inertia. ## 1.1.0 ### Minor Changes * 3d37a04: API surface cleanup and additions. Bundled as one release, `minor` rather than `major` despite three technically breaking changes below — a deliberate call: there are no real production consumers of any of them yet, so this was treated as pre-1.0-style flexibility rather than forcing a major bump for changes nobody is actually depending on. **Removed — `/v1/verify` (breaking):** the public, unauthenticated proof-of-payment surface (`GET /v1/verify/{id}`, `GET /v1/verify/{id}/events`) is gone, along with its exported schemas (`VerifyChargeSchema`, `VerifyPaymentSchema`, `VerifySplitEntrySchema`, `SplitRecipientRoleSchema`) and the now-unused `ChargeStatusEventSchema`/ `ChargeStatusEvent`. It had no real integrator depending on it — every first-party consumer already reads charge status through the authenticated `GET /v1/charges/{id}`/`GET /v1/charges/{id}/events` instead. **Moved — `/v1/cli/listen` → `/v1/webhooks/listen` (breaking URL change):** same endpoint, same behavior, relocated because it's a generic "stream every webhook event live" capability, not something exclusive to the official CLI. `@klappay/cli` needs the corresponding update. **Changed — `GET /v1/distributions/pending` now paginated (breaking response shape):** was a flat array capped at 200 rows; now returns the same `{ data, nextCursor, hasMore }` envelope every other list endpoint uses (`PaginatedPendingDistributionsSchema`), removing the hard cap. `GET /v1/distributions/pending/events` gained an optional `?limit=` for a self-contained snapshot+live connection (new `ListenPendingDistributionsQuerySchema`) — omitting it keeps the original connect-then-fetch behavior unchanged. **Added — cancel a charge:** `POST /v1/charges/{id}/cancel` marks a `pending`/`partially_paid`, `mode: standard` charge `canceled` — the one terminal `ChargeStatus` value that's merchant-initiated rather than reached automatically. New `Charge.canceledAt` field, two new webhook events (`charge.canceled`, and `charge.paid_after_cancel` for the rare case a payment still lands on a canceled charge's address afterward). **Added — public, unauthenticated charge lookup:** `GET /v1/public/charges/{id}` and its SSE sibling `GET /v1/public/charges/{id}/events` return a new, redacted `PublicCharge` shape (`PublicChargeSchema`) — no API key required, for a consumer with no per-org key at all (e.g. a hosted checkout). This is the public, unauthenticated use case `/v1/verify` used to serve, brought back in a different, safer shape now that a real consumer exists: safety comes from redacting the response (`apiKeyId`/`externalRef`/ `source` always dropped; `metadata` filtered to only the reserved `klappay` key, if present, `null` otherwise), not from a scoped credential. Both endpoints require `?environment=` (`GetPublicChargeQuerySchema`) — there's no key to infer it from, and a mismatch is indistinguishable from the charge not existing (`404`), so the endpoint can't be used to probe an id's real environment. Draws from its own rate limit and, for the SSE variant, its own small connection budget, independent of every authenticated endpoint. ## 1.0.9 ### Patch Changes * 627868c: Adds `GetChargeQrCodeQuerySchema` for `GET /v1/charges/{id}/qrcode` — a new endpoint returning a scannable EIP-681 payment QR code (SVG) for a charge. `token`/`network` query params select which accepted `(token, network)` pair to encode when a charge accepts more than one. ## 1.0.8 ### Patch Changes * 055606b: Adds schemas for three new self-service account endpoints: `ChangeNameSchema` (`PATCH /v1/auth/me`), `ChangePasswordSchema` (`POST /v1/auth/change-password`, requires the current password), and `ChangeEmailSchema`/`ConfirmEmailChangeSchema` (`POST /v1/auth/change-email` → `POST /v1/auth/confirm-email-change`, confirmed via the *current* email address before it takes effect). Also exports `SelfUserSchema` (the "pure identity" shape `AuthResponseSchema.user` already used) and three new `security`-category webhook events: `auth.password_changed`, `auth.email_change_requested`, `auth.email_changed`. ## 1.0.7 ### Patch Changes * 4f2d3e5: `WebhookSchema`/`WebhookListItem` gain `environment` (`'live' | 'test' | null`) — set automatically from whichever API key created the webhook, `null` for a webhook created before this field existed. `charges`/`webhooks`-category events are now only delivered to a webhook whose `environment` matches the event's own (or to a `null`-environment webhook, which still receives every environment); `account`/`security` events have no environment concept and are unaffected. See `docs/webhooks.md`'s "Environment scoping" section. ## 1.0.6 ### Patch Changes * db8177e: `MetricsQuerySchema`'s `orderBy.key` now requires the same safe-identifier pattern (`^[a-zA-Z_][a-zA-Z0-9_]*$`) as `metrics[].alias` — every legitimate output column name (a `groupBy` field, a metric's default name, or its alias) already matches this shape, so this only rejects a value that could never have been a real column reference in the first place. ## 1.0.5 ### Patch Changes * 97e02a5: Adds `MetricsQuerySchema` and its supporting types — the request/response contract for a new session-authenticated, org-scoped ad-hoc metrics query endpoint (`charges`/`transactions`/`distributions` resources, with per-resource typed field enums for filters/groupBy/aggregation). A `metrics[].alias` must match `^[a-zA-Z_][a-zA-Z0-9_]*$` and must not collide with a `groupBy` field name or the reserved word `bucket` (it can become a SQL column alias server-side). See `docs/metrics.md` and `packages/types/docs/metrics-query.md` for the endpoint itself. * 36b2d49: Documents `Organization.currentFeePercent`/`feeUpdatedAt` more fully — why the platform fee exists, that it's dynamic (volume-based by default, but Klappay may assign a specific organization a different rate), and that it's frozen onto each charge at creation. No exact tier thresholds/percentages are published — those stay internal by design. * a06fecf: Renames the brand name from "Klap" to "Klappay" wherever it appears in schema descriptions and docs, for consistency with the `@klappay` package scope and domain. ## 1.0.4 ### Patch Changes * b9cd132: Adds a `*Request` type — `z.input` — alongside every `*Input` type (`z.infer`) for a schema with a default or transform, for SDKs and other callers that build a request object before it's parsed: * `CreateChargeRequest`, `ListChargesRequest` * `InviteUserRequest` * `CreateWebhookRequest`, `ListWebhookDeliveriesRequest` * `ListApiKeysRequest` * `ListUsersRequest` * `ListOrganizationsRequest` * `PaginationQueryRequest` `*Input` guarantees every defaulted/transformed field is present (`mode`, `limit`, etc.) since it describes an already-parsed value — that made it the wrong type for a caller building a request, where those same fields are meant to be optional. `docs/charges.md`'s `CreateChargeInput` example actually failed to type-check for exactly this reason (missing `mode`/`currency`); it and the other affected docs now use the matching `*Request` type instead. ## 1.0.3 ### Patch Changes * 1d3e9b1: No code changes — updates package metadata and README.md so npm shows everything correctly: * Adds `keywords` for npm search discoverability. * `README.md`'s documentation table, `CHANGELOG.md` link, and `LICENSE` link all now point at real, working URLs — a public docs site at [api.klappay.com/types](https://api.klappay.com/types) (including a changelog page) for the first, `unpkg.com` for the second — instead of relative paths, which never rendered as navigable links on npmjs.com. * `docs/` is no longer bundled in the published tarball — the public docs site above is now the canonical place to read it. * `docs/*.md` had five stray references to this repo's own internal (private, unpublished) documentation by bare path — removed, since an external reader could never reach them. ## 1.0.2 ### Patch Changes * 00dd00a: No code changes — updates package metadata and docs links so npm shows them correctly: * Adds `keywords` for npm search discoverability. * `README.md`'s documentation table now links to [api.klappay.com/docs](https://api.klappay.com/docs) (a dedicated public docs site) instead of relative `./docs/*.md` paths, which never rendered as navigable links on npmjs.com. * `docs/` is no longer bundled in the published tarball — the public docs site above is now the canonical place to read it. ## 1.0.1 ### Patch Changes * 2dbda30: Test changeset to verify the version/publish pipeline end to end (OIDC trusted publishing, the "Version Packages" PR flow, CHANGELOG.md generation). No functional change to the package. ## 1.0.0 Initial public release. TypeScript types and Zod schemas for the Klappay Core API's full request/response surface: * **Charges** — create/read charges, with an optional `amount` (pay-what-you-want) and optional `expiresIn` (never-expiring charges), a `continuous` collection mode alongside the default `standard` mode, activity/pause tracking (`lastActivityAt`/`pausedAt`), and creator attribution (`apiKeyId`, `createdByUserId`, `invitedByUserId`). * **Organizations** — multi-org membership, each with its own role, plus an invite/accept flow by email. * **Webhooks** — registration, delivery inspection, and the full event vocabulary across payments/account/webhooks/security categories. * **Sandbox** — simulate charge and account events with `test` API keys, no real transactions. * **Verify** — public, unauthenticated proof-of-payment lookup. * **Distributions** — discover settlement payouts claimable within their grace period. * **Auth & accounts**, **tokens & networks**, **errors & health** — see `packages/types/docs` (published at [api.klappay.com/types](https://api.klappay.com/types)) for the full topic-split reference. See `packages/types/README.md` for usage.