{"openapi":"3.1.0","info":{"title":"Klappay Core API","version":"0.1.0","description":"Klappay — Keep Liquidity Always Permissionless.\n\nA non-custodial REST API for accepting and distributing on-chain payments. Funds never pass through Klappay — each charge gets a predicted, immutable on-chain address, and payments are detected and settled directly to the merchant.\n\nAn MIT-licensed npm package, `@klappay/types` (TypeScript types and Zod schemas for every request/response shape below), makes integrating easier — it wraps this exact API, so nothing here is duplicated by hand. Full guides and every schema explained: **[/types](/types)**. Official SDKs (multiple languages) and a CLI (`@klappay/cli`) are also available — see [github.com/klappay](https://github.com/klappay).\n\nPrefer Postman? [Download a ready-to-import collection](/postman/core.postman_collection.json) — generated fresh from this exact OpenAPI spec, always in sync with what you see here.\n\n**Quick reference:**\n- **Timestamps** — ISO 8601, UTC (e.g. `2026-01-15T10:30:00.000Z`).\n- **Amounts** — decimal, major unit (dollars, never cents), up to 6 fractional digits; anything more precise is silently truncated.\n- **Durations** (`expiresIn`, etc.) — whole seconds.\n- **Percentages** (`feePercent`, etc.) — plain numbers, `1.5` means 1.5% (never a 0–1 fraction).\n- **Environments** — one base URL; `live` vs `test` is selected entirely by which API key you present (`klap_live_...` vs `klap_test_...`), not by which URL you call. `test` keys never touch the blockchain and unlock `POST /v1/sandbox/*`.\n- **Versioning** — `/v1/` fields and behavior won't change or disappear without a new version prefix; new optional fields/endpoints may be added over time.\n- **Rate limits** — `100` req/min per API key or IP on `/v1/*`. A `429` response includes a `Retry-After` header."},"tags":[{"name":"Charges","description":"Create and read payment charges — the core payment object."},{"name":"Webhooks","description":"Register endpoints to receive event notifications, inspect delivery history, or stream every event live via SSE without registering anything."},{"name":"Sandbox","description":"Simulate charge events with `test` API keys — no real transactions, no waiting."},{"name":"Metrics","description":"Ad-hoc analytics over your own charges/transactions/distributions data, scoped entirely to the calling API key's tenant."},{"name":"Distributions","description":"Discover splits with a settlement payout claimable within its grace period — for keepers/bots calling 0xSplits' permissionless `distribute()`, not a typical merchant integration."},{"name":"Health","description":"The API's own operational status."},{"name":"Recipients","description":"Register addresses eligible to be referenced in a charge's `splitRecipients` — separate from, and a prerequisite for, actually using one in a split. See `charges:split_write`/`recipients:write`/`recipients:manage_payout` scopes."}],"servers":[{"url":"https://api.klappay.com/v1","description":"Production"}],"components":{"securitySchemes":{"apiKeyAuth":{"type":"http","scheme":"bearer","description":"API key, e.g. `klap_live_...` or `klap_test_...`"}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","description":"A stable, machine-readable error identifier (e.g. `validation_error`, `charge_not_found`)."},"message":{"type":"string","description":"Human-readable explanation, safe to log or show a developer — not meant for end users."},"param":{"type":"string","description":"Which request field the error refers to, when applicable."}},"required":["code","message"]}},"required":["error"]}},"parameters":{}},"paths":{"/health":{"get":{"tags":["Health"],"summary":"Check API status","description":"Reports overall health: a real database connectivity check, how many webhook deliveries are still pending, how long the oldest unpaid charge has been waiting, and how recently an on-chain payment notification was received — a cheap signal for whether the payment-detection pipeline is still running. Doesn't require authentication. Returns 503 (not 200) when the database check fails, so a plain HTTP-status uptime check catches an outage without needing to inspect the response body.","responses":{"200":{"description":"API is healthy","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"],"description":"`error` when the database connectivity check fails — the HTTP status code mirrors this (503 instead of 200), so a plain uptime check (not just a JSON-aware one) still catches a DB outage."},"version":{"type":"string"},"timestamp":{"type":"string","format":"date-time"},"db":{"type":"string","enum":["ok","error"],"description":"Result of a real database connectivity check, not just a process-alive check."},"pendingWebhooks":{"type":"number","description":"Count of webhook deliveries still awaiting a successful attempt."},"oldestPendingChargeAgeSeconds":{"type":["number","null"],"description":"Age of the oldest still-unpaid charge, in seconds. `null` if there are none."},"lastContractWatcherEventAgeSeconds":{"type":["number","null"],"description":"Seconds since the last on-chain payment notification was received — a cheap signal for whether payment detection is currently working. `null` if none have ever been received."}},"required":["status","version","timestamp","db","pendingWebhooks","oldestPendingChargeAgeSeconds","lastContractWatcherEventAgeSeconds"]}}}},"503":{"description":"Database is unreachable","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok","error"],"description":"`error` when the database connectivity check fails — the HTTP status code mirrors this (503 instead of 200), so a plain uptime check (not just a JSON-aware one) still catches a DB outage."},"version":{"type":"string"},"timestamp":{"type":"string","format":"date-time"},"db":{"type":"string","enum":["ok","error"],"description":"Result of a real database connectivity check, not just a process-alive check."},"pendingWebhooks":{"type":"number","description":"Count of webhook deliveries still awaiting a successful attempt."},"oldestPendingChargeAgeSeconds":{"type":["number","null"],"description":"Age of the oldest still-unpaid charge, in seconds. `null` if there are none."},"lastContractWatcherEventAgeSeconds":{"type":["number","null"],"description":"Seconds since the last on-chain payment notification was received — a cheap signal for whether payment detection is currently working. `null` if none have ever been received."}},"required":["status","version","timestamp","db","pendingWebhooks","oldestPendingChargeAgeSeconds","lastContractWatcherEventAgeSeconds"]}}}}}}},"/charges":{"post":{"tags":["Charges"],"summary":"Create a charge","description":"Creates a payment request and predicts a unique, immutable on-chain address for it — no on-chain transaction happens yet. The merchant's payout split is frozen at this moment, so it stays consistent even if the fee tier changes later. The payout wallet itself is not a field on this request — it's bound to the API key used to authenticate (issued that way by Dashboard), so a leaked key can never redirect an existing merchant's funds by passing a different address. Pass `idempotencyKey` to safely retry the same request without creating a duplicate charge. Before the address is predicted, every address this charge would pay (your payout address, each `splitRecipients` entry, and Klappay's fee treasury) is checked against each accepted token's own on-chain issuer restriction (USDC `isBlacklisted`; USDT only restricts senders, so it never blocks a recipient). These restrictions are set by the token issuer (Circle, Tether), not by Klappay — Klappay keeps no blocklist and only refuses what the chain itself would refuse. If the issuer restricts one of them from receiving on a pair, the request fails with `422 recipient_transfer_restricted` naming only the affected `TOKEN on network` pairs — drop those from `acceptedPayments` and retry. A check that can't be answered within 1 second (RPC failure, or no such function on that network) never blocks the charge. `GET /v1/recipients/{id}/transferability` reports the same status per pair ahead of time.","security":[{"apiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"amount":{"type":"number","exclusiveMinimum":0,"maximum":999999999999,"description":"Amount to charge, in `currency` units (e.g. `49.9` = $49.90) — up to 6 decimal places; anything more precise is silently truncated. Required — every charge has a target amount, the first credited transfer that reaches it confirms the charge. With `feePayer: 'payer'` (see below), this is your own desired net amount, not the total the payer ends up sending — the response's `amount` is grossed up to cover `feePercent`, while `merchantAmount` on the response echoes back this exact value."},"feePayer":{"type":"string","enum":["merchant","payer"],"default":"merchant","description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"currency":{"type":"string","enum":["USD"],"default":"USD","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"minItems":1,"maxItems":18,"description":"Every `(token, network)` pair the payer is allowed to pay with — at least one, up to 18. This list is also the only restriction knob: the payer can use any combination of the pairs listed here, and every transfer on one of them is credited and sums toward the charge total (see `paidWith`) — e.g. a charge accepting USDC and USDT can be confirmed by $9 in USDC plus $1 in USDT, or by USDC arriving on two different accepted networks. To require payment in one specific token on one specific network, list only that single pair — a transfer on any pair not in this list is still recorded (for audit) but never credited. Each network must be live (see `GET /v1/networks` for the current matrix) — an unconfigured `(token, network)` combination for your environment is rejected with `422 token_not_supported`. Every network listed must also share the same split family (which networks are grouped together is an implementation detail, not part of this API's contract) — a charge predicts one split address up front and reuses it across every accepted network, which is only safe within one family; mixing families is rejected with `400 validation_error`."},"expiresIn":{"type":"integer","minimum":60,"maximum":3600,"description":"Seconds, not minutes or milliseconds — how long the charge stays open before it expires. Required, min 60, max 3600 (60 minutes) — sized off the slowest chain Klappay supports today (Ethereum mainnet, where a safely-confirmed transfer takes up to ~15 minutes), leaving real margin for payer-side delay (gas spikes, wallet friction) on top of that. Cannot be extended or shortened after creation."},"idempotencyKey":{"type":"string","minLength":1,"maxLength":255,"description":"Scoped to your tenant. Replaying the same key with the exact same request body returns the original charge unchanged instead of creating a duplicate — safe to retry a request after a timeout without double-charging. Reusing the same key with a different body (including a different `escrow` config) is rejected with `409 idempotency_key_reused`, never silently returned as the original charge."},"externalRef":{"type":"string","minLength":1,"maxLength":255,"description":"An opaque correlation id from your own system (e.g. an order id) — echoed back on the charge and in every webhook payload. Not interpreted or validated by Klappay."},"source":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for what created this charge (e.g. `\"checkout\"`, `\"invoice\"`) — useful if you create charges from more than one flow and want to tell them apart later. Not a fixed enum; use whatever values make sense to you."},"metadata":{"type":"object","properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":"string","format":"uri","description":"Where to send the payer once this charge resolves, if you use Klappay's hosted checkout page (see `checkoutUrl` on the read shape) — ignored otherwise. Must be `http(s)` — a browser will navigate here, so `javascript:`/`data:` and other non-navigational schemes are rejected. Otherwise not validated beyond being well-formed; what happens at that destination is yours to build."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"recipientId":{"type":"string","description":"id of a `Recipient` you already registered via `POST /v1/recipients` (not a raw address) — see `recipients:write`/`charges:split_write` scopes. A leaked `charges:write`-only key can never redirect payout to a brand new address this way, only reference one already trusted."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this recipient instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay. Independent of the label the recipient was registered with."}},"required":["recipientId","percent"]},"maxItems":5,"description":"Optional extra recipients for this charge's split — e.g. a supplier or the sales rep who closed the deal — up to 5, each referenced by `recipientId` (see `POST /v1/recipients`), never a raw address. Requires the `charges:split_write` scope in addition to `charges:write`. Frozen at creation exactly like everything else that shapes the split address; cannot be changed afterward. The sum of every `percent` here must fit within `100 - feePercent` (your own net share) — a request that doesn't is rejected with `422 split_recipients_exceed_available_percent`."},"escrow":{"type":"object","properties":{"releaserAddress":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The only address ever authorized to release this charge's escrowed funds — set once at creation, immutable after. Klappay never holds a key with any release authority of its own; every release requires a signature from this address, verified on-chain, never taken on faith. Omit to default to the API key's own `payoutAddress` — the common case where the merchant releasing their own charge is the same wallet they already get paid to. Pass an explicit address only when the releaser is a different party (e.g. an operational key distinct from the payout wallet). Not validated against anything else — any well-formed address is accepted, since Klappay never custodies these funds. Must be a plain externally-owned account (a private key that can produce an ECDSA signature): a smart-contract wallet, a multisig, or an exchange deposit address can never sign a release or refund, and funds sent to such an escrow stay locked. The key behind this address can also move the escrowed funds directly on-chain without going through Klappay, and it can't be rotated for an existing charge — losing it locks that charge's funds permanently."}},"description":"Configure this charge as an escrow instead of a normal payment. Funds land in a dedicated, non-custodial Safe (not the usual split address) and only `releaserAddress` (or, if omitted, your API key's own `payoutAddress`) can ever release them — via `POST /v1/charges/{id}/release`, signed on their end, never something Klappay can trigger or redirect. Omit this field entirely for a normal charge. **Requires every accepted network to be EVM** (Safe — the underlying custody contract — isn't deployed on Tron, and can never be; it's not an EVM chain) — rejected with `400 validation_error` otherwise."}},"required":["amount","acceptedPayments","expiresIn"]},"examples":{"standard":{"summary":"Standard charge","value":{"amount":49.9,"currency":"USD","acceptedPayments":[{"token":"USDC","network":"base"}],"expiresIn":1800}},"escrow":{"summary":"Escrow charge, released by the API key's own payoutAddress","value":{"amount":49.9,"currency":"USD","acceptedPayments":[{"token":"USDC","network":"base"}],"expiresIn":1800,"escrow":{}}},"escrowWithReleaser":{"summary":"Escrow charge with an explicit releaserAddress","value":{"amount":49.9,"currency":"USD","acceptedPayments":[{"token":"USDC","network":"base"}],"expiresIn":1800,"escrow":{"releaserAddress":"0xAbC1234567890000000000000000000000dEaD"}}}}}}},"responses":{"201":{"description":"Charge created","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `ch_...`. Use this to look up the charge later."},"amount":{"type":"number","description":"The total the payer must send, in `currency` units (up to 6 decimal places). This legacy JSON number can lose precision; use `amountExact` for calculations and payment preparation. With `feePayer: 'merchant'` (the default) this is what you requested at creation. With `feePayer: 'payer'` this is grossed up to cover `feePercent` — see `merchantAmount` for what you requested/will actually net."},"amountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact total the payer must send, in `currency` units, as a nonnegative decimal string with at most 6 fractional digits and no scientific notation (e.g. `\"100.000001\"`). Use this instead of `amount` for arithmetic. Optional for compatibility with older API responses."},"feePayer":{"type":"string","enum":["merchant","payer"],"description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"feePercent":{"type":"number","description":"Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%) — includes any escrow surcharge if this charge is an escrow. Frozen at creation; see `feeAmount`/`merchantAmount` for the actual amounts this works out to."},"feeAmount":{"type":"number","description":"`amount * feePercent / 100`, in `currency` units — Klappay's cut of this charge."},"merchantAmount":{"type":"number","description":"`amount - feeAmount`, in `currency` units — what you actually net once the payout settles, regardless of `feePayer` (this is always what the split delivers to you; `feePayer` only affects what `amount` was set to at creation)."},"amountReceived":{"type":["number","null"],"description":"Cumulative amount received on-chain so far, in `currency` units. This legacy JSON number can lose precision; use `amountReceivedExact` for arithmetic with up to 18 fractional digits. `null` until the first transfer arrives. Can exceed `amount` — see `isOverpaid`."},"amountReceivedExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact cumulative amount received on-chain, in `currency` units, as a nonnegative decimal string with at most 18 fractional digits and no scientific notation (e.g. `\"0.0000000001\"`). `null` until the first transfer arrives. Can exceed `amountExact`. Optional for compatibility with older API responses."},"paymentUnavailable":{"type":"boolean","description":"When `true`, payment processing for this charge is temporarily unavailable. Do not offer payment instructions, request another transfer, or fulfill from its monetary/status fields until availability is restored. The charge retains its existing business status. Optional for compatibility with older API responses."},"isOverpaid":{"type":"boolean","description":"`true` if `amountReceived` ended up greater than `amount`. Klappay never refunds the difference automatically — see the docs for why."},"currency":{"type":"string","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair this charge was configured to accept, unchanged after creation."},"paidWith":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every distinct `(token, network)` pair that has actually contributed a credited transfer so far — empty until the first one arrives. Can hold more than one entry: a charge accepting several pairs can be paid across a combination of them, and every entry here sums toward `amountReceived`."},"swapAlternatives":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network to send `token` on — pass both as `inputToken`/`inputNetwork` to `POST /v1/charges/{id}/quote`. The same token can appear more than once here, once per network that trusts it and that this charge accepts payment on."}},"required":["token","network"]},"description":"Every `(token, network)` pair the payer can pay with instead, via `POST /v1/charges/{id}/quote` — derived from the networks in `acceptedPayments` (e.g. a charge accepting USDC on both Base and Optimism lists `ETH` on Base and `ETH` on Optimism separately, since they're different networks the payer has to choose between, not one merged option). Pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork`. Recomputed on every read against Klappay's current trusted list, not frozen at creation — empty if this charge's networks have no trusted alt-token, if swap-to-pay isn't configured on this deployment, or if `environment` is `test` (0x, who powers the swap, has no testnet support at all — `POST /v1/charges/{id}/quote` always rejects a test-environment charge with `422 swap_test_environment_unsupported`)."},"address":{"type":"string","description":"The on-chain address the payer must send funds to — identical across every accepted network (0xSplits addresses are chain-agnostic). Unique per charge, predicted at creation time — funds sent here go directly to the merchant, Klappay never custodies them."},"status":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"settlementStatus":{"type":["string","null"],"enum":["pending","completed","failed"],"description":"Progress of the payout to the merchant's wallet, a separate step from `status` — `status: confirmed` only means the payment was detected on-chain, not that the merchant has been paid yet. `pending`: payment detected, payout not yet attempted. `completed`: the merchant's wallet has the funds. `failed`: the payout attempt failed and retries were exhausted (rare; contact support). `null` on the parent `Charge` means no payout has been attempted yet — nothing has been received, or the charge is still in progress."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"apiKeyId":{"type":["string","null"],"description":"Which of your API keys created this charge. `null` for a charge created before this field existed."},"txHash":{"type":["string","null"],"description":"Transaction hash of the most recent transfer detected for this charge. `null` until a payment is detected."},"externalRef":{"type":["string","null"]},"source":{"type":["string","null"]},"metadata":{"type":["object","null"],"properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":["string","null"],"description":"Echoes the `redirectUrl` set at creation, if any. `null` if none was set."},"checkoutUrl":{"type":["string","null"],"description":"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` instead."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"Address to send a slice of this charge to — an EVM 0x... address or a TRON T... address, matching whichever network family the charge accepts (acceptedPayments can only ever list networks from one split family at a time, so this always matches that same family)."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this address instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay."}},"required":["address","percent"]},"description":"Echoes whatever extra split recipients were set at creation — empty array if none."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"When this charge stops accepting payment, if still `pending`/`partially_paid` by then."},"confirmedAt":{"type":["string","null"],"format":"date-time","description":"When `status` first reached `confirmed`. `null` until then."},"settledAt":{"type":["string","null"],"format":"date-time","description":"When `settlementStatus` first reached `completed` — the merchant's wallet actually has the funds. `null` until then, including while `settlementStatus` is `pending`/`failed`."},"lastActivityAt":{"type":"string","format":"date-time","description":"When a transfer was last credited toward this charge, or `createdAt` if none has arrived yet."},"escrow":{"type":["object","null"],"properties":{"releaserAddress":{"type":"string","description":"The only address that can ever release this escrow — never Klappay."},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the release actually executed on-chain. `null` until then."},"refundedAt":{"type":["string","null"],"format":"date-time","description":"When the refund actually executed on-chain. `null` until then. Mutually exclusive with `releasedAt` — an escrow can only ever be released or refunded once, never both."}},"required":["releaserAddress","releasedAt","refundedAt"],"description":"Present only when this charge was created as an escrow (see `escrow` on the create request) — `null` for a normal charge."}},"required":["id","amount","feePayer","feePercent","feeAmount","merchantAmount","amountReceived","isOverpaid","currency","acceptedPayments","paidWith","swapAlternatives","address","status","settlementStatus","environment","apiKeyId","txHash","externalRef","source","metadata","redirectUrl","checkoutUrl","splitRecipients","createdAt","expiresAt","confirmedAt","settledAt","lastActivityAt","escrow"]}}}},"400":{"description":"Validation error (`validation_error`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:write` scope, or the request includes `splitRecipients` and the key is missing `charges:split_write`, or the key holds both `charges:split_write` and a recipients-registration scope (never allowed together) (`insufficient_scope` / `conflicting_api_key_scopes`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"idempotencyKey reused with a different request body (`idempotency_key_reused`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This token/network/environment combination has no deployed contract yet, or a splitRecipients entry references a recipientId that doesn't exist for this org/environment, exceeds your available percent, or collides with Klappay's treasury/your own payout address, or the token issuer restricts transfers to an address this charge pays on one of the accepted pairs (`param: acceptedPayments`; the message lists the affected pairs) (`token_not_supported` / `recipient_not_found_in_split` / `split_recipients_exceed_available_percent` / `split_recipient_is_treasury_address` / `split_recipient_is_payout_address` / `recipient_transfer_restricted`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Could not reach the blockchain RPC to predict the split/escrow address, or a requested payment method is temporarily disabled. Retry RPC failures; payment methods under review remain unavailable until re-enabled. (`rpc_unavailable` / `payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Charges"],"summary":"List charges","description":"Returns charges for the authenticated organization, newest first, with cursor-based pagination. Filter by `status`, `token`/`network` (the pair actually paid, i.e. `paidWith` — not what was accepted), `environment`, `since` (created after a given time), or `isOverpaid`. Useful as a fallback to catch any status change a missed webhook delivery didn't report.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"required":false,"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that.","name":"status","in":"query"},{"schema":{"type":"string","enum":["USDC","USDT"],"description":"Filters on `paidWith.token` — the pair actually paid, not accepted."},"required":false,"description":"Filters on `paidWith.token` — the pair actually paid, not accepted.","name":"token","in":"query"},{"schema":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Filters on `paidWith.network` — the pair actually paid, not accepted."},"required":false,"description":"Filters on `paidWith.network` — the pair actually paid, not accepted.","name":"network","in":"query"},{"schema":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"required":false,"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live.","name":"environment","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Only return charges created at or after this timestamp (filters on `createdAt`, not on when the status last changed). If polling as a fallback for missed webhooks, use a window at least as wide as the longest `expiresIn` your charges use, or you can miss a long-lived charge that changed status outside a narrower window."},"required":false,"description":"Only return charges created at or after this timestamp (filters on `createdAt`, not on when the status last changed). If polling as a fallback for missed webhooks, use a window at least as wide as the longest `expiresIn` your charges use, or you can miss a long-lived charge that changed status outside a narrower window.","name":"since","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"isOverpaid","in":"query"},{"schema":{"type":"number","minimum":1,"maximum":100,"default":20,"description":"Max items to return per page (1–100, default 20)."},"required":false,"description":"Max items to return per page (1–100, default 20).","name":"limit","in":"query"},{"schema":{"type":"string","maxLength":500,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change."},"required":false,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change.","name":"cursor","in":"query"}],"responses":{"200":{"description":"Paginated charges","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `ch_...`. Use this to look up the charge later."},"amount":{"type":"number","description":"The total the payer must send, in `currency` units (up to 6 decimal places). This legacy JSON number can lose precision; use `amountExact` for calculations and payment preparation. With `feePayer: 'merchant'` (the default) this is what you requested at creation. With `feePayer: 'payer'` this is grossed up to cover `feePercent` — see `merchantAmount` for what you requested/will actually net."},"amountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact total the payer must send, in `currency` units, as a nonnegative decimal string with at most 6 fractional digits and no scientific notation (e.g. `\"100.000001\"`). Use this instead of `amount` for arithmetic. Optional for compatibility with older API responses."},"feePayer":{"type":"string","enum":["merchant","payer"],"description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"feePercent":{"type":"number","description":"Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%) — includes any escrow surcharge if this charge is an escrow. Frozen at creation; see `feeAmount`/`merchantAmount` for the actual amounts this works out to."},"feeAmount":{"type":"number","description":"`amount * feePercent / 100`, in `currency` units — Klappay's cut of this charge."},"merchantAmount":{"type":"number","description":"`amount - feeAmount`, in `currency` units — what you actually net once the payout settles, regardless of `feePayer` (this is always what the split delivers to you; `feePayer` only affects what `amount` was set to at creation)."},"amountReceived":{"type":["number","null"],"description":"Cumulative amount received on-chain so far, in `currency` units. This legacy JSON number can lose precision; use `amountReceivedExact` for arithmetic with up to 18 fractional digits. `null` until the first transfer arrives. Can exceed `amount` — see `isOverpaid`."},"amountReceivedExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact cumulative amount received on-chain, in `currency` units, as a nonnegative decimal string with at most 18 fractional digits and no scientific notation (e.g. `\"0.0000000001\"`). `null` until the first transfer arrives. Can exceed `amountExact`. Optional for compatibility with older API responses."},"paymentUnavailable":{"type":"boolean","description":"When `true`, payment processing for this charge is temporarily unavailable. Do not offer payment instructions, request another transfer, or fulfill from its monetary/status fields until availability is restored. The charge retains its existing business status. Optional for compatibility with older API responses."},"isOverpaid":{"type":"boolean","description":"`true` if `amountReceived` ended up greater than `amount`. Klappay never refunds the difference automatically — see the docs for why."},"currency":{"type":"string","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair this charge was configured to accept, unchanged after creation."},"paidWith":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every distinct `(token, network)` pair that has actually contributed a credited transfer so far — empty until the first one arrives. Can hold more than one entry: a charge accepting several pairs can be paid across a combination of them, and every entry here sums toward `amountReceived`."},"swapAlternatives":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network to send `token` on — pass both as `inputToken`/`inputNetwork` to `POST /v1/charges/{id}/quote`. The same token can appear more than once here, once per network that trusts it and that this charge accepts payment on."}},"required":["token","network"]},"description":"Every `(token, network)` pair the payer can pay with instead, via `POST /v1/charges/{id}/quote` — derived from the networks in `acceptedPayments` (e.g. a charge accepting USDC on both Base and Optimism lists `ETH` on Base and `ETH` on Optimism separately, since they're different networks the payer has to choose between, not one merged option). Pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork`. Recomputed on every read against Klappay's current trusted list, not frozen at creation — empty if this charge's networks have no trusted alt-token, if swap-to-pay isn't configured on this deployment, or if `environment` is `test` (0x, who powers the swap, has no testnet support at all — `POST /v1/charges/{id}/quote` always rejects a test-environment charge with `422 swap_test_environment_unsupported`)."},"address":{"type":"string","description":"The on-chain address the payer must send funds to — identical across every accepted network (0xSplits addresses are chain-agnostic). Unique per charge, predicted at creation time — funds sent here go directly to the merchant, Klappay never custodies them."},"status":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"settlementStatus":{"type":["string","null"],"enum":["pending","completed","failed"],"description":"Progress of the payout to the merchant's wallet, a separate step from `status` — `status: confirmed` only means the payment was detected on-chain, not that the merchant has been paid yet. `pending`: payment detected, payout not yet attempted. `completed`: the merchant's wallet has the funds. `failed`: the payout attempt failed and retries were exhausted (rare; contact support). `null` on the parent `Charge` means no payout has been attempted yet — nothing has been received, or the charge is still in progress."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"apiKeyId":{"type":["string","null"],"description":"Which of your API keys created this charge. `null` for a charge created before this field existed."},"txHash":{"type":["string","null"],"description":"Transaction hash of the most recent transfer detected for this charge. `null` until a payment is detected."},"externalRef":{"type":["string","null"]},"source":{"type":["string","null"]},"metadata":{"type":["object","null"],"properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":["string","null"],"description":"Echoes the `redirectUrl` set at creation, if any. `null` if none was set."},"checkoutUrl":{"type":["string","null"],"description":"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` instead."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"Address to send a slice of this charge to — an EVM 0x... address or a TRON T... address, matching whichever network family the charge accepts (acceptedPayments can only ever list networks from one split family at a time, so this always matches that same family)."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this address instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay."}},"required":["address","percent"]},"description":"Echoes whatever extra split recipients were set at creation — empty array if none."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"When this charge stops accepting payment, if still `pending`/`partially_paid` by then."},"confirmedAt":{"type":["string","null"],"format":"date-time","description":"When `status` first reached `confirmed`. `null` until then."},"settledAt":{"type":["string","null"],"format":"date-time","description":"When `settlementStatus` first reached `completed` — the merchant's wallet actually has the funds. `null` until then, including while `settlementStatus` is `pending`/`failed`."},"lastActivityAt":{"type":"string","format":"date-time","description":"When a transfer was last credited toward this charge, or `createdAt` if none has arrived yet."},"escrow":{"type":["object","null"],"properties":{"releaserAddress":{"type":"string","description":"The only address that can ever release this escrow — never Klappay."},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the release actually executed on-chain. `null` until then."},"refundedAt":{"type":["string","null"],"format":"date-time","description":"When the refund actually executed on-chain. `null` until then. Mutually exclusive with `releasedAt` — an escrow can only ever be released or refunded once, never both."}},"required":["releaserAddress","releasedAt","refundedAt"],"description":"Present only when this charge was created as an escrow (see `escrow` on the create request) — `null` for a normal charge."}},"required":["id","amount","feePayer","feePercent","feeAmount","merchantAmount","amountReceived","isOverpaid","currency","acceptedPayments","paidWith","swapAlternatives","address","status","settlementStatus","environment","apiKeyId","txHash","externalRef","source","metadata","redirectUrl","checkoutUrl","splitRecipients","createdAt","expiresAt","confirmedAt","settledAt","lastActivityAt","escrow"]}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` to fetch the next page. `null` when there are no more results."},"hasMore":{"type":"boolean"}},"required":["data","nextCursor","hasMore"]}}}},"400":{"description":"Malformed pagination cursor (`invalid_cursor`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The requested `environment` filter doesn't match this API key's own environment (`environment_mismatch`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}":{"get":{"tags":["Charges"],"summary":"Get a charge by id","description":"Returns the current state of a single charge — status, amount received so far, whether it was overpaid, and the on-chain address payers should send funds to.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Charge","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `ch_...`. Use this to look up the charge later."},"amount":{"type":"number","description":"The total the payer must send, in `currency` units (up to 6 decimal places). This legacy JSON number can lose precision; use `amountExact` for calculations and payment preparation. With `feePayer: 'merchant'` (the default) this is what you requested at creation. With `feePayer: 'payer'` this is grossed up to cover `feePercent` — see `merchantAmount` for what you requested/will actually net."},"amountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact total the payer must send, in `currency` units, as a nonnegative decimal string with at most 6 fractional digits and no scientific notation (e.g. `\"100.000001\"`). Use this instead of `amount` for arithmetic. Optional for compatibility with older API responses."},"feePayer":{"type":"string","enum":["merchant","payer"],"description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"feePercent":{"type":"number","description":"Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%) — includes any escrow surcharge if this charge is an escrow. Frozen at creation; see `feeAmount`/`merchantAmount` for the actual amounts this works out to."},"feeAmount":{"type":"number","description":"`amount * feePercent / 100`, in `currency` units — Klappay's cut of this charge."},"merchantAmount":{"type":"number","description":"`amount - feeAmount`, in `currency` units — what you actually net once the payout settles, regardless of `feePayer` (this is always what the split delivers to you; `feePayer` only affects what `amount` was set to at creation)."},"amountReceived":{"type":["number","null"],"description":"Cumulative amount received on-chain so far, in `currency` units. This legacy JSON number can lose precision; use `amountReceivedExact` for arithmetic with up to 18 fractional digits. `null` until the first transfer arrives. Can exceed `amount` — see `isOverpaid`."},"amountReceivedExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact cumulative amount received on-chain, in `currency` units, as a nonnegative decimal string with at most 18 fractional digits and no scientific notation (e.g. `\"0.0000000001\"`). `null` until the first transfer arrives. Can exceed `amountExact`. Optional for compatibility with older API responses."},"paymentUnavailable":{"type":"boolean","description":"When `true`, payment processing for this charge is temporarily unavailable. Do not offer payment instructions, request another transfer, or fulfill from its monetary/status fields until availability is restored. The charge retains its existing business status. Optional for compatibility with older API responses."},"isOverpaid":{"type":"boolean","description":"`true` if `amountReceived` ended up greater than `amount`. Klappay never refunds the difference automatically — see the docs for why."},"currency":{"type":"string","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair this charge was configured to accept, unchanged after creation."},"paidWith":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every distinct `(token, network)` pair that has actually contributed a credited transfer so far — empty until the first one arrives. Can hold more than one entry: a charge accepting several pairs can be paid across a combination of them, and every entry here sums toward `amountReceived`."},"swapAlternatives":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network to send `token` on — pass both as `inputToken`/`inputNetwork` to `POST /v1/charges/{id}/quote`. The same token can appear more than once here, once per network that trusts it and that this charge accepts payment on."}},"required":["token","network"]},"description":"Every `(token, network)` pair the payer can pay with instead, via `POST /v1/charges/{id}/quote` — derived from the networks in `acceptedPayments` (e.g. a charge accepting USDC on both Base and Optimism lists `ETH` on Base and `ETH` on Optimism separately, since they're different networks the payer has to choose between, not one merged option). Pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork`. Recomputed on every read against Klappay's current trusted list, not frozen at creation — empty if this charge's networks have no trusted alt-token, if swap-to-pay isn't configured on this deployment, or if `environment` is `test` (0x, who powers the swap, has no testnet support at all — `POST /v1/charges/{id}/quote` always rejects a test-environment charge with `422 swap_test_environment_unsupported`)."},"address":{"type":"string","description":"The on-chain address the payer must send funds to — identical across every accepted network (0xSplits addresses are chain-agnostic). Unique per charge, predicted at creation time — funds sent here go directly to the merchant, Klappay never custodies them."},"status":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"settlementStatus":{"type":["string","null"],"enum":["pending","completed","failed"],"description":"Progress of the payout to the merchant's wallet, a separate step from `status` — `status: confirmed` only means the payment was detected on-chain, not that the merchant has been paid yet. `pending`: payment detected, payout not yet attempted. `completed`: the merchant's wallet has the funds. `failed`: the payout attempt failed and retries were exhausted (rare; contact support). `null` on the parent `Charge` means no payout has been attempted yet — nothing has been received, or the charge is still in progress."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"apiKeyId":{"type":["string","null"],"description":"Which of your API keys created this charge. `null` for a charge created before this field existed."},"txHash":{"type":["string","null"],"description":"Transaction hash of the most recent transfer detected for this charge. `null` until a payment is detected."},"externalRef":{"type":["string","null"]},"source":{"type":["string","null"]},"metadata":{"type":["object","null"],"properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":["string","null"],"description":"Echoes the `redirectUrl` set at creation, if any. `null` if none was set."},"checkoutUrl":{"type":["string","null"],"description":"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` instead."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"Address to send a slice of this charge to — an EVM 0x... address or a TRON T... address, matching whichever network family the charge accepts (acceptedPayments can only ever list networks from one split family at a time, so this always matches that same family)."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this address instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay."}},"required":["address","percent"]},"description":"Echoes whatever extra split recipients were set at creation — empty array if none."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"When this charge stops accepting payment, if still `pending`/`partially_paid` by then."},"confirmedAt":{"type":["string","null"],"format":"date-time","description":"When `status` first reached `confirmed`. `null` until then."},"settledAt":{"type":["string","null"],"format":"date-time","description":"When `settlementStatus` first reached `completed` — the merchant's wallet actually has the funds. `null` until then, including while `settlementStatus` is `pending`/`failed`."},"lastActivityAt":{"type":"string","format":"date-time","description":"When a transfer was last credited toward this charge, or `createdAt` if none has arrived yet."},"escrow":{"type":["object","null"],"properties":{"releaserAddress":{"type":"string","description":"The only address that can ever release this escrow — never Klappay."},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the release actually executed on-chain. `null` until then."},"refundedAt":{"type":["string","null"],"format":"date-time","description":"When the refund actually executed on-chain. `null` until then. Mutually exclusive with `releasedAt` — an escrow can only ever be released or refunded once, never both."}},"required":["releaserAddress","releasedAt","refundedAt"],"description":"Present only when this charge was created as an escrow (see `escrow` on the create request) — `null` for a normal charge."}},"required":["id","amount","feePayer","feePercent","feeAmount","merchantAmount","amountReceived","isOverpaid","currency","acceptedPayments","paidWith","swapAlternatives","address","status","settlementStatus","environment","apiKeyId","txHash","externalRef","source","metadata","redirectUrl","checkoutUrl","splitRecipients","createdAt","expiresAt","confirmedAt","settledAt","lastActivityAt","escrow"]}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/timeline":{"get":{"tags":["Charges"],"summary":"Get the full event timeline for a charge","description":"Returns every event recorded against a charge, in chronological order: creation, each on-chain transfer detected, the payout distribution, and every webhook delivery attempt with its outcome. Useful for debugging a specific payment without needing separate audit tooling.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Charge timeline","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["charge.created","charge.expired","transaction.detected","split.distributed","webhook.dispatched","webhook.delivered","webhook.failed","transfer.reclaimed"],"description":"`charge.created`: the charge was created. `charge.expired`: `expiresAt` passed with no full payment. `transaction.detected`: a raw on-chain transfer was seen (see the `event`-shaped fields below for details — a charge can have more than one, e.g. a partial payment followed by the rest). `split.distributed`: a payout to the merchant completed on-chain, for one contributing `(token, network)` pair — a charge settled across more than one pair emits one of these per pair (see the `token`/`network` fields below). `webhook.dispatched`/`webhook.delivered`/`webhook.failed`: one specific delivery *attempt* for one webhook subscription — `failed` here means this single attempt failed, not that all retries were exhausted (see `WebhookDeliveryStatusSchema` for the exhausted-all-retries state). `transfer.reclaimed`: an on-chain transfer was detected but never reached its network's required confirmation depth before vanishing (reverted, or dropped from the canonical chain) — see `txHash` below for which transfer."},"at":{"type":"string","format":"date-time"},"txHash":{"type":"string","description":"Present for `transaction.detected`, `split.distributed`, and `transfer.reclaimed` events only."},"amount":{"type":"number","description":"Present for `transaction.detected` events only — the amount that specific transfer carried."},"source":{"type":"string","enum":["contract_watcher","reconciliation_job","sandbox","tron_watcher"],"description":"Present for `transaction.detected` events only."},"token":{"type":"string","enum":["USDC","USDT"],"description":"Present for `transaction.detected` and `split.distributed` events — which token this specific transfer, or settlement, used. A charge can accept (and be settled across) more than one `(token, network)` pair (see `Charge.acceptedPayments`); this is how to tell which one a given event actually used."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Present for `transaction.detected` and `split.distributed` events — which network this specific transfer, or settlement, used."},"causedTransition":{"type":"boolean","description":"Present for `transaction.detected` events only. `true` if this specific transfer changed the charge's status (e.g. PENDING→CONFIRMED) — a charge paid in installments can have more than one such event."},"event":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}],"description":"Present for `webhook.*` events only — which event type this delivery was for."},"responseCode":{"type":["number","null"],"description":"Present for `webhook.*` events only — HTTP status your endpoint returned, or `null` if the request never connected."},"attempts":{"type":"number","description":"Present for `webhook.*` events only — how many delivery attempts have been made so far."}},"required":["type","at"]}}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/events":{"get":{"tags":["Charges"],"summary":"Stream live status updates for a charge (Server-Sent Events)","description":"Opens a Server-Sent Events stream that pushes the full `Charge` object every time its `status` or `settlementStatus` changes, starting with the current state immediately on connect. Also pushes an `event: confirmation_progress` (`data: <ConfirmationProgress JSON>`) whenever a real on-chain transfer has been detected but hasn't yet reached its network's required confirmation depth — use it to render a progress bar while a payment finalizes; the next `event: charge` is what signals it's done (see `docs/payments.md`'s \"Confirmation depth\" section). Closes automatically once the charge reaches a terminal state (`expired`, or `confirmed`/`underpaid` with a resolved `settlementStatus`), when the charge itself expires, or if the client disconnects. This is what an official SDK's `waitForConfirmation()`/`waitForSettlement()` use instead of polling when available — see [github.com/klappay](https://github.com/klappay) for available SDKs; prefer one over consuming this directly unless you have a reason not to.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"SSE stream. `event: charge` carries `data: <Charge JSON>`; `event: confirmation_progress` carries `data: <ConfirmationProgress JSON>` and is only ever sent while a detected transfer is still short of its required confirmation depth. A comment-only heartbeat (`: ping`) is sent periodically to keep the connection alive through proxies.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Too many concurrent event streams — try again shortly. (`sse_capacity`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/qrcode":{"get":{"tags":["Charges"],"summary":"Get a scannable payment QR code for a charge","description":"Returns an SVG QR code encoding an EIP-681 payment URI (`ethereum:<contract>@<chainId>/transfer?address=...&uint256=...`) for one of this charge's accepted `(token, network)` pairs — scanning it with a wallet that supports EIP-681 prefill (e.g. Trust Wallet, imToken; MetaMask's support has historically been partial) prefills the recipient, token, and remaining amount. The remaining balance is calculated exactly from stored decimal values; when it cannot be represented by the selected token, the request rounds up to the smallest atomic unit that covers it. This may create a small overpayment. Temporarily disabled payment methods or charges under an integrity review return `503 payment_temporarily_unavailable`. `token`/`network` query params select which accepted pair to encode — required when `acceptedPayments` has more than one pair; with a single pair they may be omitted, and any explicit pair is still validated. Only available while the charge can still be paid (`pending`/`partially_paid`) — a QR for an already-`confirmed`/`expired`/`underpaid` charge would direct a payer's funds nowhere useful.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","enum":["USDC","USDT"],"description":"Which accepted `(token, network)` pair to encode in the QR — required if `acceptedPayments` has more than one pair, since there is no single unambiguous default to fall back to. Ignored (and unnecessary) when the charge accepts exactly one pair."},"required":false,"description":"Which accepted `(token, network)` pair to encode in the QR — required if `acceptedPayments` has more than one pair, since there is no single unambiguous default to fall back to. Ignored (and unnecessary) when the charge accepts exactly one pair.","name":"token","in":"query"},{"schema":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."},"required":false,"description":"The blockchain a charge/payment is on.","name":"network","in":"query"}],"responses":{"200":{"description":"QR code as an SVG image","content":{"image/svg+xml":{"schema":{"type":"string"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Charge is already confirmed, expired, or underpaid, or has no remaining balance (`charge_not_payable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"acceptedPayments has more than one pair and none was specified via token/network, the specified pair isn't one of this charge's accepted pairs, or this token/network/environment combination has no deployed contract yet (`payment_pair_required` / `payment_pair_not_accepted` / `token_not_supported`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The charge or selected payment method is temporarily unavailable, or exact payment values cannot be verified (`payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/quote":{"post":{"tags":["Charges"],"summary":"Get a locked quote to pay this charge with a different cryptocurrency","description":"Quotes a swap (via the 0x Swap API) from a non-stablecoin cryptocurrency the payer holds into one of this charge's `acceptedPayments` tokens — see `swapAlternatives` on the charge for which inputs are trusted. The swap's output is delivered directly to this charge's own `address`, so once the payer signs and submits `transaction`, the resulting USDC/USDT is detected and credited exactly like any other transfer — Klappay never sees or custodies the input cryptocurrency. Klappay charges the payer a separate 1% fee on top of the merchant's own `feePercent`, collected as part of the swap itself; the charge address receives the remaining balance, rounded up only when needed to the selected output token's smallest atomic unit. This may create a small overpayment. `outputAmountExact` is the actual requested output in token units; use the exact string fields for calculations, since the numeric amount fields are approximate. Temporarily disabled payment methods and charges under an integrity review return `503 payment_temporarily_unavailable`. Requires `charges:write` (not just `charges:read`) since, unlike every other read on this resource, each call costs a real, billable request against Klappay's own 0x account — rate-limited per charge on top of that, independent of the general per-key rate limit. **Two different client-side flows depending on `inputToken`**: a network's own native currency (ETH/BNB/MATIC/AVAX) needs no extra step — sign and send `transaction` directly; an ERC-20 input (today, only `BTC`) needs `permit2`'s EIP-712 message signed first, with the signature appended to `transaction.data` before sending. `permit2` is always present on the response — `null` for a native-currency input, an object for an ERC-20 one — check whether it's non-null rather than whether the key exists. See each field's own description for the exact mechanics. **When swap-to-pay is configured and the charge is available for payment, `test`-environment charges reject with `422 swap_test_environment_unsupported`** — 0x, who powers this endpoint, has no testnet support at all today, so no `inputToken`/`inputNetwork` combination could ever succeed there; `swapAlternatives` on a test charge is always empty for the same reason.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"inputToken":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"Which alt-cryptocurrency the payer wants to send — must be one of this charge's `swapAlternatives`, or `422 token_not_supported`."},"inputNetwork":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network the payer will send `inputToken` on. Also picks which of this charge's `acceptedPayments` pairs the swap resolves to — a charge accepting USDC on both Base and Optimism resolves to whichever `inputNetwork` you pass. If the charge accepts more than one token on that same network, Klappay applies its token preference among enabled pairs the charge accepts."},"takerAddress":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$","description":"The payer's own wallet address — the account that will sign and submit the swap transaction. Not validated against anything else; any well-formed address is accepted, since Klappay never custodies these funds."}},"required":["inputToken","inputNetwork","takerAddress"]},"example":{"inputToken":"ETH","inputNetwork":"base","takerAddress":"0x1111111111111111111111111111111111111111"}}}},"responses":{"200":{"description":"Locked swap quote","content":{"application/json":{"schema":{"type":"object","properties":{"inputToken":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"inputNetwork":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."},"inputAmount":{"type":"number","description":"The ceiling of `inputToken` the payer needs available to sign for, in whole units (not wei/base units) — not necessarily the exact final cost. Any `inputToken` beyond what the swap actually needs (price moved favorably, less slippage than budgeted) is swapped back and refunded to the payer automatically, in the same transaction — never a separate step or a Klappay-side refund."},"inputAmountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact ceiling of `inputToken` the payer needs available, in whole token units as a nonnegative decimal string with at most 18 fractional digits and no scientific notation. Prefer this over the legacy numeric `inputAmount`. Optional for compatibility with older API responses."},"outputToken":{"type":"string","enum":["USDC","USDT"],"description":"Which of this charge's `acceptedPayments` tokens the swap resolves to."},"outputNetwork":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."},"outputAmount":{"type":"number","description":"The stablecoin amount requested by this quote, in whole `outputToken` units. Covers the remaining balance, rounded up only if the selected token cannot represent it exactly. This legacy JSON number can lose precision; use `outputAmountExact` for arithmetic. The actual transfer is verified on-chain before crediting the charge."},"outputAmountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact stablecoin amount requested by this quote, in whole `outputToken` units as a nonnegative decimal string with no scientific notation. Covers the remaining charge balance and rounds upward only to the smallest representable token unit when needed. Optional for compatibility with older API responses."},"fees":{"type":"object","properties":{"klappayFee":{"type":"number","description":"Klappay's own swap fee (1% today), in `outputToken` units — paid by the payer and already reflected in `inputAmount`, separate from the merchant's own `feePercent`. Never subtracted from `outputAmount`."},"klappayFeeExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact Klappay swap fee in whole `outputToken` units as a nonnegative decimal string with at most 18 fractional digits and no scientific notation. Already reflected in the input ceiling. Optional for compatibility with older API responses."},"zeroExFee":{"type":["number","null"],"description":"0x's own protocol fee for this specific token pair, in `outputToken` units, or `null` when this pair isn't currently one 0x charges on. Paid by the payer and already reflected in `inputAmount`, never subtracted from `outputAmount` — Klappay never sees this fee, it goes straight to 0x."},"zeroExFeeExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact 0x fee in whole `outputToken` units as a nonnegative decimal string with at most 18 fractional digits and no scientific notation, or `null` when no fee applies. Already reflected in the input ceiling. Optional for compatibility with older API responses."}},"required":["klappayFee","zeroExFee"],"description":"Every fee the payer is charged for using swap-to-pay, broken out by who collects it — both already reflected in `inputAmount`, shown here separately for transparency. Neither ever reduces `outputAmount`."},"expiresAt":{"type":"string","format":"date-time","description":"When this quote's price is no longer safely valid — a rough guide for the payer's UI countdown only. The actual price guarantee is enforced on-chain by the swap transaction itself (a signed Permit2 deadline, or a minimum-output check for a native-currency sell), not by this timestamp — submitting after it expires either reverts on-chain or simply gets re-quoted at the current price, never silently executes at a stale rate."},"transaction":{"type":"object","properties":{"to":{"type":"string","description":"Contract address the payer's wallet must send this transaction to."},"data":{"type":"string","description":"Calldata — opaque, must be sent unmodified."},"value":{"type":"string","description":"Native currency (ETH/BNB/POL/AVAX) to attach, in wei — `\"0\"` when `inputToken` isn't this network's native currency."}},"required":["to","data","value"],"description":"Pass this directly to the payer's wallet (e.g. viem/ethers `sendTransaction`) — Klappay never touches the payer's private key or submits anything on their behalf. If `permit2` is present on this response, sign that first and append the signature to this `data` before sending; if `permit2` is absent, send `transaction` as-is with no extra step."},"permit2":{"type":["object","null"],"properties":{"eip712":{"type":"object","additionalProperties":{}}},"required":["eip712"],"description":"Present only when `inputToken` is an ERC-20 (BTC, LINK, or a chain-specific blue chip like ARB/OP/CBETH) — the payer's wallet must sign this EIP-712 message and append the signature to `transaction.data` before sending, since an ERC-20 sell needs a Permit2 allowance signature that a native-currency sell doesn't. `null` (never omitted, in a genuine 0x-backed quote) when `inputToken` is a network's own native currency (ETH/BNB/POL/AVAX) — `transaction` is then ready to sign and send directly, no extra step."}},"required":["inputToken","inputNetwork","inputAmount","outputToken","outputNetwork","outputAmount","fees","expiresAt","transaction"]}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Charge is already confirmed, expired, or underpaid, or has no remaining balance (`charge_not_payable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"inputToken/inputNetwork isn't a trusted swap input for this charge (see its swapAlternatives), or the charge is in the test environment, which 0x doesn't support at all (`token_not_supported` / `swap_test_environment_unsupported`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many swap quote requests for this charge (`rate_limited`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Swap credentials are unavailable, the charge or payment method is temporarily unavailable, or the upstream quote failed validation or retrieval. A mismatched output amount or fee denomination is rejected as swap_quote_failed. (`swap_unavailable` / `swap_quote_failed` / `payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/check":{"post":{"tags":["Charges"],"summary":"Trigger an on-demand payment check for a charge","description":"Re-verifies this charge's on-chain payment state immediately, instead of waiting for the background reconciliation pass that otherwise catches it within roughly a minute as a backstop. This never trusts the caller's claim that a payment happened — by default it re-runs the same independent on-chain lookup the reconciliation job and webhook ingestion already use, and only changes the charge's state if a real matching transfer is actually found on-chain and has reached that network's required confirmation depth (a transfer seen but not yet deep enough is left for the next reconciliation pass, not credited early). A charge under an integrity review returns `503 payment_temporarily_unavailable` without crediting a transfer. A charge already in a terminal state (`confirmed`/`underpaid`) is never re-scanned or credited again, since neither can transition — the only RPC call it can still make is one receipt read when `txHash`/`network` names a transfer already credited to this charge, to fill `transactionSender`/`tokenSenders`/`userOperationSenders`; any other hint (or none) returns without touching the RPC. Optionally pass `txHash`/`network` (e.g. right after a swap-to-pay or wallet-connect transaction is sent) to verify that specific transaction directly instead — one RPC call instead of scanning a block range, so it resolves faster and cheaper; the amount credited still always comes from what that transaction actually contains on-chain, never from anything implied by passing it. Rate-limited to once every 10 seconds per charge — shared across every caller of that charge, not per API key — since each call costs a real RPC round trip; prefer `GET /v1/charges/{id}/events` (SSE) to observe the result instead of polling this endpoint repeatedly.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"txHash":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$","description":"The on-chain transaction hash to verify directly, if you already have it — e.g. right after a swap-to-pay or wallet-connect transaction is sent. Costs a single RPC call instead of scanning a block range, so the check resolves faster and cheaper. Omit to fall back to scanning recent transfers to this charge's address, the same lookup the background reconciliation pass runs. Never trusted at face value — whatever this transaction actually contains on-chain is what gets credited, regardless of any amount/token implied elsewhere."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network `txHash` is on — required together with `txHash`, since a transaction hash alone doesn't identify a chain. Must be one of the networks this charge actually accepts payment on, or `422 payment_pair_not_accepted`."}}},"example":{"txHash":"0x1234567890123456789012345678901234567890123456789012345678901234","network":"base"}}}},"responses":{"200":{"description":"Current charge state after the check","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `ch_...`. Use this to look up the charge later."},"amount":{"type":"number","description":"The total the payer must send, in `currency` units (up to 6 decimal places). This legacy JSON number can lose precision; use `amountExact` for calculations and payment preparation. With `feePayer: 'merchant'` (the default) this is what you requested at creation. With `feePayer: 'payer'` this is grossed up to cover `feePercent` — see `merchantAmount` for what you requested/will actually net."},"amountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact total the payer must send, in `currency` units, as a nonnegative decimal string with at most 6 fractional digits and no scientific notation (e.g. `\"100.000001\"`). Use this instead of `amount` for arithmetic. Optional for compatibility with older API responses."},"feePayer":{"type":"string","enum":["merchant","payer"],"description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"feePercent":{"type":"number","description":"Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%) — includes any escrow surcharge if this charge is an escrow. Frozen at creation; see `feeAmount`/`merchantAmount` for the actual amounts this works out to."},"feeAmount":{"type":"number","description":"`amount * feePercent / 100`, in `currency` units — Klappay's cut of this charge."},"merchantAmount":{"type":"number","description":"`amount - feeAmount`, in `currency` units — what you actually net once the payout settles, regardless of `feePayer` (this is always what the split delivers to you; `feePayer` only affects what `amount` was set to at creation)."},"amountReceived":{"type":["number","null"],"description":"Cumulative amount received on-chain so far, in `currency` units. This legacy JSON number can lose precision; use `amountReceivedExact` for arithmetic with up to 18 fractional digits. `null` until the first transfer arrives. Can exceed `amount` — see `isOverpaid`."},"amountReceivedExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact cumulative amount received on-chain, in `currency` units, as a nonnegative decimal string with at most 18 fractional digits and no scientific notation (e.g. `\"0.0000000001\"`). `null` until the first transfer arrives. Can exceed `amountExact`. Optional for compatibility with older API responses."},"paymentUnavailable":{"type":"boolean","description":"When `true`, payment processing for this charge is temporarily unavailable. Do not offer payment instructions, request another transfer, or fulfill from its monetary/status fields until availability is restored. The charge retains its existing business status. Optional for compatibility with older API responses."},"isOverpaid":{"type":"boolean","description":"`true` if `amountReceived` ended up greater than `amount`. Klappay never refunds the difference automatically — see the docs for why."},"currency":{"type":"string","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair this charge was configured to accept, unchanged after creation."},"paidWith":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every distinct `(token, network)` pair that has actually contributed a credited transfer so far — empty until the first one arrives. Can hold more than one entry: a charge accepting several pairs can be paid across a combination of them, and every entry here sums toward `amountReceived`."},"swapAlternatives":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network to send `token` on — pass both as `inputToken`/`inputNetwork` to `POST /v1/charges/{id}/quote`. The same token can appear more than once here, once per network that trusts it and that this charge accepts payment on."}},"required":["token","network"]},"description":"Every `(token, network)` pair the payer can pay with instead, via `POST /v1/charges/{id}/quote` — derived from the networks in `acceptedPayments` (e.g. a charge accepting USDC on both Base and Optimism lists `ETH` on Base and `ETH` on Optimism separately, since they're different networks the payer has to choose between, not one merged option). Pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork`. Recomputed on every read against Klappay's current trusted list, not frozen at creation — empty if this charge's networks have no trusted alt-token, if swap-to-pay isn't configured on this deployment, or if `environment` is `test` (0x, who powers the swap, has no testnet support at all — `POST /v1/charges/{id}/quote` always rejects a test-environment charge with `422 swap_test_environment_unsupported`)."},"address":{"type":"string","description":"The on-chain address the payer must send funds to — identical across every accepted network (0xSplits addresses are chain-agnostic). Unique per charge, predicted at creation time — funds sent here go directly to the merchant, Klappay never custodies them."},"status":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"settlementStatus":{"type":["string","null"],"enum":["pending","completed","failed"],"description":"Progress of the payout to the merchant's wallet, a separate step from `status` — `status: confirmed` only means the payment was detected on-chain, not that the merchant has been paid yet. `pending`: payment detected, payout not yet attempted. `completed`: the merchant's wallet has the funds. `failed`: the payout attempt failed and retries were exhausted (rare; contact support). `null` on the parent `Charge` means no payout has been attempted yet — nothing has been received, or the charge is still in progress."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"apiKeyId":{"type":["string","null"],"description":"Which of your API keys created this charge. `null` for a charge created before this field existed."},"txHash":{"type":["string","null"],"description":"Transaction hash of the most recent transfer detected for this charge. `null` until a payment is detected."},"externalRef":{"type":["string","null"]},"source":{"type":["string","null"]},"metadata":{"type":["object","null"],"properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":["string","null"],"description":"Echoes the `redirectUrl` set at creation, if any. `null` if none was set."},"checkoutUrl":{"type":["string","null"],"description":"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` instead."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"Address to send a slice of this charge to — an EVM 0x... address or a TRON T... address, matching whichever network family the charge accepts (acceptedPayments can only ever list networks from one split family at a time, so this always matches that same family)."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this address instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay."}},"required":["address","percent"]},"description":"Echoes whatever extra split recipients were set at creation — empty array if none."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"When this charge stops accepting payment, if still `pending`/`partially_paid` by then."},"confirmedAt":{"type":["string","null"],"format":"date-time","description":"When `status` first reached `confirmed`. `null` until then."},"settledAt":{"type":["string","null"],"format":"date-time","description":"When `settlementStatus` first reached `completed` — the merchant's wallet actually has the funds. `null` until then, including while `settlementStatus` is `pending`/`failed`."},"lastActivityAt":{"type":"string","format":"date-time","description":"When a transfer was last credited toward this charge, or `createdAt` if none has arrived yet."},"escrow":{"type":["object","null"],"properties":{"releaserAddress":{"type":"string","description":"The only address that can ever release this escrow — never Klappay."},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the release actually executed on-chain. `null` until then."},"refundedAt":{"type":["string","null"],"format":"date-time","description":"When the refund actually executed on-chain. `null` until then. Mutually exclusive with `releasedAt` — an escrow can only ever be released or refunded once, never both."}},"required":["releaserAddress","releasedAt","refundedAt"],"description":"Present only when this charge was created as an escrow (see `escrow` on the create request) — `null` for a normal charge."},"transactionSender":{"type":["string","null"],"description":"The `txHash` transaction's own sender (`from`) — whoever signed and submitted it on-chain. For a wallet that signs its own transactions this is the payer, including when the payment swaps through a router/aggregator on the way (where the credited transfer's own `from` is the router, not the payer). It is **not** the payer when someone else submits the transaction on the payer's behalf — a gas-sponsoring relayer (e.g. an EIP-7702 smart account) or an ERC-4337 bundler; see `tokenSenders`/`userOperationSenders` for those. Only populated when `txHash`/`network` was passed in the request and that transaction actually paid this charge: on a `pending`/`partially_paid`/`expired` charge, a successful receipt containing at least one Transfer of an accepted token (on that network) to this charge's address — even one not yet deep enough to be credited; on an already-`confirmed`/`underpaid` charge, a transaction already credited to this charge on that network. Anything else — a hint-less background scan, an unaccepted network, a not-found/reverted transaction, or a successful transaction that never paid this charge — leaves this `null`, so it never vouches for a hash that didn't pay this charge."},"tokenSenders":{"type":"array","items":{"type":"string"},"description":"Checksummed addresses the paying token transfers came from — the `from` of every Transfer in the `txHash` transaction that was emitted by an accepted token's own contract and sent to this charge's address (mints from the zero address excluded). Covers a wallet whose transaction was submitted by someone else, e.g. an EIP-7702 smart account paying through a gas-sponsoring relayer, where `transactionSender` is the relayer but the tokens left the wallet itself. Transfer events emitted by any other contract are ignored, since any contract can emit a Transfer-shaped event. For a swap-to-pay this is the router/pool, not the payer. Evidence, not identity — Klappay doesn't decide who the payer is. A transaction that settles several parties at once can list more than one address here if more than one of them paid this charge in that transaction, so combine it with your own per-`txHash` uniqueness. Same population rule as `transactionSender`; `[]` whenever that field is `null`."},"userOperationSenders":{"type":"array","items":{"type":"string"},"description":"Checksummed ERC-4337 smart-account addresses (`UserOperationEvent.sender`) whose own successful user operation produced a paying transfer to this charge in the `txHash` transaction — the case where a bundler, not the account, is `transactionSender`, including an account that swapped through a router on the way to paying. Only events from the official EntryPoint contracts (v0.6, v0.7, v0.8, v0.9) count. A transfer is attributed to a user operation only when it happened inside that operation's own execution, so other, unrelated accounts bundled into the same transaction are never listed, and a transfer that can't be attributed to exactly one operation isn't listed at all. Evidence, not identity. Same population rule as `transactionSender`; `[]` whenever that field is `null`, and also `[]` for any transaction that isn't an ERC-4337 bundle."},"confirmationProgress":{"type":["object","null"],"properties":{"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network the transfer was seen on."},"blocksSeen":{"type":"integer","minimum":0,"description":"How many blocks have passed since the transfer's own block, as of this update — a raw block count, not seconds. Grows toward `blocksRequired` as the network's blocks keep arriving."},"blocksRequired":{"type":"integer","minimum":1,"description":"This network's minimum confirmation depth (a fixed, per-network constant) — the transfer is only credited once `blocksSeen` reaches this value."},"percent":{"type":"integer","minimum":0,"maximum":99,"description":"`blocksSeen`/`blocksRequired` as a rounded-down 0-99 percentage, for a progress bar. Never reaches 100 by construction — once a transfer is deep enough it is credited immediately and this stops being reported at all (the charge event itself is the \"done\" signal)."}},"required":["network","blocksSeen","blocksRequired","percent"],"description":"Present when this check found a real matching transfer on-chain that has not yet reached its network's required confirmation depth — use it to render a progress bar while waiting. `null` when nothing new was found, when the charge is already terminal (no re-scan and nothing credited — at most one receipt read, only when `txHash` is a transfer already credited to it, to fill `transactionSender`), or once the transfer is deep enough to have already been credited (the charge's own `status` field is the signal for that, not this one)."}},"required":["id","amount","feePayer","feePercent","feeAmount","merchantAmount","amountReceived","isOverpaid","currency","acceptedPayments","paidWith","swapAlternatives","address","status","settlementStatus","environment","apiKeyId","txHash","externalRef","source","metadata","redirectUrl","checkoutUrl","splitRecipients","createdAt","expiresAt","confirmedAt","settledAt","lastActivityAt","escrow","transactionSender","tokenSenders","userOperationSenders","confirmationProgress"]}}}},"400":{"description":"`txHash`/`network` malformed, or only one of the two was provided (`validation_error`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`network` was provided but this charge doesn't accept payment on it (`payment_pair_not_accepted`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"This charge was already checked in the last 10 seconds — wait and retry, or watch `GET /v1/charges/{id}/events` instead (`rate_limited`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Could not reach the blockchain RPC, or this charge is temporarily unavailable pending an integrity review. Retry RPC failures; held charges remain unavailable until reviewed. (`rpc_unavailable` / `payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/release":{"post":{"tags":["Charges"],"summary":"Release an escrow charge's funds","description":"Moves an escrow-configured charge's entire current token balance out of its Safe to the charge's split address, where the normal distribution mechanism then pays out the merchant/platform shares — see `docs/payments.md`. Get the exact transaction to sign from `GET /v1/charges/{id}/release/prepare`, sign its `typedData` (EIP-712) with the private key behind this charge's `escrowReleaserAddress`, and send that signature here. It is verified on-chain by the Safe contract itself before anything moves, never taken on faith by Klappay. If the escrow's balance changed since you prepared, the signature no longer matches and this fails with `400 escrow_invalid_signature` — prepare again and re-sign. Can only be called once per charge — a second call fails with `409 escrow_already_released`.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"signature":{"type":"string","pattern":"^0x[0-9a-fA-F]+$","description":"The Safe transaction signature authorizing the release: a transfer of the escrow's entire current token balance to this charge's already-frozen split address (the same split that receives payment on a normal, non-escrow charge). Get the exact transaction to sign from `GET /v1/charges/{id}/release/prepare`. Sign the `typedData` returned by the matching prepare endpoint with the private key behind this charge's `escrowReleaserAddress` (EIP-712 `signTypedData` — the resulting 65-byte signature is what goes here). The amount is the escrow's live token balance, so a prepare result is only valid while that balance is unchanged; if it changes before you submit, the signature no longer authorizes the transfer and this call fails with `400 escrow_invalid_signature` — prepare again and re-sign. Independently verified on-chain before anything moves: the Safe contract itself rejects any signature that isn't from `escrowReleaserAddress`, never trusted at face value by Klappay."}},"required":["signature"]},"example":{"signature":"0x2d0fbf1dba287883a4b6c5aeef9da7653dc68b3e20417d42e87db700ad9e878662edc0c052143c22892355d5c87e74e66be49399c7c7a4db98108ea51643d4651c"}}}},"responses":{"200":{"description":"Released charge","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `ch_...`. Use this to look up the charge later."},"amount":{"type":"number","description":"The total the payer must send, in `currency` units (up to 6 decimal places). This legacy JSON number can lose precision; use `amountExact` for calculations and payment preparation. With `feePayer: 'merchant'` (the default) this is what you requested at creation. With `feePayer: 'payer'` this is grossed up to cover `feePercent` — see `merchantAmount` for what you requested/will actually net."},"amountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact total the payer must send, in `currency` units, as a nonnegative decimal string with at most 6 fractional digits and no scientific notation (e.g. `\"100.000001\"`). Use this instead of `amount` for arithmetic. Optional for compatibility with older API responses."},"feePayer":{"type":"string","enum":["merchant","payer"],"description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"feePercent":{"type":"number","description":"Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%) — includes any escrow surcharge if this charge is an escrow. Frozen at creation; see `feeAmount`/`merchantAmount` for the actual amounts this works out to."},"feeAmount":{"type":"number","description":"`amount * feePercent / 100`, in `currency` units — Klappay's cut of this charge."},"merchantAmount":{"type":"number","description":"`amount - feeAmount`, in `currency` units — what you actually net once the payout settles, regardless of `feePayer` (this is always what the split delivers to you; `feePayer` only affects what `amount` was set to at creation)."},"amountReceived":{"type":["number","null"],"description":"Cumulative amount received on-chain so far, in `currency` units. This legacy JSON number can lose precision; use `amountReceivedExact` for arithmetic with up to 18 fractional digits. `null` until the first transfer arrives. Can exceed `amount` — see `isOverpaid`."},"amountReceivedExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact cumulative amount received on-chain, in `currency` units, as a nonnegative decimal string with at most 18 fractional digits and no scientific notation (e.g. `\"0.0000000001\"`). `null` until the first transfer arrives. Can exceed `amountExact`. Optional for compatibility with older API responses."},"paymentUnavailable":{"type":"boolean","description":"When `true`, payment processing for this charge is temporarily unavailable. Do not offer payment instructions, request another transfer, or fulfill from its monetary/status fields until availability is restored. The charge retains its existing business status. Optional for compatibility with older API responses."},"isOverpaid":{"type":"boolean","description":"`true` if `amountReceived` ended up greater than `amount`. Klappay never refunds the difference automatically — see the docs for why."},"currency":{"type":"string","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair this charge was configured to accept, unchanged after creation."},"paidWith":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every distinct `(token, network)` pair that has actually contributed a credited transfer so far — empty until the first one arrives. Can hold more than one entry: a charge accepting several pairs can be paid across a combination of them, and every entry here sums toward `amountReceived`."},"swapAlternatives":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network to send `token` on — pass both as `inputToken`/`inputNetwork` to `POST /v1/charges/{id}/quote`. The same token can appear more than once here, once per network that trusts it and that this charge accepts payment on."}},"required":["token","network"]},"description":"Every `(token, network)` pair the payer can pay with instead, via `POST /v1/charges/{id}/quote` — derived from the networks in `acceptedPayments` (e.g. a charge accepting USDC on both Base and Optimism lists `ETH` on Base and `ETH` on Optimism separately, since they're different networks the payer has to choose between, not one merged option). Pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork`. Recomputed on every read against Klappay's current trusted list, not frozen at creation — empty if this charge's networks have no trusted alt-token, if swap-to-pay isn't configured on this deployment, or if `environment` is `test` (0x, who powers the swap, has no testnet support at all — `POST /v1/charges/{id}/quote` always rejects a test-environment charge with `422 swap_test_environment_unsupported`)."},"address":{"type":"string","description":"The on-chain address the payer must send funds to — identical across every accepted network (0xSplits addresses are chain-agnostic). Unique per charge, predicted at creation time — funds sent here go directly to the merchant, Klappay never custodies them."},"status":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"settlementStatus":{"type":["string","null"],"enum":["pending","completed","failed"],"description":"Progress of the payout to the merchant's wallet, a separate step from `status` — `status: confirmed` only means the payment was detected on-chain, not that the merchant has been paid yet. `pending`: payment detected, payout not yet attempted. `completed`: the merchant's wallet has the funds. `failed`: the payout attempt failed and retries were exhausted (rare; contact support). `null` on the parent `Charge` means no payout has been attempted yet — nothing has been received, or the charge is still in progress."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"apiKeyId":{"type":["string","null"],"description":"Which of your API keys created this charge. `null` for a charge created before this field existed."},"txHash":{"type":["string","null"],"description":"Transaction hash of the most recent transfer detected for this charge. `null` until a payment is detected."},"externalRef":{"type":["string","null"]},"source":{"type":["string","null"]},"metadata":{"type":["object","null"],"properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":["string","null"],"description":"Echoes the `redirectUrl` set at creation, if any. `null` if none was set."},"checkoutUrl":{"type":["string","null"],"description":"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` instead."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"Address to send a slice of this charge to — an EVM 0x... address or a TRON T... address, matching whichever network family the charge accepts (acceptedPayments can only ever list networks from one split family at a time, so this always matches that same family)."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this address instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay."}},"required":["address","percent"]},"description":"Echoes whatever extra split recipients were set at creation — empty array if none."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"When this charge stops accepting payment, if still `pending`/`partially_paid` by then."},"confirmedAt":{"type":["string","null"],"format":"date-time","description":"When `status` first reached `confirmed`. `null` until then."},"settledAt":{"type":["string","null"],"format":"date-time","description":"When `settlementStatus` first reached `completed` — the merchant's wallet actually has the funds. `null` until then, including while `settlementStatus` is `pending`/`failed`."},"lastActivityAt":{"type":"string","format":"date-time","description":"When a transfer was last credited toward this charge, or `createdAt` if none has arrived yet."},"escrow":{"type":["object","null"],"properties":{"releaserAddress":{"type":"string","description":"The only address that can ever release this escrow — never Klappay."},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the release actually executed on-chain. `null` until then."},"refundedAt":{"type":["string","null"],"format":"date-time","description":"When the refund actually executed on-chain. `null` until then. Mutually exclusive with `releasedAt` — an escrow can only ever be released or refunded once, never both."}},"required":["releaserAddress","releasedAt","refundedAt"],"description":"Present only when this charge was created as an escrow (see `escrow` on the create request) — `null` for a normal charge."}},"required":["id","amount","feePayer","feePercent","feeAmount","merchantAmount","amountReceived","isOverpaid","currency","acceptedPayments","paidWith","swapAlternatives","address","status","settlementStatus","environment","apiKeyId","txHash","externalRef","source","metadata","redirectUrl","checkoutUrl","splitRecipients","createdAt","expiresAt","confirmedAt","settledAt","lastActivityAt","escrow"]}}}},"400":{"description":"`signature` did not authorize this release — it isn't from this charge's `escrowReleaserAddress`, or it was made for a prepared transaction that no longer matches the escrow's current balance (prepare again and re-sign) (`escrow_invalid_signature`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"This escrow was already released, or another release request for it is currently being processed — safe to retry shortly in the latter case (`escrow_already_released` / `escrow_release_in_progress`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This charge wasn't created as an escrow, currently holds a zero balance, received payment across more than one token/network pair (not yet supported), or its Safe can't be reconstructed at the charge's address (`escrow_safe_mismatch` — not transient, contact support) (`escrow_not_configured` / `escrow_safe_mismatch` / `escrow_nothing_to_release` / `escrow_multiple_payment_pairs`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The blockchain RPC could not be reached, or payment processing is temporarily unavailable while this charge or payment method is under review. An integrity hold requires review before release can resume. (`rpc_unavailable` / `payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/refund":{"post":{"tags":["Charges"],"summary":"Refund an escrow charge's funds back to the payer","description":"Moves an escrow-configured charge's entire current token balance out of its Safe back to the address that funded it (the sender of the most recently credited transfer) — no distribution follows, the full balance goes directly to the payer. Get the exact transaction to sign, including that payer address, from `GET /v1/charges/{id}/refund/prepare`, sign its `typedData` (EIP-712) with the private key behind this charge's `escrowReleaserAddress`, and send that signature here. It is verified on-chain by the Safe contract itself before anything moves, never taken on faith by Klappay. If the escrow's balance changed since you prepared, the signature no longer matches and this fails with `400 escrow_invalid_signature` — prepare again and re-sign. Mutually exclusive with `POST /v1/charges/{id}/release` — an escrow can only ever be released or refunded once, never both; whichever happens first empties the Safe and the other call then fails.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"signature":{"type":"string","pattern":"^0x[0-9a-fA-F]+$","description":"The Safe transaction signature authorizing the refund: a transfer of the escrow's entire current token balance back to the address that funded the charge (the sender of the most recently credited transfer). Get the exact transaction to sign, including that destination, from `GET /v1/charges/{id}/refund/prepare`. Sign the `typedData` returned by the matching prepare endpoint with the private key behind this charge's `escrowReleaserAddress` (EIP-712 `signTypedData` — the resulting 65-byte signature is what goes here). The amount is the escrow's live token balance, so a prepare result is only valid while that balance is unchanged; if it changes before you submit, the signature no longer authorizes the transfer and this call fails with `400 escrow_invalid_signature` — prepare again and re-sign. Independently verified on-chain before anything moves: the Safe contract itself rejects any signature that isn't from `escrowReleaserAddress`, never trusted at face value by Klappay."}},"required":["signature"]},"example":{"signature":"0x2d0fbf1dba287883a4b6c5aeef9da7653dc68b3e20417d42e87db700ad9e878662edc0c052143c22892355d5c87e74e66be49399c7c7a4db98108ea51643d4651c"}}}},"responses":{"200":{"description":"Refunded charge","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `ch_...`. Use this to look up the charge later."},"amount":{"type":"number","description":"The total the payer must send, in `currency` units (up to 6 decimal places). This legacy JSON number can lose precision; use `amountExact` for calculations and payment preparation. With `feePayer: 'merchant'` (the default) this is what you requested at creation. With `feePayer: 'payer'` this is grossed up to cover `feePercent` — see `merchantAmount` for what you requested/will actually net."},"amountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact total the payer must send, in `currency` units, as a nonnegative decimal string with at most 6 fractional digits and no scientific notation (e.g. `\"100.000001\"`). Use this instead of `amount` for arithmetic. Optional for compatibility with older API responses."},"feePayer":{"type":"string","enum":["merchant","payer"],"description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"feePercent":{"type":"number","description":"Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%) — includes any escrow surcharge if this charge is an escrow. Frozen at creation; see `feeAmount`/`merchantAmount` for the actual amounts this works out to."},"feeAmount":{"type":"number","description":"`amount * feePercent / 100`, in `currency` units — Klappay's cut of this charge."},"merchantAmount":{"type":"number","description":"`amount - feeAmount`, in `currency` units — what you actually net once the payout settles, regardless of `feePayer` (this is always what the split delivers to you; `feePayer` only affects what `amount` was set to at creation)."},"amountReceived":{"type":["number","null"],"description":"Cumulative amount received on-chain so far, in `currency` units. This legacy JSON number can lose precision; use `amountReceivedExact` for arithmetic with up to 18 fractional digits. `null` until the first transfer arrives. Can exceed `amount` — see `isOverpaid`."},"amountReceivedExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact cumulative amount received on-chain, in `currency` units, as a nonnegative decimal string with at most 18 fractional digits and no scientific notation (e.g. `\"0.0000000001\"`). `null` until the first transfer arrives. Can exceed `amountExact`. Optional for compatibility with older API responses."},"paymentUnavailable":{"type":"boolean","description":"When `true`, payment processing for this charge is temporarily unavailable. Do not offer payment instructions, request another transfer, or fulfill from its monetary/status fields until availability is restored. The charge retains its existing business status. Optional for compatibility with older API responses."},"isOverpaid":{"type":"boolean","description":"`true` if `amountReceived` ended up greater than `amount`. Klappay never refunds the difference automatically — see the docs for why."},"currency":{"type":"string","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair this charge was configured to accept, unchanged after creation."},"paidWith":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every distinct `(token, network)` pair that has actually contributed a credited transfer so far — empty until the first one arrives. Can hold more than one entry: a charge accepting several pairs can be paid across a combination of them, and every entry here sums toward `amountReceived`."},"swapAlternatives":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network to send `token` on — pass both as `inputToken`/`inputNetwork` to `POST /v1/charges/{id}/quote`. The same token can appear more than once here, once per network that trusts it and that this charge accepts payment on."}},"required":["token","network"]},"description":"Every `(token, network)` pair the payer can pay with instead, via `POST /v1/charges/{id}/quote` — derived from the networks in `acceptedPayments` (e.g. a charge accepting USDC on both Base and Optimism lists `ETH` on Base and `ETH` on Optimism separately, since they're different networks the payer has to choose between, not one merged option). Pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork`. Recomputed on every read against Klappay's current trusted list, not frozen at creation — empty if this charge's networks have no trusted alt-token, if swap-to-pay isn't configured on this deployment, or if `environment` is `test` (0x, who powers the swap, has no testnet support at all — `POST /v1/charges/{id}/quote` always rejects a test-environment charge with `422 swap_test_environment_unsupported`)."},"address":{"type":"string","description":"The on-chain address the payer must send funds to — identical across every accepted network (0xSplits addresses are chain-agnostic). Unique per charge, predicted at creation time — funds sent here go directly to the merchant, Klappay never custodies them."},"status":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"settlementStatus":{"type":["string","null"],"enum":["pending","completed","failed"],"description":"Progress of the payout to the merchant's wallet, a separate step from `status` — `status: confirmed` only means the payment was detected on-chain, not that the merchant has been paid yet. `pending`: payment detected, payout not yet attempted. `completed`: the merchant's wallet has the funds. `failed`: the payout attempt failed and retries were exhausted (rare; contact support). `null` on the parent `Charge` means no payout has been attempted yet — nothing has been received, or the charge is still in progress."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"apiKeyId":{"type":["string","null"],"description":"Which of your API keys created this charge. `null` for a charge created before this field existed."},"txHash":{"type":["string","null"],"description":"Transaction hash of the most recent transfer detected for this charge. `null` until a payment is detected."},"externalRef":{"type":["string","null"]},"source":{"type":["string","null"]},"metadata":{"type":["object","null"],"properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":["string","null"],"description":"Echoes the `redirectUrl` set at creation, if any. `null` if none was set."},"checkoutUrl":{"type":["string","null"],"description":"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` instead."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"Address to send a slice of this charge to — an EVM 0x... address or a TRON T... address, matching whichever network family the charge accepts (acceptedPayments can only ever list networks from one split family at a time, so this always matches that same family)."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this address instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay."}},"required":["address","percent"]},"description":"Echoes whatever extra split recipients were set at creation — empty array if none."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"When this charge stops accepting payment, if still `pending`/`partially_paid` by then."},"confirmedAt":{"type":["string","null"],"format":"date-time","description":"When `status` first reached `confirmed`. `null` until then."},"settledAt":{"type":["string","null"],"format":"date-time","description":"When `settlementStatus` first reached `completed` — the merchant's wallet actually has the funds. `null` until then, including while `settlementStatus` is `pending`/`failed`."},"lastActivityAt":{"type":"string","format":"date-time","description":"When a transfer was last credited toward this charge, or `createdAt` if none has arrived yet."},"escrow":{"type":["object","null"],"properties":{"releaserAddress":{"type":"string","description":"The only address that can ever release this escrow — never Klappay."},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the release actually executed on-chain. `null` until then."},"refundedAt":{"type":["string","null"],"format":"date-time","description":"When the refund actually executed on-chain. `null` until then. Mutually exclusive with `releasedAt` — an escrow can only ever be released or refunded once, never both."}},"required":["releaserAddress","releasedAt","refundedAt"],"description":"Present only when this charge was created as an escrow (see `escrow` on the create request) — `null` for a normal charge."}},"required":["id","amount","feePayer","feePercent","feeAmount","merchantAmount","amountReceived","isOverpaid","currency","acceptedPayments","paidWith","swapAlternatives","address","status","settlementStatus","environment","apiKeyId","txHash","externalRef","source","metadata","redirectUrl","checkoutUrl","splitRecipients","createdAt","expiresAt","confirmedAt","settledAt","lastActivityAt","escrow"]}}}},"400":{"description":"`signature` did not authorize this refund — it isn't from this charge's `escrowReleaserAddress`, or it was made for a prepared transaction that no longer matches the escrow's current balance (prepare again and re-sign) (`escrow_invalid_signature`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"This escrow was already released or refunded, or another refund request for it is currently being processed — safe to retry shortly in the latter case (`escrow_already_released` / `escrow_already_refunded` / `escrow_refund_in_progress`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This charge wasn't created as an escrow, currently holds a zero balance, received payment across more than one token/network pair (not yet supported), or its Safe can't be reconstructed at the charge's address (`escrow_safe_mismatch` — not transient, contact support) (`escrow_not_configured` / `escrow_safe_mismatch` / `escrow_nothing_to_refund` / `escrow_multiple_payment_pairs`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The blockchain RPC could not be reached, or payment processing is temporarily unavailable while this charge or payment method is under review. An integrity hold requires review before refund can resume. (`rpc_unavailable` / `payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/release/prepare":{"get":{"tags":["Charges"],"summary":"Prepare an escrow charge's release for signing","description":"Returns the exact Safe transaction `POST /v1/charges/{id}/release` will execute — a transfer of the escrow's entire live token balance to this charge's split address — with its EIP-712 `typedData` and `safeTxHash`. Sign `typedData` with the private key behind `escrowReleaserAddress` and send the signature to the release endpoint; no Safe SDK or RPC access is needed on your side. Check `destination`, `tokenAddress`, `amount`, `chainId` and `safeAddress` against your own records before signing. Read-only — preparing never moves funds. Valid only while the escrow's balance is unchanged: if more funds arrive before you submit, the signature no longer matches and release fails with `400 escrow_invalid_signature` — prepare again and re-sign.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The Safe transaction to sign for this release","content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string","enum":["release","refund"],"description":"Which escrow action this transaction performs."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The network the escrow was paid on — the transaction executes there."},"token":{"type":"string","enum":["USDC","USDT"],"description":"The token being moved out of the escrow."},"chainId":{"type":"integer","description":"EVM chain id of `network`."},"safeAddress":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The escrow Safe's address (the charge's `address`)."},"releaserAddress":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The only address whose signature authorizes this transaction (the charge's `escrowReleaserAddress`)."},"tokenAddress":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The token's contract address on `network`."},"destination":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"Where the funds go: the charge's split address for a release, or the payer address for a refund."},"amount":{"type":"string","pattern":"^\\d+$","description":"Amount transferred, in the token's smallest unit (e.g. `1500000` = 1.50 for a 6-decimal token) — the escrow's entire live balance at prepare time."},"decimals":{"type":"integer","description":"The token's decimals on `network`, to convert `amount` for display."},"nonce":{"type":"string","pattern":"^\\d+$","description":"The Safe nonce this transaction executes at."},"safeTxHash":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$","description":"EIP-712 hash of `typedData` — what the signature actually commits to."},"typedData":{"type":"object","properties":{"domain":{"type":"object","properties":{"chainId":{"type":"integer","description":"EVM chain id of the network the escrow is paid on."},"verifyingContract":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The escrow Safe's address."}},"required":["chainId","verifyingContract"]},"types":{"type":"object","properties":{"SafeTx":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string"}},"required":["name","type"]}}},"required":["SafeTx"],"description":"The `SafeTx` struct definition. `EIP712Domain` is deliberately omitted — EIP-712 libraries derive it from `domain` (viem does, and ethers rejects typed data that includes it)."},"primaryType":{"type":"string","enum":["SafeTx"]},"message":{"type":"object","properties":{"to":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The token contract — the Safe calls the token's `transfer`."},"value":{"type":"string","pattern":"^\\d+$","description":"Native value sent with the call, always `\"0\"`."},"data":{"type":"string","pattern":"^0x[0-9a-fA-F]*$","description":"ABI-encoded `transfer(destination, amount)` call on the token contract."},"operation":{"type":"integer","description":"Always `0` (a plain call, never a delegatecall)."},"safeTxGas":{"type":"string","pattern":"^\\d+$","description":"Always `\"0\"`."},"baseGas":{"type":"string","pattern":"^\\d+$","description":"Always `\"0\"`."},"gasPrice":{"type":"string","pattern":"^\\d+$","description":"Always `\"0\"` — Klappay pays the gas, nothing is refunded from the Safe."},"gasToken":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"Always the zero address."},"refundReceiver":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"Always the zero address."},"nonce":{"type":"string","pattern":"^\\d+$","description":"The Safe nonce this transaction executes at."}},"required":["to","value","data","operation","safeTxGas","baseGas","gasPrice","gasToken","refundReceiver","nonce"]}},"required":["domain","types","primaryType","message"],"description":"EIP-712 typed data for the Safe transaction. Numeric fields are decimal strings, so it is plain JSON; in JavaScript it can go straight to an EIP-712 signer (viem's or ethers' `signTypedData`, or a KMS that signs typed data). In TypeScript, pass it through `toSignableEscrowTypedData` from `@klappay/types` first — it returns the same data with `bigint` numbers and literal field types, which viem's typings require. Hashing it yields `safeTxHash`."}},"required":["action","network","token","chainId","safeAddress","releaserAddress","tokenAddress","destination","amount","decimals","nonce","safeTxHash","typedData"],"description":"The exact Safe transaction Klappay will execute for this escrow's release or refund, for `releaserAddress` to sign. Check `destination`, `tokenAddress`, `amount`, `chainId` and `safeAddress` against your own records before signing — signing commits the escrow's funds to exactly this transfer. Valid only while the escrow's balance is unchanged; prepare again if it changes. Read-only: preparing never moves funds."},"example":{"action":"release","network":"base","token":"USDC","chainId":8453,"safeAddress":"0x5aa1ce2b1f2ae1bd3b0fd2fbd6f3f2f5b5d6c7e8","releaserAddress":"0x1111111111111111111111111111111111111111","tokenAddress":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","destination":"0x2222222222222222222222222222222222222222","amount":"25000000","decimals":6,"nonce":"0","safeTxHash":"0x8f7c1b0a3e2d4c5b6a79881726354433221100ffeeddccbbaa99887766554433","typedData":{"domain":{"chainId":8453,"verifyingContract":"0x5aa1ce2b1f2ae1bd3b0fd2fbd6f3f2f5b5d6c7e8"},"types":{"SafeTx":[{"name":"to","type":"address"},{"name":"value","type":"uint256"},{"name":"data","type":"bytes"},{"name":"operation","type":"uint8"},{"name":"safeTxGas","type":"uint256"},{"name":"baseGas","type":"uint256"},{"name":"gasPrice","type":"uint256"},{"name":"gasToken","type":"address"},{"name":"refundReceiver","type":"address"},{"name":"nonce","type":"uint256"}]},"primaryType":"SafeTx","message":{"to":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","value":"0","data":"0xa9059cbb000000000000000000000000222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000017d7840","operation":0,"safeTxGas":"0","baseGas":"0","gasPrice":"0","gasToken":"0x0000000000000000000000000000000000000000","refundReceiver":"0x0000000000000000000000000000000000000000","nonce":"0"}}}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"This escrow was already released or refunded — nothing left to prepare (`escrow_already_released` / `escrow_already_refunded`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This charge wasn't created as an escrow, currently holds a zero balance, received payment across more than one token/network pair (not yet supported), or its Safe can't be reconstructed at the charge's address (`escrow_safe_mismatch` — not transient, contact support) (`escrow_not_configured` / `escrow_nothing_to_release` / `escrow_multiple_payment_pairs` / `escrow_safe_mismatch`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The blockchain RPC could not be reached, or payment processing is temporarily unavailable while this charge or payment method is under review. An integrity hold requires review before release can resume. (`rpc_unavailable` / `payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/charges/{id}/refund/prepare":{"get":{"tags":["Charges"],"summary":"Prepare an escrow charge's refund for signing","description":"Returns the exact Safe transaction `POST /v1/charges/{id}/refund` will execute — a transfer of the escrow's entire live token balance back to `destination`, the sender of this charge's most recently credited transfer — with its EIP-712 `typedData` and `safeTxHash`. Sign `typedData` with the private key behind `escrowReleaserAddress` and send the signature to the refund endpoint; no Safe SDK or RPC access is needed on your side. Check `destination` before signing: it's whoever sent the last credited transfer, which isn't always the buyer (an exchange withdrawal arrives from the exchange's wallet, a swap from a router contract, and a charge paid by several wallets refunds only to the last one). Read-only — preparing never moves funds. Valid only while the escrow's balance is unchanged; prepare again if it changes.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The Safe transaction to sign for this refund","content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string","enum":["release","refund"],"description":"Which escrow action this transaction performs."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The network the escrow was paid on — the transaction executes there."},"token":{"type":"string","enum":["USDC","USDT"],"description":"The token being moved out of the escrow."},"chainId":{"type":"integer","description":"EVM chain id of `network`."},"safeAddress":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The escrow Safe's address (the charge's `address`)."},"releaserAddress":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The only address whose signature authorizes this transaction (the charge's `escrowReleaserAddress`)."},"tokenAddress":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The token's contract address on `network`."},"destination":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"Where the funds go: the charge's split address for a release, or the payer address for a refund."},"amount":{"type":"string","pattern":"^\\d+$","description":"Amount transferred, in the token's smallest unit (e.g. `1500000` = 1.50 for a 6-decimal token) — the escrow's entire live balance at prepare time."},"decimals":{"type":"integer","description":"The token's decimals on `network`, to convert `amount` for display."},"nonce":{"type":"string","pattern":"^\\d+$","description":"The Safe nonce this transaction executes at."},"safeTxHash":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$","description":"EIP-712 hash of `typedData` — what the signature actually commits to."},"typedData":{"type":"object","properties":{"domain":{"type":"object","properties":{"chainId":{"type":"integer","description":"EVM chain id of the network the escrow is paid on."},"verifyingContract":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The escrow Safe's address."}},"required":["chainId","verifyingContract"]},"types":{"type":"object","properties":{"SafeTx":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string"}},"required":["name","type"]}}},"required":["SafeTx"],"description":"The `SafeTx` struct definition. `EIP712Domain` is deliberately omitted — EIP-712 libraries derive it from `domain` (viem does, and ethers rejects typed data that includes it)."},"primaryType":{"type":"string","enum":["SafeTx"]},"message":{"type":"object","properties":{"to":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"The token contract — the Safe calls the token's `transfer`."},"value":{"type":"string","pattern":"^\\d+$","description":"Native value sent with the call, always `\"0\"`."},"data":{"type":"string","pattern":"^0x[0-9a-fA-F]*$","description":"ABI-encoded `transfer(destination, amount)` call on the token contract."},"operation":{"type":"integer","description":"Always `0` (a plain call, never a delegatecall)."},"safeTxGas":{"type":"string","pattern":"^\\d+$","description":"Always `\"0\"`."},"baseGas":{"type":"string","pattern":"^\\d+$","description":"Always `\"0\"`."},"gasPrice":{"type":"string","pattern":"^\\d+$","description":"Always `\"0\"` — Klappay pays the gas, nothing is refunded from the Safe."},"gasToken":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"Always the zero address."},"refundReceiver":{"type":"string","pattern":"^0x[0-9a-f]{40}$","description":"Always the zero address."},"nonce":{"type":"string","pattern":"^\\d+$","description":"The Safe nonce this transaction executes at."}},"required":["to","value","data","operation","safeTxGas","baseGas","gasPrice","gasToken","refundReceiver","nonce"]}},"required":["domain","types","primaryType","message"],"description":"EIP-712 typed data for the Safe transaction. Numeric fields are decimal strings, so it is plain JSON; in JavaScript it can go straight to an EIP-712 signer (viem's or ethers' `signTypedData`, or a KMS that signs typed data). In TypeScript, pass it through `toSignableEscrowTypedData` from `@klappay/types` first — it returns the same data with `bigint` numbers and literal field types, which viem's typings require. Hashing it yields `safeTxHash`."}},"required":["action","network","token","chainId","safeAddress","releaserAddress","tokenAddress","destination","amount","decimals","nonce","safeTxHash","typedData"],"description":"The exact Safe transaction Klappay will execute for this escrow's release or refund, for `releaserAddress` to sign. Check `destination`, `tokenAddress`, `amount`, `chainId` and `safeAddress` against your own records before signing — signing commits the escrow's funds to exactly this transfer. Valid only while the escrow's balance is unchanged; prepare again if it changes. Read-only: preparing never moves funds."},"example":{"action":"refund","network":"base","token":"USDC","chainId":8453,"safeAddress":"0x5aa1ce2b1f2ae1bd3b0fd2fbd6f3f2f5b5d6c7e8","releaserAddress":"0x1111111111111111111111111111111111111111","tokenAddress":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","destination":"0x2222222222222222222222222222222222222222","amount":"25000000","decimals":6,"nonce":"0","safeTxHash":"0x8f7c1b0a3e2d4c5b6a79881726354433221100ffeeddccbbaa99887766554433","typedData":{"domain":{"chainId":8453,"verifyingContract":"0x5aa1ce2b1f2ae1bd3b0fd2fbd6f3f2f5b5d6c7e8"},"types":{"SafeTx":[{"name":"to","type":"address"},{"name":"value","type":"uint256"},{"name":"data","type":"bytes"},{"name":"operation","type":"uint8"},{"name":"safeTxGas","type":"uint256"},{"name":"baseGas","type":"uint256"},{"name":"gasPrice","type":"uint256"},{"name":"gasToken","type":"address"},{"name":"refundReceiver","type":"address"},{"name":"nonce","type":"uint256"}]},"primaryType":"SafeTx","message":{"to":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","value":"0","data":"0xa9059cbb000000000000000000000000222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000017d7840","operation":0,"safeTxGas":"0","baseGas":"0","gasPrice":"0","gasToken":"0x0000000000000000000000000000000000000000","refundReceiver":"0x0000000000000000000000000000000000000000","nonce":"0"}}}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `charges:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"This escrow was already released or refunded — nothing left to prepare (`escrow_already_released` / `escrow_already_refunded`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This charge wasn't created as an escrow, currently holds a zero balance, received payment across more than one token/network pair (not yet supported), or its Safe can't be reconstructed at the charge's address (`escrow_safe_mismatch` — not transient, contact support) (`escrow_not_configured` / `escrow_nothing_to_refund` / `escrow_multiple_payment_pairs` / `escrow_safe_mismatch`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The blockchain RPC could not be reached, or payment processing is temporarily unavailable while this charge or payment method is under review. An integrity hold requires review before refund can resume. (`rpc_unavailable` / `payment_temporarily_unavailable`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks":{"post":{"tags":["Webhooks"],"summary":"Register a webhook","description":"Registers a URL to receive event notifications — must be HTTPS and resolve to a public address. Select events individually (`events`), by whole category (`eventCategories` — new events in that category arrive automatically), or via `events: [\"*\"]` combined with `excludeEvents`. At least one of `events` or `eventCategories` is required — omitting both is a `400`. Returns a signing secret exactly once; use it to verify the `X-Klappay-Signature` header on every delivery. Limited to 20 active webhooks per organization.","security":[{"apiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","maxLength":2048,"format":"uri","description":"Must be HTTPS and resolve to a public address — private/internal IPs are rejected."},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."},{"type":"string","enum":["*"]}]},"maxItems":14,"default":[],"description":"Individual event types to receive, or `\"*\"` for every event (combine with `excludeEvents` to opt back out of specific ones). Omit in favor of `eventCategories` if you want whole categories instead."},"eventCategories":{"type":"array","items":{"type":"string","enum":["payments","webhooks"],"description":"Subscribe to every event in a category via `eventCategories` instead of listing events one by one — new events added to a category later arrive automatically, no subscription update needed."},"maxItems":2,"default":[],"description":"Subscribe to every event in these categories. At least one of `events` or `eventCategories` is required."},"excludeEvents":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]},"maxItems":13,"default":[],"description":"Event types to exclude even if selected via `events: [\"*\"]` or `eventCategories`."}},"required":["url"],"example":{"url":"https://example.com/webhooks/klap","events":["charge.confirmed","charge.settled"],"eventCategories":[],"excludeEvents":[]}}}}},"responses":{"201":{"description":"Webhook created","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"environment":{"type":["string","null"],"enum":["live","test"],"description":"Which environment's API key created this webhook — `live` or `test`. Every event is only ever delivered to a webhook whose `environment` matches the event's own (or to a webhook with `environment: null`, which receives every environment — the case for every webhook created before this field existed)."},"url":{"type":"string"},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]}},"eventCategories":{"type":"array","items":{"type":"string","enum":["payments","webhooks"],"description":"Subscribe to every event in a category via `eventCategories` instead of listing events one by one — new events added to a category later arrive automatically, no subscription update needed."}},"excludeEvents":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]}},"isWildcard":{"type":"boolean"},"secret":{"type":"string","description":"The signing secret, used to verify the `X-Klappay-Signature` header on every delivery. Returned in full only this once — store it now, it is not recoverable afterward. Header format: `t=<unix-seconds>,v1=<hex-encoded HMAC-SHA256>`. Compute the expected signature as `HMAC-SHA256(secret, \"${t}.${raw request body}\")` (hex-encoded) and compare it to `v1` using a constant-time comparison; as a replay-protection measure, also reject if `t` is too far from the current time — Klappay does not enforce or check any particular tolerance server-side, so the exact threshold is entirely the receiver's own policy call. An official SDK's `constructEvent()`/`verifySignature()` do this for you, defaulting to a 300-second tolerance, overridable via `constructEvent`'s `toleranceSeconds` option — see github.com/klappay for available SDKs."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","environment","url","events","eventCategories","excludeEvents","isWildcard","secret","createdAt"]}}}},"400":{"description":"Validation error, or `url` isn't a valid HTTPS URL that resolves to a public (non-private, non-loopback, non-reserved) address (`validation_error` / `invalid_webhook_url` / `insecure_webhook_url` / `unresolvable_webhook_url` / `unsafe_webhook_url`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `webhooks:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Webhook limit reached for this organization (`webhook_limit_reached`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Webhooks"],"summary":"List webhooks","description":"Lists your tenant's registered webhooks, with a hint of the secret instead of the secret itself. Scoped to the calling key's own environment — a webhook created with a `live`/`test` key is only visible to a `live`/`test` key respectively (a webhook predating environment-scoping, with no `environment` of its own, is visible to both).","security":[{"apiKeyAuth":[]}],"responses":{"200":{"description":"Webhooks","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"environment":{"type":["string","null"],"enum":["live","test"],"description":"Which environment's API key created this webhook — `live` or `test`. Every event is only ever delivered to a webhook whose `environment` matches the event's own (or to a webhook with `environment: null`, which receives every environment — the case for every webhook created before this field existed)."},"url":{"type":"string"},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]}},"eventCategories":{"type":"array","items":{"type":"string","enum":["payments","webhooks"],"description":"Subscribe to every event in a category via `eventCategories` instead of listing events one by one — new events added to a category later arrive automatically, no subscription update needed."}},"excludeEvents":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]}},"isWildcard":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"hint":{"type":"string","description":"A truncated, safe-to-display form of the secret (e.g. `whsec_...ab12`)."}},"required":["id","environment","url","events","eventCategories","excludeEvents","isWildcard","createdAt","hint"]}}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `webhooks:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/{id}":{"delete":{"tags":["Webhooks"],"summary":"Remove a webhook","description":"Stops future event deliveries to this webhook. Past delivery history is kept and still queryable.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Webhook removed"},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `webhooks:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Webhook not found (`webhook_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/{id}/rotate-secret":{"post":{"tags":["Webhooks"],"summary":"Rotate a webhook signing secret","description":"Generates a fresh signing secret for this webhook, replacing the old one immediately — no further `X-Klappay-Signature` header will validate against the old secret. Use this if a secret ever leaks, instead of deleting and recreating the webhook (which would lose its id and delivery history). Returns the new secret exactly once, same as creation.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Webhook with its new secret","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"environment":{"type":["string","null"],"enum":["live","test"],"description":"Which environment's API key created this webhook — `live` or `test`. Every event is only ever delivered to a webhook whose `environment` matches the event's own (or to a webhook with `environment: null`, which receives every environment — the case for every webhook created before this field existed)."},"url":{"type":"string"},"events":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]}},"eventCategories":{"type":"array","items":{"type":"string","enum":["payments","webhooks"],"description":"Subscribe to every event in a category via `eventCategories` instead of listing events one by one — new events added to a category later arrive automatically, no subscription update needed."}},"excludeEvents":{"type":"array","items":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]}},"isWildcard":{"type":"boolean"},"secret":{"type":"string","description":"The signing secret, used to verify the `X-Klappay-Signature` header on every delivery. Returned in full only this once — store it now, it is not recoverable afterward. Header format: `t=<unix-seconds>,v1=<hex-encoded HMAC-SHA256>`. Compute the expected signature as `HMAC-SHA256(secret, \"${t}.${raw request body}\")` (hex-encoded) and compare it to `v1` using a constant-time comparison; as a replay-protection measure, also reject if `t` is too far from the current time — Klappay does not enforce or check any particular tolerance server-side, so the exact threshold is entirely the receiver's own policy call. An official SDK's `constructEvent()`/`verifySignature()` do this for you, defaulting to a 300-second tolerance, overridable via `constructEvent`'s `toleranceSeconds` option — see github.com/klappay for available SDKs."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","environment","url","events","eventCategories","excludeEvents","isWildcard","secret","createdAt"]}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `webhooks:manage_secret` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Webhook not found (`webhook_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/{id}/deliveries":{"get":{"tags":["Webhooks"],"summary":"List delivery logs for a webhook","description":"Returns delivery attempts for a webhook, newest first — status, HTTP response code, and attempt count. Cursor-paginated. Useful for diagnosing why an event never arrived.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"number","minimum":1,"maximum":100,"default":20,"description":"Max items to return per page (1–100, default 20)."},"required":false,"description":"Max items to return per page (1–100, default 20).","name":"limit","in":"query"},{"schema":{"type":"string","maxLength":500,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change."},"required":false,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change.","name":"cursor","in":"query"}],"responses":{"200":{"description":"Delivery logs","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"webhookId":{"type":"string"},"event":{"anyOf":[{"type":"string","enum":["charge.created","charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid","charge.escrow_released","charge.escrow_refunded"],"description":"Note the distinction between `charge.confirmed` and `charge.settled`: `confirmed` means the payment was detected on-chain; `settled` means the merchant's wallet actually received the funds — a separate, later step. Subscribe to `confirmed` if you only need \"will I get paid,\" or `settled` if you need \"has the money actually arrived.\" `charge.overpaid` fires alongside `charge.confirmed`/`charge.partially_paid` whenever the cumulative amount received ends up above `amount` (see `Charge.isOverpaid`). `charge.escrow_released` fires once an escrow-configured charge's funds have been moved out of its Safe to the split address by `POST /v1/charges/{id}/release` — a normal `charge.settled` still follows once the split itself finishes distributing. `charge.escrow_refunded` fires once an escrow-configured charge's funds have been moved out of its Safe back to the payer by `POST /v1/charges/{id}/refund` — mutually exclusive with `charge.escrow_released`, an escrow charge only ever emits one of the two. Every event in this category carries the full `Charge` object as `data`."},{"type":"string","enum":["webhook.delivery_failed","webhook.delivery_recovered","webhook.endpoint_unhealthy"],"description":"Meta-events about the health of your own webhook endpoints — useful for monitoring without polling `GET /v1/webhooks/{id}/deliveries`. `webhook.endpoint_unhealthy` fires once when a webhook's failure rate over the trailing 24h crosses 20%, and `webhook.delivery_recovered` fires once when a delivery to that webhook next succeeds. `data` for every event in this category: `{ webhookId, url, failureRatio? }` (`failureRatio` only present on `webhook.endpoint_unhealthy`)."}]},"status":{"type":"string","enum":["pending","delivered","failed"],"description":"`pending`: still retrying. `delivered`: got a 2xx response. `failed`: retries exhausted (5 attempts over ~24h) — use `POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry` to try again manually."},"attempts":{"type":"number"},"responseCode":{"type":["number","null"],"description":"HTTP status your endpoint returned on the most recent attempt. `null` if every attempt failed to connect at all."},"nextRetryAt":{"type":["string","null"],"format":"date-time"},"deliveredAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","webhookId","event","status","attempts","responseCode","nextRetryAt","deliveredAt","createdAt"]}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` to fetch the next page. `null` when there are no more results."},"hasMore":{"type":"boolean"}},"required":["data","nextCursor","hasMore"]}}}},"400":{"description":"Malformed pagination cursor (`invalid_cursor`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `webhooks:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Webhook not found (`webhook_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/{id}/deliveries/{deliveryId}/retry":{"post":{"tags":["Webhooks"],"summary":"Manually retry a webhook delivery","description":"Immediately attempts redelivery of a specific event, regardless of its normal retry schedule or whether retries were already exhausted. Does not wait for the attempt to finish — check `GET /v1/webhooks/{id}/deliveries` afterward for the outcome.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"deliveryId","in":"path"}],"responses":{"202":{"description":"Retry accepted"},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `webhooks:write` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Webhook or delivery not found (`webhook_not_found` / `delivery_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/listen":{"get":{"tags":["Webhooks"],"summary":"Stream every webhook event for this organization live (used by the official CLI)","description":"Opens a long-lived Server-Sent Events stream carrying every webhook event dispatched for the authenticated organization, regardless of whether any webhook is registered for it. Charge events are scoped to the connecting API key's own environment (a `test` key never sees `live` charge events and vice versa); webhook-delivery-health events aren't charge-specific, so they're always visible regardless of which key connected. The first event (`event: session`) carries an ephemeral signing secret, valid only for this connection and never persisted — used to sign forwarded payloads the same way a real webhook delivery is signed. This is what `klap listen --forward-to` (`@klappay/cli`, [github.com/klappay](https://github.com/klappay)) consumes to forward events to a local server without exposing it to the internet, and is also the right endpoint for anyone building an equivalent local-dev tool — no webhook registration required.","security":[{"apiKeyAuth":[]}],"responses":{"200":{"description":"SSE stream. First event is `event: session` with `data: { \"secret\": \"whsec_...\" }`. Every subsequent event is `event: webhook` with `data: <WebhookPayload JSON>`. A comment-only heartbeat (`: ping`) is sent periodically to keep the connection alive through proxies.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `webhooks:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Too many concurrent event streams — try again shortly. (`sse_capacity`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/sandbox/charges/{id}/trigger":{"post":{"tags":["Sandbox"],"summary":"Simulate a charge event (test API key only)","description":"Pushes a `test`-environment charge into any state transition, without any real on-chain transfer — the matching webhook fires and any SSE stream updates exactly as it would for a real payment. Both the API key used and the charge itself must be `test`-environment. Each event has a precondition on the charge's current state (e.g. `charge.underpaid` requires the charge to already be `partially_paid` — trigger that first): triggering an event the charge isn't eligible for returns `422 invalid_trigger_state`. `charge.settled`/`charge.settlement_failed` never call the blockchain — they simulate the outcome directly, so a test charge never spends real gas. `charge.overpaid` also dispatches `charge.confirmed` alongside it, matching real behavior — it never happens standalone. This is the only sandbox trigger endpoint — webhook-delivery-health events are derived from real delivery attempts and have no simulated trigger of their own.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","enum":["charge.partially_paid","charge.confirmed","charge.expired","charge.underpaid","charge.settled","charge.settlement_failed","charge.overpaid"],"description":"Every charge event that represents a payment-progress state transition a sandbox charge can be pushed into — everything except `charge.created` (a charge already exists by the time you have an id to trigger against) and the two escrow-terminal events, which are reached by actually calling `POST /v1/charges/{id}/release` or `POST /v1/charges/{id}/refund` on a test-environment escrow charge (a real Safe transaction on that network's testnet), not this generic trigger."},"amount":{"type":"number","exclusiveMinimum":0,"maximum":999999999999,"description":"Used with `charge.partially_paid` (amount to simulate as received so far — must be less than the charge amount, defaults to half of it if omitted) and with `charge.overpaid` (amount received — must be greater than the charge amount, defaults to 1.5x it if omitted). Ignored for every other event."}},"required":["event"]},"example":{"event":"charge.confirmed"}}}},"responses":{"200":{"description":"Charge updated","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `ch_...`. Use this to look up the charge later."},"amount":{"type":"number","description":"The total the payer must send, in `currency` units (up to 6 decimal places). This legacy JSON number can lose precision; use `amountExact` for calculations and payment preparation. With `feePayer: 'merchant'` (the default) this is what you requested at creation. With `feePayer: 'payer'` this is grossed up to cover `feePercent` — see `merchantAmount` for what you requested/will actually net."},"amountExact":{"type":"string","pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact total the payer must send, in `currency` units, as a nonnegative decimal string with at most 6 fractional digits and no scientific notation (e.g. `\"100.000001\"`). Use this instead of `amount` for arithmetic. Optional for compatibility with older API responses."},"feePayer":{"type":"string","enum":["merchant","payer"],"description":"Who ends up covering Klappay's `feePercent`. `merchant` (default): `amount` is exactly what you asked for, and Klappay's fee is deducted from 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 amount you originally requested — the payer sees and sends the larger, fee-inclusive total. Frozen at creation like every other fee input; does not change how `feeAmount`/`merchantAmount` are computed on read, only what `amount` was set to in the first place."},"feePercent":{"type":"number","description":"Klappay's fee for this charge, as a percent of `amount` (e.g. `2` = 2%) — includes any escrow surcharge if this charge is an escrow. Frozen at creation; see `feeAmount`/`merchantAmount` for the actual amounts this works out to."},"feeAmount":{"type":"number","description":"`amount * feePercent / 100`, in `currency` units — Klappay's cut of this charge."},"merchantAmount":{"type":"number","description":"`amount - feeAmount`, in `currency` units — what you actually net once the payout settles, regardless of `feePayer` (this is always what the split delivers to you; `feePayer` only affects what `amount` was set to at creation)."},"amountReceived":{"type":["number","null"],"description":"Cumulative amount received on-chain so far, in `currency` units. This legacy JSON number can lose precision; use `amountReceivedExact` for arithmetic with up to 18 fractional digits. `null` until the first transfer arrives. Can exceed `amount` — see `isOverpaid`."},"amountReceivedExact":{"type":["string","null"],"pattern":"^(0|[1-9]\\d*)(?:\\.\\d{1,18})?$(?!\\s)","description":"Exact cumulative amount received on-chain, in `currency` units, as a nonnegative decimal string with at most 18 fractional digits and no scientific notation (e.g. `\"0.0000000001\"`). `null` until the first transfer arrives. Can exceed `amountExact`. Optional for compatibility with older API responses."},"paymentUnavailable":{"type":"boolean","description":"When `true`, payment processing for this charge is temporarily unavailable. Do not offer payment instructions, request another transfer, or fulfill from its monetary/status fields until availability is restored. The charge retains its existing business status. Optional for compatibility with older API responses."},"isOverpaid":{"type":"boolean","description":"`true` if `amountReceived` ended up greater than `amount`. Klappay never refunds the difference automatically — see the docs for why."},"currency":{"type":"string","description":"Always `USD` today — the only supported currency."},"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair this charge was configured to accept, unchanged after creation."},"paidWith":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every distinct `(token, network)` pair that has actually contributed a credited transfer so far — empty until the first one arrives. Can hold more than one entry: a charge accepting several pairs can be paid across a combination of them, and every entry here sums toward `amountReceived`."},"swapAlternatives":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["ETH","BNB","POL","AVAX","BTC","LINK","ARB","OP","CBETH"],"description":"A non-stablecoin cryptocurrency Klappay trusts as swap input for a charge, via the 0x Swap API — swapped to one of the charge's `acceptedPayments` tokens before it ever reaches the merchant, so the merchant always receives USDC/USDT regardless of what the payer sent. Trusted on a given network only when it has deep, reputably-issued/custodied liquidity there: each network's own native currency, `BTC` (wrapped) on the networks with a trusted deployment, `LINK` (Chainlink's own official per-chain deployment, except Avalanche's bridged `LINK.e`), and a small set of chain-specific blue chips (`ARB` on Arbitrum, `OP` on Optimism, `CBETH` on Base) — see `ALT_TOKEN_ADDRESSES` for the authoritative per-network list, never assume every value here is available on every network. `POL` replaced `MATIC` as Polygon's native currency name (Polygon's own token migration, 2024) — this schema tracks the network's current native asset, not the deprecated ticker."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"Which network to send `token` on — pass both as `inputToken`/`inputNetwork` to `POST /v1/charges/{id}/quote`. The same token can appear more than once here, once per network that trusts it and that this charge accepts payment on."}},"required":["token","network"]},"description":"Every `(token, network)` pair the payer can pay with instead, via `POST /v1/charges/{id}/quote` — derived from the networks in `acceptedPayments` (e.g. a charge accepting USDC on both Base and Optimism lists `ETH` on Base and `ETH` on Optimism separately, since they're different networks the payer has to choose between, not one merged option). Pass an entry's `token`/`network` straight through as `inputToken`/`inputNetwork`. Recomputed on every read against Klappay's current trusted list, not frozen at creation — empty if this charge's networks have no trusted alt-token, if swap-to-pay isn't configured on this deployment, or if `environment` is `test` (0x, who powers the swap, has no testnet support at all — `POST /v1/charges/{id}/quote` always rejects a test-environment charge with `422 swap_test_environment_unsupported`)."},"address":{"type":"string","description":"The on-chain address the payer must send funds to — identical across every accepted network (0xSplits addresses are chain-agnostic). Unique per charge, predicted at creation time — funds sent here go directly to the merchant, Klappay never custodies them."},"status":{"type":"string","enum":["pending","partially_paid","confirmed","expired","underpaid"],"description":"Payment progress, from the payer side. `pending`: created, nothing received yet. `partially_paid`: some funds received, less than `amount`. `confirmed`: full amount received (or more — see `isOverpaid`). `expired`: `expiresAt` passed with zero funds received. `underpaid`: `expiresAt` passed while `partially_paid`. Every status is reached automatically, on its own timeline — there is no merchant-initiated cancellation. This never reflects whether funds actually reached the merchant — see `settlementStatus` for that."},"settlementStatus":{"type":["string","null"],"enum":["pending","completed","failed"],"description":"Progress of the payout to the merchant's wallet, a separate step from `status` — `status: confirmed` only means the payment was detected on-chain, not that the merchant has been paid yet. `pending`: payment detected, payout not yet attempted. `completed`: the merchant's wallet has the funds. `failed`: the payout attempt failed and retries were exhausted (rare; contact support). `null` on the parent `Charge` means no payout has been attempted yet — nothing has been received, or the charge is still in progress."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"apiKeyId":{"type":["string","null"],"description":"Which of your API keys created this charge. `null` for a charge created before this field existed."},"txHash":{"type":["string","null"],"description":"Transaction hash of the most recent transfer detected for this charge. `null` until a payment is detected."},"externalRef":{"type":["string","null"]},"source":{"type":["string","null"]},"metadata":{"type":["object","null"],"properties":{"klappay":{"type":"object","properties":{"products":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"What the payer is buying, shown as-is on the hosted checkout page."},"quantity":{"type":"integer","exclusiveMinimum":0,"maximum":9999,"description":"How many of this item. Omit for a single, unquantified item."},"imageUrl":{"type":"string","maxLength":2048,"format":"uri","description":"Product image, fetched only by the payer's own browser — Klappay never fetches it server-side. Must be `http(s)`."}},"required":["name"]},"maxItems":20,"description":"What the payer is buying, shown on the hosted checkout page — up to 20 items. Purely informational: never validated against `amount`, never used by any payment or distribution logic."}},"description":"Reserved for Klappay — the one namespace inside `metadata` whose format is defined and enforced by Klappay, not by you. A `metadata.klappay` that does not match this shape is rejected outright (`400 validation_error`), unlike every other key in `metadata`, which accepts absolutely anything and never fails validation."}},"additionalProperties":{},"description":"Arbitrary key/value data, returned as-is on every read. Put whatever you want in here — none of it is validated, except the `klappay` key, which is reserved for Klappay: if present, it must match `KlappayCheckoutMetadataSchema` exactly, or the whole request is rejected with `400 validation_error`."},"redirectUrl":{"type":["string","null"],"description":"Echoes the `redirectUrl` set at creation, if any. `null` if none was set."},"checkoutUrl":{"type":["string","null"],"description":"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` instead."},"splitRecipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"Address to send a slice of this charge to — an EVM 0x... address or a TRON T... address, matching whichever network family the charge accepts (acceptedPayments can only ever list networks from one split family at a time, so this always matches that same family)."},"percent":{"type":"number","exclusiveMinimum":0,"maximum":100,"description":"Percent of *your own* net share (i.e. of `100 - feePercent`, not of the charge's gross `amount`) to route to this address instead of your own payout wallet. Klappay's fee is computed on the gross amount first and is never diluted by how you choose to split what's left — see `docs/payments.md`'s \"Settling the payout\" section for the exact math."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`, `\"sales rep\"`) — echoed back unchanged, never interpreted by Klappay."}},"required":["address","percent"]},"description":"Echoes whatever extra split recipients were set at creation — empty array if none."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"When this charge stops accepting payment, if still `pending`/`partially_paid` by then."},"confirmedAt":{"type":["string","null"],"format":"date-time","description":"When `status` first reached `confirmed`. `null` until then."},"settledAt":{"type":["string","null"],"format":"date-time","description":"When `settlementStatus` first reached `completed` — the merchant's wallet actually has the funds. `null` until then, including while `settlementStatus` is `pending`/`failed`."},"lastActivityAt":{"type":"string","format":"date-time","description":"When a transfer was last credited toward this charge, or `createdAt` if none has arrived yet."},"escrow":{"type":["object","null"],"properties":{"releaserAddress":{"type":"string","description":"The only address that can ever release this escrow — never Klappay."},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the release actually executed on-chain. `null` until then."},"refundedAt":{"type":["string","null"],"format":"date-time","description":"When the refund actually executed on-chain. `null` until then. Mutually exclusive with `releasedAt` — an escrow can only ever be released or refunded once, never both."}},"required":["releaserAddress","releasedAt","refundedAt"],"description":"Present only when this charge was created as an escrow (see `escrow` on the create request) — `null` for a normal charge."}},"required":["id","amount","feePayer","feePercent","feeAmount","merchantAmount","amountReceived","isOverpaid","currency","acceptedPayments","paidWith","swapAlternatives","address","status","settlementStatus","environment","apiKeyId","txHash","externalRef","source","metadata","redirectUrl","checkoutUrl","splitRecipients","createdAt","expiresAt","confirmedAt","settledAt","lastActivityAt","escrow"]}}}},"400":{"description":"Invalid amount for charge.partially_paid/charge.overpaid (`invalid_amount`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Requires a test API key with the `sandbox:trigger` scope, and the charge itself must be test-environment (`forbidden` / `charge_not_test_environment` / `insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Charge not found (`charge_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Charge is not in the state this event requires (`invalid_trigger_state`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/distributions/pending":{"get":{"tags":["Distributions"],"summary":"List distributions currently claimable within the grace period","description":"Cursor-paginated snapshot of every split, in the connecting API key's own environment, with a confirmed payout still inside its 5-minute grace period before Klappay's own worker claims it. `distribute()` on 0xSplits is permissionless — anyone can call it and receive `distributorFeePercent` of the balance. This endpoint exists for discoverability and accountability (which key is consuming it), not to grant a new on-chain capability — `distribute()` is callable by anyone with or without it. Pairs with `GET /v1/distributions/pending/events` for real-time deltas — open that stream *first*, then call this endpoint to bootstrap state (or pass `?limit=` to the stream itself for a self-contained snapshot+live connection), treating `distribution.available`/`distribution.claimed` as idempotent add/remove operations against the snapshot; opening the stream after this call instead leaves a small gap where a delta between the two requests is never delivered. Distributions temporarily unavailable while a payment is under review are omitted. Rate limited to 20 requests/min per tenant (shared across every API key on your account, not per-key).","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"number","minimum":1,"maximum":100,"default":20,"description":"Max items to return per page (1–100, default 20)."},"required":false,"description":"Max items to return per page (1–100, default 20).","name":"limit","in":"query"},{"schema":{"type":"string","maxLength":500,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change."},"required":false,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change.","name":"cursor","in":"query"}],"responses":{"200":{"description":"Currently claimable distributions in the caller's environment","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"splitAddress":{"type":"string","description":"The on-chain 0xSplits address to call `distribute()` on."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."},"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"recipients":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","description":"On-chain recipient address."},"percentAllocation":{"type":"number","description":"This recipient's share of the split, as a percentage (e.g. `99.0991` = 99.0991%)."}},"required":["address","percentAllocation"]},"description":"The exact recipient list to pass to `distribute()` — the split contract only stores a hash of this config, so the caller must supply the identical array to prove it matches. Always present, never reconstructed from partial data."},"distributorFeePercent":{"type":"number","description":"Percentage of the split balance paid to whoever calls `distribute()` first (e.g. `0.1` = 0.1%). Frozen at charge creation, same for every distribution today."},"estimatedRewardAmount":{"type":"number","description":"Estimate only, in the charge's `currency` units, based on the amount Klappay detected on-chain — not a live read of the split's current balance. Read the balance yourself before submitting a transaction; a stale estimate is harmless (see the docs), never a reason to skip that check."},"availableSince":{"type":"string","format":"date-time","description":"When this distribution entered its grace period."},"graceEndsAt":{"type":"string","format":"date-time","description":"When Klappay's own worker may claim this distribution. Racing to call `distribute()` after this timestamp is possible but increasingly likely to lose to the worker."}},"required":["splitAddress","network","token","recipients","distributorFeePercent","estimatedRewardAmount","availableSince","graceEndsAt"]}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` to fetch the next page. `null` when there are no more results."},"hasMore":{"type":"boolean"}},"required":["data","nextCursor","hasMore"]}}}},"400":{"description":"Malformed pagination cursor (`invalid_cursor`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited — max 20 requests/min on /v1/distributions/* (`rate_limited`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/distributions/pending/events":{"get":{"tags":["Distributions"],"summary":"Real-time stream of distribution availability","description":"Server-Sent Events, scoped to the connecting API key's own environment — a `test` key never sees `live` distributions and vice versa. Emits `distribution.available` the instant a new distribution enters its grace period (or re-enters it, if Klappay's own attempt failed and is being retried), and `distribution.claimed` the instant one is no longer claimable — settled by anyone, or picked up by Klappay's own worker (published the moment the worker claims the row, before it finishes, so a keeper doesn't waste gas racing an in-flight attempt). By default (`?limit=` omitted or `0`), no initial snapshot is sent — connect here *first*, then call `GET /v1/distributions/pending` to bootstrap, applying its rows as the baseline and every event received (before or after that call resolves) as an idempotent add/remove on top of it. Pass `?limit=` (up to 100) for a self-contained connection instead — up to that many currently-claimable distributions are sent as synthetic `distribution.available` events immediately after connecting, before live deltas continue; if more than `limit` are claimable at connect time the excess isn't sent, so use the paginated `GET /v1/distributions/pending` directly if you need a complete listing during an anomalous backlog. Distributions temporarily unavailable while a payment is under review are omitted. Rate limited to 20 requests/min per tenant (shared across every API key on your account, not per-key) for the initial connection.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":["integer","null"],"minimum":0,"maximum":100,"default":0,"description":"How many currently-claimable distributions to emit as an initial snapshot right after connecting — each as a synthetic `distribution.available` event — before continuing with real-time deltas. `0` (the default, same as omitting it) sends no snapshot at all, matching this endpoint's original behavior: connect first, then call `GET /v1/distributions/pending` yourself to bootstrap. Not a page — there is no cursor for this snapshot, so if more than `limit` are claimable at connect time, the excess is simply not sent; call `GET /v1/distributions/pending` directly for a complete, paginated listing."},"required":false,"description":"How many currently-claimable distributions to emit as an initial snapshot right after connecting — each as a synthetic `distribution.available` event — before continuing with real-time deltas. `0` (the default, same as omitting it) sends no snapshot at all, matching this endpoint's original behavior: connect first, then call `GET /v1/distributions/pending` yourself to bootstrap. Not a page — there is no cursor for this snapshot, so if more than `limit` are claimable at connect time, the excess is simply not sent; call `GET /v1/distributions/pending` directly for a complete, paginated listing.","name":"limit","in":"query"}],"responses":{"200":{"description":"SSE stream. Each event is `event: distribution.available` or `event: distribution.claimed` with `data: <PendingDistributionEvent JSON>`. A comment-only heartbeat (`: ping`) is sent periodically to keep the connection alive through proxies.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited — max 20 requests/min on /v1/distributions/* (`rate_limited`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Too many concurrent event streams — try again shortly. (`sse_capacity`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/networks":{"get":{"tags":["Networks"],"summary":"List (token, network) pairs your environment's key can accept","description":"The live capabilities matrix for the environment your API key belongs to — the exact same `TOKEN_ADDRESSES` lookup `POST /v1/charges` validates `acceptedPayments` against, so a pair listed here is always safe to submit. Build a payment-method picker from this instead of hardcoding the matrix client-side; it changes as new networks/tokens come online.","security":[{"apiKeyAuth":[]}],"responses":{"200":{"description":"Accepted (token, network) pairs for the calling environment","content":{"application/json":{"schema":{"type":"object","properties":{"acceptedPayments":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."}},"required":["token","network"]},"description":"Every `(token, network)` pair enabled for your environment right now, using the same deployment metadata and availability policy as `POST /v1/charges`. Cataloged but disabled pairs are omitted. Availability can change between requests, so handle `422 token_not_supported` when creating a charge. Use this to build a picker UI instead of hardcoding the matrix client-side."}},"required":["acceptedPayments"]}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/metrics/query":{"post":{"tags":["Metrics"],"summary":"Query metrics","description":"Ad-hoc analytics over your `charges`/`transactions`/`distributions` data — pick a resource, an aggregation, optional `groupBy` (including a single time-bucketed `date_bucket` entry), and filters; the response rows are shaped by that query, not a fixed report. Every filterable/groupable/aggregatable field is an explicit, typed enum — this is not a raw SQL passthrough. Scoped entirely to the calling API key's own tenant — there is no way to query another tenant's data. `environment` must match the calling key's own environment (a `test` key can never query `live` data or vice versa) — a mismatch is `422 environment_mismatch`. Rate limited to 60 requests/min per tenant (shared across every API key on your account, not per-key) — this can be an expensive aggregate query, tighter than the general `/v1/*` limit.","security":[{"apiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"resource":{"type":"string","enum":["charges"]},"environment":{"type":"string","enum":["live","test"],"description":"Which environment's data to query — `live` or `test`. Must match the environment of the API key used to authenticate — scopes the query to charges/transactions/distributions created under a `live` or `test` API key respectively."},"dateRange":{"type":"object","properties":{"field":{"type":"string","enum":["createdAt","confirmedAt","lastActivityAt","expiresAt","escrowReleasedAt"],"description":"A `Charge` timestamp to filter/bucket by. `confirmedAt` is `null` until the charge reaches `confirmed` — a `dateRange`/`date_bucket` on it implicitly excludes every charge that never confirmed. `expiresAt` is always present (set at creation), useful for e.g. finding charges expiring soon or measuring how close to expiry charges typically resolve. `escrowReleasedAt` is `null` until an escrow-configured charge is actually released — same implicit-exclusion behavior as `confirmedAt`, scoped to escrow charges only."},"from":{"type":"string","maxLength":64,"format":"date-time"},"to":{"type":"string","maxLength":64,"format":"date-time"}},"required":["field","from","to"]},"groupBy":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["field"]},"field":{"type":"string","enum":["status","source","apiKeyId","currency","isOverpaid","externalRef","escrowReleaserAddress"],"description":"A `Charge` field to filter or group by — see `ChargeStatusSchema` for `status`'s own possible values (charges.md). `source`/`externalRef` are free-form strings your own integration set at creation, not a fixed enum. `escrowReleaserAddress` is `null` for a normal charge — filter `escrowReleaserAddress` with operator `neq`/value `null` to isolate escrow-configured charges (see `escrow` in charges.md)."}},"required":["type","field"]},{"type":"object","properties":{"type":{"type":"string","enum":["date_bucket"]},"field":{"type":"string","enum":["createdAt","confirmedAt","lastActivityAt","expiresAt","escrowReleasedAt"],"description":"A `Charge` timestamp to filter/bucket by. `confirmedAt` is `null` until the charge reaches `confirmed` — a `dateRange`/`date_bucket` on it implicitly excludes every charge that never confirmed. `expiresAt` is always present (set at creation), useful for e.g. finding charges expiring soon or measuring how close to expiry charges typically resolve. `escrowReleasedAt` is `null` until an escrow-configured charge is actually released — same implicit-exclusion behavior as `confirmedAt`, scoped to escrow charges only."},"granularity":{"type":"string","enum":["day","week","month","year"],"description":"Bucket width for a `date_bucket` `groupBy` entry — Postgres `date_trunc` semantics (UTC)."}},"required":["type","field","granularity"]}]},"maxItems":3,"default":[]},"metrics":{"type":"array","items":{"type":"object","properties":{"aggregation":{"type":"string","enum":["count","sum","avg","min","max"],"description":"`count` counts matching rows and never takes `field`. `sum`/`avg`/`min`/`max` require `field` to be set to one of the resource’s numeric fields."},"field":{"type":"string","enum":["amount","amountReceived","feePercent","escrowFeePercent"],"description":"A `Charge` numeric field to aggregate. `amount`/`amountReceived` are decimal currency amounts (requested vs. actually received — see `charges.md`). `feePercent` is the platform fee frozen on the charge at creation, e.g. `1.5` means 1.5%. `escrowFeePercent` is the additional escrow-specific fee component, only present on escrow-configured charges — see `docs/payments.md`."},"alias":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-zA-Z_][a-zA-Z0-9_]*$"}},"required":["aggregation"]},"minItems":1,"maxItems":10},"filters":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["status","source","apiKeyId","currency","isOverpaid","externalRef","escrowReleaserAddress"],"description":"A `Charge` field to filter or group by — see `ChargeStatusSchema` for `status`'s own possible values (charges.md). `source`/`externalRef` are free-form strings your own integration set at creation, not a fixed enum. `escrowReleaserAddress` is `null` for a normal charge — filter `escrowReleaserAddress` with operator `neq`/value `null` to isolate escrow-configured charges (see `escrow` in charges.md)."},"operator":{"type":"string","enum":["eq","neq","in","gt","gte","lt","lte"],"description":"`in` expects an array value (max 50 entries); every other operator expects a single scalar."},"value":{"anyOf":[{"type":"string","maxLength":255},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"anyOf":[{"type":"string","maxLength":255},{"type":"number"}]},"minItems":1,"maxItems":50},{"type":"null"}]}},"required":["field","operator","value"]},"maxItems":20,"default":[]},"orderBy":{"type":"object","properties":{"key":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-zA-Z_][a-zA-Z0-9_]*$","description":"An output column name from this same query — either a `groupBy` field name, or a metric’s `alias` (or its default name: `${aggregation}` for `count`, `${aggregation}_${field}` otherwise, e.g. `sum_amount`). Must match `^[a-zA-Z_][a-zA-Z0-9_]*$` — every real output column name already does, so this only ever rejects a value that could never have been one."},"direction":{"type":"string","enum":["asc","desc"]}},"required":["key","direction"],"description":"Sort the result rows by any output column — including the bucket field itself for a `date_bucket` query (e.g. `createdAt`). Omit to get ascending-by-bucket order for a date-bucketed query, or implementation-defined (not guaranteed stable) order otherwise."},"limit":{"type":"integer","minimum":1,"maximum":1000,"default":100,"description":"Max rows to return, 1–1000, default 100. If more rows matched, `meta.truncated` is `true` on the response — narrow the query instead of just raising this."}},"required":["resource","environment","dateRange","metrics"]},{"type":"object","properties":{"resource":{"type":"string","enum":["transactions"]},"environment":{"type":"string","enum":["live","test"],"description":"Which environment's data to query — `live` or `test`. Must match the environment of the API key used to authenticate — scopes the query to charges/transactions/distributions created under a `live` or `test` API key respectively."},"dateRange":{"type":"object","properties":{"field":{"type":"string","enum":["detectedAt"],"description":"When Klappay detected this transfer on-chain (not when it was mined)."},"from":{"type":"string","maxLength":64,"format":"date-time"},"to":{"type":"string","maxLength":64,"format":"date-time"}},"required":["field","from","to"]},"groupBy":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["field"]},"field":{"type":"string","enum":["network","token","source","causedTransition"],"description":"A `Transaction` field to filter or group by — see `NetworkSchema`/`TokenSchema`/`TransactionSourceSchema` for their possible values. `causedTransition` is `true` only for the transfer(s) that actually flipped the charge's `status` — a charge paid in installments can have more than one; filtering/grouping on it excludes no-op duplicate transfers (see `TimelineEvent.causedTransition` in charges.md for the full explanation)."}},"required":["type","field"]},{"type":"object","properties":{"type":{"type":"string","enum":["date_bucket"]},"field":{"type":"string","enum":["detectedAt"],"description":"When Klappay detected this transfer on-chain (not when it was mined)."},"granularity":{"type":"string","enum":["day","week","month","year"],"description":"Bucket width for a `date_bucket` `groupBy` entry — Postgres `date_trunc` semantics (UTC)."}},"required":["type","field","granularity"]}]},"maxItems":3,"default":[]},"metrics":{"type":"array","items":{"type":"object","properties":{"aggregation":{"type":"string","enum":["count","sum","avg","min","max"],"description":"`count` counts matching rows and never takes `field`. `sum`/`avg`/`min`/`max` require `field` to be set to one of the resource’s numeric fields."},"field":{"type":"string","enum":["amount"],"description":"The transfer amount, in the charge's `currency` units."},"alias":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-zA-Z_][a-zA-Z0-9_]*$"}},"required":["aggregation"]},"minItems":1,"maxItems":10},"filters":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["network","token","source","causedTransition"],"description":"A `Transaction` field to filter or group by — see `NetworkSchema`/`TokenSchema`/`TransactionSourceSchema` for their possible values. `causedTransition` is `true` only for the transfer(s) that actually flipped the charge's `status` — a charge paid in installments can have more than one; filtering/grouping on it excludes no-op duplicate transfers (see `TimelineEvent.causedTransition` in charges.md for the full explanation)."},"operator":{"type":"string","enum":["eq","neq","in","gt","gte","lt","lte"],"description":"`in` expects an array value (max 50 entries); every other operator expects a single scalar."},"value":{"anyOf":[{"type":"string","maxLength":255},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"anyOf":[{"type":"string","maxLength":255},{"type":"number"}]},"minItems":1,"maxItems":50},{"type":"null"}]}},"required":["field","operator","value"]},"maxItems":20,"default":[]},"orderBy":{"type":"object","properties":{"key":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-zA-Z_][a-zA-Z0-9_]*$","description":"An output column name from this same query — either a `groupBy` field name, or a metric’s `alias` (or its default name: `${aggregation}` for `count`, `${aggregation}_${field}` otherwise, e.g. `sum_amount`). Must match `^[a-zA-Z_][a-zA-Z0-9_]*$` — every real output column name already does, so this only ever rejects a value that could never have been one."},"direction":{"type":"string","enum":["asc","desc"]}},"required":["key","direction"],"description":"Sort the result rows by any output column — including the bucket field itself for a `date_bucket` query (e.g. `createdAt`). Omit to get ascending-by-bucket order for a date-bucketed query, or implementation-defined (not guaranteed stable) order otherwise."},"limit":{"type":"integer","minimum":1,"maximum":1000,"default":100,"description":"Max rows to return, 1–1000, default 100. If more rows matched, `meta.truncated` is `true` on the response — narrow the query instead of just raising this."}},"required":["resource","environment","dateRange","metrics"]},{"type":"object","properties":{"resource":{"type":"string","enum":["distributions"]},"environment":{"type":"string","enum":["live","test"],"description":"Which environment's data to query — `live` or `test`. Must match the environment of the API key used to authenticate — scopes the query to charges/transactions/distributions created under a `live` or `test` API key respectively."},"dateRange":{"type":"object","properties":{"field":{"type":"string","enum":["createdAt","processingStartedAt","completedAt"],"description":"`createdAt`: when this settlement was queued. `processingStartedAt`: when a worker began its most recent attempt at the payout — `null` until the first attempt, then overwritten on every subsequent retry, so it reflects the *latest* attempt's start, not the first. `completedAt`: when it actually paid out — `null` until `status` reaches `completed`, so a `dateRange`/`date_bucket` on it implicitly excludes every distribution still pending/processing/failed."},"from":{"type":"string","maxLength":64,"format":"date-time"},"to":{"type":"string","maxLength":64,"format":"date-time"}},"required":["field","from","to"]},"groupBy":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["field"]},"field":{"type":"string","enum":["status","network","token","distributorAddress"],"description":"A `SplitDistribution` field to filter or group by — see `SplitDistributionStatusSchema`/`NetworkSchema`/`TokenSchema` for their possible values. `distributorAddress` is the public on-chain address that actually called `distribute()` for a `completed` distribution — Klappay's own operator address if settled by Klappay's own worker, a community keeper's address if settled externally (or `null` on the rare case that lookup failed), `null` for every non-`completed` status."}},"required":["type","field"]},{"type":"object","properties":{"type":{"type":"string","enum":["date_bucket"]},"field":{"type":"string","enum":["createdAt","processingStartedAt","completedAt"],"description":"`createdAt`: when this settlement was queued. `processingStartedAt`: when a worker began its most recent attempt at the payout — `null` until the first attempt, then overwritten on every subsequent retry, so it reflects the *latest* attempt's start, not the first. `completedAt`: when it actually paid out — `null` until `status` reaches `completed`, so a `dateRange`/`date_bucket` on it implicitly excludes every distribution still pending/processing/failed."},"granularity":{"type":"string","enum":["day","week","month","year"],"description":"Bucket width for a `date_bucket` `groupBy` entry — Postgres `date_trunc` semantics (UTC)."}},"required":["type","field","granularity"]}]},"maxItems":3,"default":[]},"metrics":{"type":"array","items":{"type":"object","properties":{"aggregation":{"type":"string","enum":["count","sum","avg","min","max"],"description":"`count` counts matching rows and never takes `field`. `sum`/`avg`/`min`/`max` require `field` to be set to one of the resource’s numeric fields."},"field":{"type":"string","enum":["attempts"],"description":"How many times a payout was attempted for this settlement so far — incremented on every attempt, whether it succeeded or is being retried after failing. A high `attempts` alongside `status: 'failed'` means every automatic retry was exhausted."},"alias":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-zA-Z_][a-zA-Z0-9_]*$"}},"required":["aggregation"]},"minItems":1,"maxItems":10},"filters":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","enum":["status","network","token","distributorAddress"],"description":"A `SplitDistribution` field to filter or group by — see `SplitDistributionStatusSchema`/`NetworkSchema`/`TokenSchema` for their possible values. `distributorAddress` is the public on-chain address that actually called `distribute()` for a `completed` distribution — Klappay's own operator address if settled by Klappay's own worker, a community keeper's address if settled externally (or `null` on the rare case that lookup failed), `null` for every non-`completed` status."},"operator":{"type":"string","enum":["eq","neq","in","gt","gte","lt","lte"],"description":"`in` expects an array value (max 50 entries); every other operator expects a single scalar."},"value":{"anyOf":[{"type":"string","maxLength":255},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{"anyOf":[{"type":"string","maxLength":255},{"type":"number"}]},"minItems":1,"maxItems":50},{"type":"null"}]}},"required":["field","operator","value"]},"maxItems":20,"default":[]},"orderBy":{"type":"object","properties":{"key":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-zA-Z_][a-zA-Z0-9_]*$","description":"An output column name from this same query — either a `groupBy` field name, or a metric’s `alias` (or its default name: `${aggregation}` for `count`, `${aggregation}_${field}` otherwise, e.g. `sum_amount`). Must match `^[a-zA-Z_][a-zA-Z0-9_]*$` — every real output column name already does, so this only ever rejects a value that could never have been one."},"direction":{"type":"string","enum":["asc","desc"]}},"required":["key","direction"],"description":"Sort the result rows by any output column — including the bucket field itself for a `date_bucket` query (e.g. `createdAt`). Omit to get ascending-by-bucket order for a date-bucketed query, or implementation-defined (not guaranteed stable) order otherwise."},"limit":{"type":"integer","minimum":1,"maximum":1000,"default":100,"description":"Max rows to return, 1–1000, default 100. If more rows matched, `meta.truncated` is `true` on the response — narrow the query instead of just raising this."}},"required":["resource","environment","dateRange","metrics"]}]},"example":{"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"}]}}}},"responses":{"200":{"description":"Query result rows","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"null"}]}}},"meta":{"type":"object","properties":{"resource":{"type":"string","enum":["charges","transactions","distributions"],"description":"Which underlying dataset to query. `charges`: one row per charge. `transactions`: one row per detected on-chain transfer — a charge paid in installments has more than one. `distributions`: one row per payout attempt to the merchant, one per `(token, network)` pair a charge settled across."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"rowCount":{"type":"integer","description":"Number of rows in `data`."},"truncated":{"type":"boolean","description":"`true` if more rows matched than `limit` allowed — `data` holds only the first `limit`."}},"required":["resource","environment","rowCount","truncated"]}},"required":["data","meta"]}}}},"400":{"description":"Validation error, or a filter/groupBy/metric value that fails its field type (`validation_error` / `invalid_field`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing `metrics:read` and the matching `metrics:{resource}:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The requested `environment` doesn't match this API key's own environment (`environment_mismatch`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited — max 60 requests/min on /v1/metrics/* (`rate_limited`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/recipients":{"post":{"tags":["Recipients"],"summary":"Register a trusted split recipient","description":"Registers an address as eligible to be *referenced* (by `recipientId`) inside a `splitRecipients` entry on `POST /v1/charges`. Idempotent — registering an address already known to your organization/environment updates its `label` (and revives it if previously revoked) instead of creating a duplicate. Does **not** make this address eligible to become an API key's own `payoutAddress` — that requires the separate `recipients:manage_payout` scope on `PATCH /v1/recipients/{id}`. Every call through this scope, including re-registering an address that already has `payout: true`, resets it back to `false` — `recipients:write` can never grant or preserve payout eligibility, only `recipients:manage_payout` can, and only by calling `PATCH` again afterward.","security":[{"apiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","description":"Address to register as a trusted split recipient for your organization — an EVM 0x... address or a TRON T... address."},"label":{"type":"string","minLength":1,"maxLength":64,"description":"Free-form label for your own bookkeeping (e.g. `\"supplier\"`) — never interpreted."}},"required":["address"]}}}},"responses":{"201":{"description":"Recipient registered (or revived/updated, if it already existed)","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `rc_...` — this, not the raw address, is what a charge's `splitRecipients[].recipientId` references."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"address":{"type":"string"},"label":{"type":["string","null"]},"payout":{"type":"boolean","description":"Whether this recipient is eligible to be used as an API key's `payoutAddress` (in addition to being referenceable in a split, which every non-revoked recipient already is). Set via `PATCH /v1/recipients/{id}` — requires the `recipients:manage_payout` scope, deliberately separate from `recipients:write`."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","environment","address","label","payout","createdAt"]}}}},"400":{"description":"Validation error (`validation_error`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `recipients:write` scope, or holds it together with `charges:split_write` (never allowed together) (`insufficient_scope` / `conflicting_api_key_scopes`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Recipients"],"summary":"List registered recipients","description":"Lists your tenant's non-revoked recipients for the calling key's environment, newest first, with cursor-based pagination.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"number","minimum":1,"maximum":100,"default":20,"description":"Max items to return per page (1–100, default 20)."},"required":false,"description":"Max items to return per page (1–100, default 20).","name":"limit","in":"query"},{"schema":{"type":"string","maxLength":500,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change."},"required":false,"description":"Opaque — pass the previous response's `nextCursor` verbatim to fetch the next page. Never construct or parse this value yourself; its shape is not part of the public contract and may change.","name":"cursor","in":"query"}],"responses":{"200":{"description":"Paginated recipients","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `rc_...` — this, not the raw address, is what a charge's `splitRecipients[].recipientId` references."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"address":{"type":"string"},"label":{"type":["string","null"]},"payout":{"type":"boolean","description":"Whether this recipient is eligible to be used as an API key's `payoutAddress` (in addition to being referenceable in a split, which every non-revoked recipient already is). Set via `PATCH /v1/recipients/{id}` — requires the `recipients:manage_payout` scope, deliberately separate from `recipients:write`."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","environment","address","label","payout","createdAt"]}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` to fetch the next page. `null` when there are no more results."},"hasMore":{"type":"boolean"}},"required":["data","nextCursor","hasMore"]}}}},"400":{"description":"Malformed pagination cursor (`invalid_cursor`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `recipients:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/recipients/{id}/transferability":{"get":{"tags":["Recipients"],"summary":"Check whether token issuers restrict a recipient's transfers","description":"Reports, for every token/network pair currently enabled for payments in the calling key's environment (EVM pairs only — empty for a TRON address today), what the token issuer's own contract says about this recipient's address: `receive` (can it be credited) and `send` (can it move funds out). The only source is each token contract's own on-chain view function (USDC `isBlacklisted`, USDT `isBlackListed`/`isBlocked`) — never a Klappay list or a third-party sanctions source. `POST /v1/charges` rejects a pair with `422 recipient_transfer_restricted` only when `receive` is `restricted` for an address the charge pays; `send: restricted` never blocks a charge (USDT only checks the sender, so the address can still be paid), but funds it receives may stay stuck until the issuer lifts the restriction. `unknown` means no answer (no such function on that network, or the read failed or took over 1 second) and never blocks anything. Results are cached for up to 5 minutes. Rate limited to 20 requests/min per tenant (shared across every API key on your account), on top of the general per-tenant limit.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Per-pair receive/send restriction status for this recipient","content":{"application/json":{"schema":{"type":"object","properties":{"recipientId":{"type":"string","description":"The recipient this report is about, e.g. `rc_...`."},"address":{"type":"string","description":"The recipient's registered address."},"pairs":{"type":"array","items":{"type":"object","properties":{"token":{"type":"string","enum":["USDC","USDT"],"description":"Which stablecoin the payer will send. Availability depends on `network` and `environment`; check `GET /v1/networks` before creating a charge. Unconfigured or disabled pairs are rejected with `422 token_not_supported`. Base, Optimism, and Ethereum have USDC test deployments; no test USDT is configured. Both configured BNB contracts are Binance-Peg tokens with 18 decimals; the other configured deployments use 6. Binance-Peg tokens carry Binance custody/backing risk and must not be represented as direct Circle/Tether issuance. Resolve address and decimals together with `getTokenDeployment`; a catalog entry alone does not imply payment availability."},"network":{"type":"string","enum":["base","optimism","polygon","ethereum","arbitrum","avalanche","bnb","tron","arc"],"description":"The blockchain a charge/payment is on."},"receive":{"type":"string","enum":["allowed","restricted","unknown"],"description":"Whether this address can be credited with `token` on `network`. A charge is rejected with `422 recipient_transfer_restricted` only when this is `restricted` for one of its `acceptedPayments` pairs. USDT's contracts never check the recipient of a transfer, so `receive` is always `allowed` for a configured USDT pair — even when `send` is `restricted`. Same status semantics as `TransferRestrictionStatus`."},"send":{"type":"string","enum":["allowed","restricted","unknown"],"description":"Whether this address can move `token` out on `network` once it holds it. Klappay never blocks a charge on this — the address can still be paid — but funds it receives may stay stuck in it until the issuer lifts the restriction, which is worth surfacing to its owner. Same status semantics as `TransferRestrictionStatus`."}},"required":["token","network","receive","send"]},"description":"One entry per token/network pair currently enabled for payments in the calling key's environment that matches the address's family — EVM pairs for an `0x...` address. Empty for a TRON address today."}},"required":["recipientId","address","pairs"],"example":{"recipientId":"rc_abc123","address":"0x1111111111111111111111111111111111111111","pairs":[{"token":"USDC","network":"ethereum","receive":"restricted","send":"restricted"},{"token":"USDC","network":"base","receive":"allowed","send":"allowed"},{"token":"USDT","network":"ethereum","receive":"allowed","send":"restricted"},{"token":"USDT","network":"base","receive":"allowed","send":"unknown"}]}}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `recipients:read` scope (`insufficient_scope`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No non-revoked recipient with that id in your organization and the calling key's environment (`recipient_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited — max 20 requests/min on /v1/recipients/{id}/transferability (`rate_limited`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/recipients/{id}":{"patch":{"tags":["Recipients"],"summary":"Set a recipient's payout eligibility","description":"Sets whether this recipient is eligible to become an API key's own `payoutAddress` (in addition to being referenceable in a split, which any non-revoked recipient already is). Requires `recipients:manage_payout` — a scope 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 action (e.g. Dashboard's own internal key, after session + owner + email confirmation on its side) — never a merchant-facing or third-party integration key.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"payout":{"type":"boolean","description":"New payout-eligibility value for this recipient."}},"required":["payout"]}}}},"responses":{"200":{"description":"Recipient with updated payout eligibility","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Klappay-generated id, e.g. `rc_...` — this, not the raw address, is what a charge's `splitRecipients[].recipientId` references."},"environment":{"type":"string","enum":["live","test"],"description":"`live` or `test`, matching the `klap_live_.../klap_test_...` prefix of the API key that created or is scoped to this resource. `live` uses the selected network's mainnet with real funds; `test` uses its separately configured testnet. Token contracts and availability are resolved per network/environment; a missing test deployment never falls back to live."},"address":{"type":"string"},"label":{"type":["string","null"]},"payout":{"type":"boolean","description":"Whether this recipient is eligible to be used as an API key's `payoutAddress` (in addition to being referenceable in a split, which every non-revoked recipient already is). Set via `PATCH /v1/recipients/{id}` — requires the `recipients:manage_payout` scope, deliberately separate from `recipients:write`."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","environment","address","label","payout","createdAt"]}}}},"400":{"description":"Validation error (`validation_error`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `recipients:manage_payout` scope, or holds it together with `charges:split_write` (never allowed together) (`insufficient_scope` / `conflicting_api_key_scopes`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Recipient not found (`recipient_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Recipients"],"summary":"Revoke a recipient","description":"Revokes a recipient — it can no longer be referenced in a new `splitRecipients` entry. Past charges that already referenced it are unaffected. If this recipient currently has `payout: true`, revoking it requires the `recipients:manage_payout` scope, not just `recipients:write` — a `recipients:write`-only key gets `404 recipient_not_found` (identical to a genuinely nonexistent id, never a distinct error that would confirm a payout-eligible recipient exists). This is deliberate: `payoutAddress` is public (the first entry in the on-chain split), so if a plain `recipients:write` key could revoke a `payout: true` recipient, it could disable any other API key in the org whose `payoutAddress` matches it — revoking one *does* immediately reject every future request from a matching key (`401 payout_address_revoked`), so only a key that already holds the stricter scope can trigger that.","security":[{"apiKeyAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Recipient revoked"},"401":{"description":"Missing or invalid API key — including a key whose payoutAddress has since been revoked as a recipient (`missing_api_key` / `invalid_api_key` / `payout_address_revoked`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API key is missing the `recipients:write` scope, or holds it together with `charges:split_write` (never allowed together) (`insufficient_scope` / `conflicting_api_key_scopes`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Recipient not found, or exists but is payout-eligible and this key lacks `recipients:manage_payout` to revoke it (`recipient_not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"webhooks":{}}