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 invalidFields worth knowing about:
amount— required,.positive(), up toCHARGE_AMOUNT_MAX. Every charge has a real target amount — there's no "pay what you want" mode anymore. WithfeePayer: 'payer'(below), this is your desired net amount, not the total the payer ends up sending — seefeePayer.feePayer(ChargeFeePayerSchema,'merchant' | 'payer', defaults to'merchant') — who covers Klappay's fee.'merchant':amountis exactly what you set it to, and the fee comes out of your own payout (you netamount * (1 - feePercent / 100)).'payer':amountis grossed up at creation time so that, after the same fee deduction, you still net the value you passed in — the response'samountreflects the larger, fee-inclusive total, andmerchantAmountechoes back what you originally asked for. Frozen at creation, and included in the idempotency-key hash — seedocs/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 toCHARGE_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 with400 validation_errorat the schema level. Each pair'snetworkmust be inOPERATIONAL_NETWORKS, and the exact(token, network)combination must have metadata inTOKEN_DEPLOYMENTSfor your key'senvironmentand passisPaymentDeploymentEnabled— an unconfigured or disabled pair anywhere in the array rejects the whole request with422 token_not_supported, naming the first bad index (e.g.param: "acceptedPayments[1]"), not silently dropped. Not every token is deployed on everynetwork/environment—USDThas notestdeployment onbase,optimism, orethereum(Tether doesn't issue an official Sepolia one, unlike Circle'sUSDC);arbitrum,polygon, andavalanchehave notestenvironment at all yet, for either token (0xSplits itself has no Arbitrum Sepolia deployment and no Polygon or Avalanche Fuji testnet support);bnbhas notestenvironment either, and both of its cataloged live contracts are Binance-Peg assets with 18 decimals — seetokens-and-networks.mdfor deployment metadata. CallGET /v1/networksto 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) toCHARGE_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 differentamount, say) is a409 idempotency_key_reusederror, 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 entirePOST /v1/chargesrequest is rejected with400 validation_error, same as getting any other field wrong. Every other key inmetadatais unaffected by this — onlyklappayitself is checked. Todaymetadata.klappay.productsis the one thing it carries: up toCHECKOUT_PRODUCTS_MAX(20) items (name, optionalquantity, optionalimageUrl) shown on Klappay's hosted checkout page so the payer knows what they're buying. Purely informational — never validated againstamount, never used by any payment or distribution logic.imageUrlmust behttp(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 (seecheckoutUrlbelow). 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 toCHARGE_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 —recipientIdmust reference aRecipientyou already registered for your organization/environment viaPOST /v1/recipients(seerecipients.md), and using this field at all requires thecharges:split_writescope in addition tocharges:write(seeapi-keys.md). ArecipientIdthat doesn't resolve — wrong id, wrong environment, or revoked — is rejected with422 recipient_not_found_in_split, identically in every case, never a distinct error that would confirm it exists elsewhere.percentis of your own net share (100 - feePercent), not of the charge's grossamount— 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; seedocs/payments.md's "Settling the payout" section for the exact math. The sum of everypercentmust fit within100 - feePercentor the request is rejected with422 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.
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, nevernulland never changes after creation. WithfeePayer: 'payer'this already includes Klappay's fee — seemerchantAmountfor 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 ofamount(e.g.2= 2%), including any escrow surcharge. Frozen at creation.feeAmount—amount * feePercent / 100, incurrencyunits. Computed on every read, not stored — never drifts fromamount/feePercent.merchantAmount—amount - feeAmount, what you actually net once the payout settles. The same formula regardless offeePayer— that field only affects whatamountwas 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 fromacceptedPayments' own networks and recomputed on every read. Empty on atest-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 — notnull; a charge that's never been paid returnspaidWith: []. Every accepted pair sums: a charge configured withacceptedPayments: [USDC/base, USDT/base]can be confirmed by $9 in USDC plus $1 in USDT, andpaidWithwould then hold both pairs. Only a transfer on a pair that's actually inacceptedPaymentsis credited — one on any other pair is still recorded (visible in the charge's timeline) but never counted towardamountReceived.amountReceived— cumulative amount received on-chain as a legacy JSON number.nulluntil the first transfer arrives. Can lose precision; preferamountReceivedExactfor calculations.amountReceivedExact— exact cumulative received amount in currency units, with up to eighteen fractional digits and no scientific notation.nulluntil a transfer arrives. Can exceedamountExact.paymentUnavailable— whentrue, 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—trueonceamountReceivedends up greater thanamount. 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'ssettlementStatus.settlementStatus(SettlementStatusSchema, nullable) — a separate step fromstatus.status: confirmedonly means the payment was detected on-chain;settlementStatus: completedmeans the merchant's wallet actually has the funds.nullon the parentChargemeans no payout has been attempted yet.settledAt(nullable) — whensettlementStatusfirst reachedcompleted, mirroringconfirmedAt's role forstatus.nullwhilesettlementStatusispending/failed/null.environment(EnvironmentSchema) —liveortest, matching the API key used to create it.liveuses the selected network's mainnet;testuses its configured testnet — real on-chain activity, just never real money.address— the on-chain address the payer sends funds to, identical no matter whichacceptedPaymentspair they use (0xSplits addresses are chain-agnostic — CREATE2 with nochainIdin 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 untilescrow .releaserAddressreleases them, not immediately available to the merchant.escrow(nullable) — present only when this charge was created withescrow— see below.lastActivityAt— when a transfer was last credited toward this charge, orcreatedAtif none has arrived yet.redirectUrl(nullable) — echoes whatever was set at creation, ornullif you didn't set one.checkoutUrl(nullable) — link to Klappay's hosted checkout page for this charge.nullif this deployment has no hosted checkout configured — build your own payment UI fromaddress/acceptedPaymentsin 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:
- Prepare.
GET /v1/charges/{id}/release/prepare(or/refund/prepare) returnsEscrowSafeTransactionSchema: the exact Safe transaction Klappay will execute, withdestination,tokenAddress,amount(in the token's smallest unit),chainId,safeAddress, its EIP-712typedDataandsafeTxHash. Preparing is read-only and never moves funds. - Check and sign. Compare
destination,tokenAddress,amount,chainIdandsafeAddresswith your own records, then signtypedDatawith the releaser's key — any EIP-712 signer works (viem, ethers, a KMS). No Safe SDK and no RPC access are needed. - 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).addressStore 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'spayoutAddressbecomes 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.
transactionSenderis thetxHashtransaction'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 ownfromis 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.tokenSenderslists the checksummedfromof 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.userOperationSenderslists the ERC-4337 account whose own successful user operation produced a paying transfer, read fromUserOperationEvents 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 payment URI (ethereum:<tokenContractAddress>@<chainId>/transfer?address=<charge.address>&uint256=<rawAmount>), 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— the fullcharge.*event vocabulary and theWebhookPayloadenvelope these events arrive in.sandbox.md— triggering any charge state transition without a real on-chain transfer.
