Infrastructure & stability
Kaskade Payment Solution is a contract-first API. The endpoints, fields and webhook events on this page are the product — the settlement engine behind them is an implementation detail that you never touch. Your keys are Kaskade Payment Solution keys, your objects are Kaskade Payment Solution objects, and your webhooks are signed with your Kaskade Payment Solution secret.
That separation is deliberate. Today settlement runs on a third-party custody provider; as volume grows we move to self-custody on an MPC platform (e.g. Fireblocks-class infrastructure) with our own on-chain wallets, treasury and payout signing. None of that changes a single request you make.The base URL, authentication, payment object, statuses and webhook payloads are guaranteed stable across that migration.
Base URL & version- https://kaskade.com/api/v1 stays; breaking changes only ever ship under a new version prefix.
Authentication- Bearer keys and HMAC signing are unaffected by backend changes.
Object shapes- Existing fields are never removed or repurposed; we only add new optional fields.
Webhook events- Event names, payload shape and signature scheme stay constant.
Statuses- The payment status vocabulary is fixed (new states, if any, are additive).
Base URL & environments
All endpoints are served over HTTPS from one versioned base URL. Plain HTTP is rejected.
https://kaskade.com/api/v1Content-Typeheaderrequired- All request and response bodies are
application/json. Amountsnumberoptional- Fiat amounts are decimal USD. Crypto amounts are decimal in the coin's own unit.
Timestampsstringoptional- ISO-8601 in UTC, e.g. 2026-06-20T12:34:56.000Z.
IDsstringoptional- Opaque strings — never parse or assume a format.
Versioning & changelog
Two versions are live side by side, each pinned in the URL path: https://kaskade.com/api/v1 and https://kaskade.com/api/v2. V2 carries the newer capabilities — payouts with an explicit network-fee authorisation, customer deposit addresses, recipients and mass disbursements — and V1 keeps running unchanged; use both from the same key. Within a version we treat the following as backward-compatible and may ship them without a version bump:
- Adding new endpoints, optional request fields, or response fields.
- Adding new webhook event types or new enum values.
- Changing an endpoint's availability tag from Planned → Beta → Live.
Anything breaking (removing a field, changing a type) only ships under a new prefix, and the older version keeps running. One V1 operation has been superseded rather than changed: creating a payout (see Create a payout). Material changes are listed in the dashboard changelog.
Quick start
- Create an API key. In your dashboard, open API Keys and create a key. Copy the secret — shown only once.
- Create a payment. POST a USD price and the coin to pay in; you get a deposit address and the exact crypto amount.
- Collect payment. Show the address (or our hosted page) to your customer.
- Fulfil on webhook. Act when you receive a
payment.updatedevent with statusfinished.
curl -X POST https://kaskade.com/api/v1/payments \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"priceUsd": 50,
"payCurrency": "btc",
"orderId": "ORDER-123",
"orderDescription": "Premium plan"
}'Which endpoint should I use?
Two endpoints create a request for money. They differ in who shows the payment page.
POST /payments →
You build the checkout. Returns a deposit address and the exact coin amount, so you render the payment screen yourself.
POST /invoices →
We host the checkout. Returns a shareable url on kaskade.com — send it to your customer and we do the rest.
Both support the same two ways of saying how much, described in fiat mode vs coin mode below. Everything else — webhooks, statuses, settlement — is identical.
Pick a row
| You want | Call | Body |
|---|---|---|
| Hosted invoice — exactly 100 USDT on TRON | POST /invoices | { "payAmount": "100", "payCurrency": "usdttrc20" } |
| Hosted invoice — exactly 100 USDT on Ethereum | POST /invoices | { "payAmount": "100", "payCurrency": "usdterc20" } |
| Direct payment — exactly 0.005 BTC | POST /payments | { "payAmount": "0.005", "payCurrency": "btc" } |
| Direct payment — exactly 0.25 ETH | POST /payments | { "payAmount": "0.25", "payCurrency": "eth" } |
| Hosted invoice — $100, customer pays in crypto | POST /invoices | { "amountUsd": 100 } — add payCurrency to lock it to one coin |
| Direct payment — $50, customer pays in BTC | POST /payments | { "priceUsd": 50, "payCurrency": "btc" } |
POST /payment-links in this API. Payment Links are a dashboard feature for sharing a link without writing code. Over the API, the hosted equivalent is POST /invoices, which returns the same kind of shareable URL.Fiat mode vs coin mode
Every request is priced in one of two things. The amount field you send isthe mode — there is no pricingMode to set.
| Fiat-denominated | Coin-denominated | |
|---|---|---|
| Send | priceUsd (payments)amountUsd (invoices) | payAmount + payCurrency (both) |
| Example | { "amountUsd": 100, "payCurrency": "usdttrc20" } | { "payAmount": "100", "payCurrency": "usdttrc20" } |
| Priced in | Your base currency. Kaskade calculates the coin amount from the live rate. | The coin itself. You state the coin amount; no rate is involved. |
| Customer owes | Whatever 100 USD is worth in that coin at quote time — about 100 USDT, not exactly 100. | Exactly 100 USDT on TRON. Nothing added, nothing rounded. |
| Type | JSON number — 100 | Decimal string — "100" |
| Payable in | Any supported coin, unless you set payCurrency. | That coin and network only — see same-asset rule. |
| Response says | "pricingMode": "fiat" | "pricingMode": "crypto" |
priceUsd or payAmount on a payment; amountUsd or payAmount on an invoice. Sending both, or neither, is a validation_error — we will not guess which one you meant to charge.USDT: always name the network
USDT is one name over several unrelated blockchains. A ticker here always encodes asset + network, so there is no separate network field to send — and "usdt" on its own is not valid, because it does not say which chain the money should arrive on.
| You mean | payCurrency | Network |
|---|---|---|
| USDT on Tron (TRC-20) | usdttrc20 | Tron |
| USDT on Ethereum (ERC-20) | usdterc20 | Ethereum |
| USDT, network unspecified | usdt — rejected | — |
usdttrc20 and usdterc20 are different rails and are never interchangeable. A TRC-20 request is only ever settled by USDT arriving on Tron, and an ERC-20 request only by USDT arriving on Ethereum; USDT sent on the other chain does not pay it. Get the exact spelling of any ticker from GET /currenciesrather than guessing.
Authentication
Every request must be authenticated. Two schemes grant the same access — use whichever fits your stack. Both work on V1 and V2. Some endpoints also need a scope on the key.
- Bearer key — simplest; send your secret key in a header.
- Request signing (HMAC) — strongest; sign each request so the key is never transmitted.
Bearer keys
Pass your key (ks_live_…) in the Authorization header. x-api-key: ks_live_… is also accepted.
Authorization: Bearer ks_live_YOUR_API_KEYRequest signing (HMAC)
Sign each request with your key's client secret; the secret never leaves your server. Enable a signing secret on any key in the dashboard — your Client ID is the key's Public ID.
X-Kaskade-Clientstringrequired- Your Client ID (the key's Public ID).
X-Kaskade-Timestampstringrequired- ISO-8601 UTC time, within 5 minutes of server time (replay protection).
X-Kaskade-Signaturestringrequired- Base64 HMAC-SHA256 of the canonical string, keyed with your client secret.
Build the canonical string by concatenating six parts in order, with no separators:
BOM + METHOD + fullURL + clientId + timestamp + rawBodyBOMconstantrequired- The Unicode byte-order mark
. METHODstringrequired- Upper-case HTTP method, e.g.
POST. fullURLstringrequired- Full request URL as called, e.g.
https://kaskade.com/api/v1/payments. clientIdstringrequired- Same as the X-Kaskade-Client header.
timestampstringrequired- Same as the X-Kaskade-Timestamp header.
rawBodystringrequired- Exact JSON body; empty string
""for GET.
import crypto from "crypto";
const clientId = "YOUR_CLIENT_ID"; // = your key's Public ID
const clientSecret = "ks_sk_YOUR_CLIENT_SECRET"; // shown once when you enable signing
const method = "POST";
const url = "https://kaskade.com/api/v1/payments"; // the full URL, exactly as called
const ts = new Date().toISOString();
const body = JSON.stringify({ priceUsd: 50, payCurrency: "btc", orderId: "ORDER-123" });
// canonical = BOM + METHOD + URL + clientId + timestamp + body
const canonical = "\ufeff" + method + url + clientId + ts + body;
const signature = crypto.createHmac("sha256", clientSecret)
.update(canonical, "utf8").digest("base64");
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
"X-Kaskade-Client": clientId,
"X-Kaskade-Timestamp": ts,
"X-Kaskade-Signature": signature,
},
body,
});Signing test vector
Unit-test your signer against these fixed values before you send a real request. All of it is fake — the secret is not a credential, and the client id is not an account.
methodstringPOSTfull URLstringhttps://kaskade.com/api/v1/paymentsclientIdstringEXAMPLE-CLIENT-ID-0000-0000timestampstring2026-01-01T00:00:00.000ZrawBodystring{"priceUsd":50,"payCurrency":"usdttrc20","orderId":"ORDER-1"}clientSecretstringks_sk_0000000000000000000000000000000000000000000000
The canonical string begins with a byte-order mark (U+FEFF) and contains no separators. Encode it as UTF-8 — it is 154 bytes for this vector, which is the quickest way to check you have the BOM.
"POSThttps://kaskade.com/api/v1/paymentsEXAMPLE-CLIENT-ID-0000-00002026-01-01T00:00:00.000Z{"priceUsd":50,"payCurrency":"usdttrc20","orderId":"ORDER-1"}"gu8s/nNUiG7u1vLDWfgQ94C8e91Wy6gw42MI2EHlkPU=If your implementation produces this signature, it will authenticate. If it does not, the usual causes are a missing BOM, a re-serialized body (sign the exact bytes you send), or hex instead of base64.
Key scopes & access
Most endpoints need nothing beyond a valid key. The ones that move money in new ways also need a scope on that key. A new key has no scopes, and scopes are granted per key by Kaskade, not from your dashboard — ask support, naming the key by its prefix (ks_live_…). A missing scope answers 403 insufficient_scope.
payouts:writeV2- Quote and create payouts:
POST /v2/payouts/quote,POST /v2/payouts. conversions:readV1- Routes, quotes and reading conversions.
conversions:writeincludes it. conversions:writeV1- Create conversions (
POST /conversions). recipients:readV2- List and read recipients.
recipients:writeV2- Create recipients and add their addresses. Includes read.
disbursements:readV2- Read disbursements and mass batches.
disbursements:writeV2- Quote and create disbursements; create, validate and cancel mass batches. Includes read. Approving a batch over the API needs automatic API approval as well — see mass disbursements.
Every other endpoint — currencies, rates, fees, balance, payments, invoices, payout destinations, listing payouts, the fiat ramp, customer addresses and GET /v2/payouts/:id — works with any valid key. Some features are also switched on per account before any key can use them: conversions (409 conversion_disabled), customer addresses (403 customer_addresses_not_enabled), recipients (403 recipients_not_enabled) and disbursements (403 disbursements_not_enabled).
No IP allowlist. Kaskade does not restrict a key to source IP addresses today, and there is no setting for it. Protect a key the other ways: keep it server-side, use request signing so the secret never travels, use one key per service so you can revoke one without the others, and give a key only the scopes it needs.
Errors & status codes
Conventional HTTP status codes; errors carry a human-readable error message. Most also carry a stable code to branch on, and a refusal caused by one input names it in field so you do not have to guess which value we rejected. Both are additive — read error alone if that is all you need.
{
"error": "The invoice number is too long — 500 characters, and the most is 200.",
"field": "orderId",
"code": "validation_error"
}| Code | Meaning |
|---|---|
200 | OK. |
400 | Bad request — invalid params or below the coin's minimum. |
401 | Unauthorized — missing, invalid, or unsigned credentials. |
403 | Forbidden — the key lacks a scope, the feature is not switched on for your account, or the account cannot do this now (kyc_required, payouts_restricted, payments_disabled). Retrying will not help; the code says what to change. |
404 | Not found — resource doesn't exist or isn't yours. |
405 | Method not supported on this path (e.g. GET /v2/payouts — list through V1). |
409 | Conflict — idempotency key reused with a different body, or the request cannot proceed in the current state (a destination not approved yet, a quote already used, a fee above what you authorised). The code names which. |
429 | Too many requests — slow down (see rate limits). |
500 | Server error. |
502 | Upstream settlement error — safe to retry. |
503 | Temporarily unavailable — a dependency is down or a feature is paused (the fiat ramp, an idempotency store). Nothing was created; retry with backoff. |
Idempotency
To safely retry a POST without creating duplicates, send an Idempotency-Key header with a unique value (e.g. a UUID) per logical operation. We store the first response for 24 hours and replay it — byte for byte — for any retry with the same key and body. Replays carry Idempotent-Replay: true.
Idempotency-Key: 9f1c8a3e-1b2d-4c5f-8a90-abc123def456Supported on every POST: payments, invoices, payouts, payout destinations, conversions and both ramp endpoints, where it is optional — omit it and nothing changes. On every V2 write it is required; a V2 write without one answers 400 idempotency_key_required.
409 idempotency_conflicterror- The same key with a different body. Fix the key or the body — do not retry as-is.
409 idempotency_in_progresserror- The first attempt is still running. Retry shortly and you will get its response.
409 idempotency_recovery_requirederror- A previous attempt with this key never finished and its outcome is unknown. Check whether the object exists (e.g. list your payments) before retrying with a new key. We will not re-run it, because re-running could create a second object.
503 idempotency_unavailableerror- We could not honour the key, so nothing was created. Retry the identical request — it is safe.
If you cannot send the header, dedupe on your side using orderId, which we echo back on the payment.
Pagination
List endpoints return the most recent items (currently up to 100, newest first). Cursor pagination via limit and starting_after is on the roadmap. The shape below is our current intent, not a promise — do not build against it until it ships:
{
"data": [ /* … items … */ ],
"has_more": true,
"next_cursor": "cmq7c0aa0000abc"
}What you can rely on today: list responses return a plain array under a resource key (e.g. payments), newest first. That is the frozen V1 behaviour. A cursor envelope, if it ships, will be additive and will not change this response.
Cursor pagination exists today on GET /conversions (V1) and on the V2 lists. A V2 list answers { "data": [ … ], "hasMore": true }, newest first; pass limit and the id of the last item you received to get the next page. The parameter is starting_after on customer addresses and recipients, and startingAfter on disbursements.
Rate limits
Kaskade does not currently apply a documented per-key rate limit, and the API returns no X-RateLimit-* headers. There is no published quota to design against, and we would rather say that than publish a number we do not enforce.
Infrastructure in front of the API (our reverse proxy, and whatever sits in front of it) may still shed load under abuse or attack, so a well-behaved client should handle 429 and503 defensively — retry with exponential backoff and jitter — even though neither is part of a contract today.
A per-key limit with published headers may be introduced later. It will be announced in the changelog before it is enforced.
Currencies
Prices are set in USD and converted at a live rate. Customers can pay in any of the 7 coins across 4 networks below. Pass a coin's ticker as payCurrencywhen creating a payment; network-specific tickers select the chain. The live list is always GET /currencies.
List currencies
/currenciesLiveReturns the coins your account can accept, with display name and network. For a coin's live minimum payable amount, call Get a currency.
This is where payCurrency values come from — do not guess coin names.The code of each entry is the canonical Kaskade ticker, and it encodes the network as well as the asset: that is why USDT appears as usdttrc20 (Tron) and usdterc20 (Ethereum) rather than as a bare usdt. Every ticker you send to POST /payments or POST /invoices should be a code from this list.
Metadata is not support. GET /currencies/:code answers for any ticker, including coins Kaskade does not accept and a few legacy identifiers (matic, usdcmatic, usdtsol, dai) that resolve to a friendly name only. Getting a name and a network back is not proof that deposits are enabled. The authoritative signals are enabled and depositAvailable — check one of those, never the presence of metadata.
curl https://kaskade.com/api/v1/currencies \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"{
"currencies": [
{ "code": "btc", "name": "Bitcoin", "network": "bitcoin", "enabled": true },
{ "code": "usdttrc20", "name": "Tether (TRC-20)", "network": "tron", "enabled": true },
{ "code": "usdterc20", "name": "Tether (ERC-20)", "network": "ethereum", "enabled": true }
]
}Get a currency
/currencies/:codeLiveMetadata for a single coin, including the current minimum payable amount in USD (the same minimum enforced by Create a payment).
curl https://kaskade.com/api/v1/currencies/btc \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"{
"currency": { "code": "btc", "name": "Bitcoin", "network": "bitcoin", "minUsd": 18.92, "enabled": true }
}Rates
Get a live conversion estimate before creating a payment — useful to preview the crypto amount a customer will owe for a given USD price.
Estimate a rate
/rates/estimateLiveQuotes how much of currency equals a given USD amount at the current rate.
amountnumberrequired- USD amount to convert.
currencystringrequired- Target coin ticker (e.g. btc).
curl https://kaskade.com/api/v1/rates/estimate?amount=50¤cy=btc \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"{
"amountUsd": 50,
"currency": "btc",
"estimatedAmount": 0.00052471,
"rate": 95291.41
}Fees
Kaskade Payment Solution charges a transparent percentage on successful payments. Your effective rate and fee mode (absorb vs. add-on) are configured per account in the dashboard.
Get fees
/feesLiveReturns the fee schedule that applies to your account.
curl https://kaskade.com/api/v1/fees \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"{
"platformFeePercent": 1,
"merchantExtraPercent": 0,
"feeMode": "absorb",
"currency": "USD"
}The payment object
A payment is a single request for funds. It is priced either in your base currency — the coin amount is then quoted at the live rate — or in the coin itself, in which case the payer owes exactly the amount you named. pricingMode says which.
idstring- Kaskade Payment Solution payment id — use it to look the payment up later.
statusstring- Current state. See statuses.
pricingModestringfiat— you named a price and the coin amount was quoted from it.crypto— you named the coin amount itself.requestedAmountstring | null- In coin mode, the exact amount you asked for, as a decimal string. Null in fiat mode.
requestedCurrencystring | null- In coin mode, the coin the request was priced in. Null in fiat mode.
amountDueExactstring | null- The amount due as a decimal string, in both modes. Same value as payAmount; use this behind a copy button.
priceUsdnumber | null- Amount charged in your base currency. Null in coin mode, where you named no fiat price.
payCurrencystring- Coin the customer pays in.
payAmountnumber- Exact crypto amount due.
payAddressstring- Deposit address to show the customer.
payExtraIdstring | null- Destination tag / memo. Non-null only on chains that route deposits by one; null on every rail live today. If it is ever set you must show it to the customer alongside the address — a payment sent without it cannot be credited and the funds are not recoverable by us.
payInHashstring | null- On-chain deposit transaction hash, once detected.
actuallyPaidnumber- Amount received so far (handles underpayment).
orderIdstring | null- Your reference, echoed back.
customerIdstring | null- Set when the payment came in through a customer deposit address: your id for that customer. Null on every other payment.
customerAddressIdstring | null- The customer deposit address the payment arrived at. Null on every other payment.
publicAddressIdstring | null- Set when the payment came in through your public address. Null on every other payment.
expiresAtstring | null- When the payment request expires. In fiat mode this is also when the quoted rate lapses; in coin mode there is no rate to lapse — the amount does not move with the market.
createdAtstring- ISO-8601 creation time.
Payment statuses
| Status | Meaning |
|---|---|
waiting | Created; awaiting the customer's funds. |
confirming | Deposit seen on-chain; awaiting confirmations. |
confirmed | Confirmed on-chain; settling. |
sending | Funds routing to settlement. |
partially_paid | Underpaid; remainder still pending. A fiat-priced payment allows a small shortfall (dust and wallet rounding) before it counts as paid; a coin-priced one does not — the exact amount is the threshold. |
finished | Settled. Fulfil the order here. |
failed | Payment failed. |
refunded | Payment refunded. |
expired | No funds before the quote expired. |
Partial payments & returns
With partial payments enabled on your account (ask us to switch it on), a payment can be paid in several transfers, cancelled, and returned to the wallet that sent it. status keeps the meaning above for every client; the precise picture is an optional p12 object on GET /v1/payments/:id and on every payment.updated event. Accounts without partial payments never receive it. Amounts are exact decimal strings in the payment's coin.
paymentStatestringWAITING,PARTIALLY_PAID,PAID,OVERPAID,EXPIRED,EXPIRED_PARTIAL(closed part-paid; the funds stay yours to settle),CANCELLED,CANCELLED_PARTIAL.requestedAmount / receivedAmount / remainingAmountstring- What was asked for, the exact sum of every deposit, and what is still due. Requested and remaining are null for an open amount.
completionPercentstring | null- 0.00–100.00, capped at 100.
excessAmountstring- Received beyond the requested amount.
unacceptedAmountstring- Received but not yet yours to spend: overpayment excess held under your overpayment policy, or a partial below your partial minimum.
excessDispositionstring | nullACCEPTED,HELD,RETURN_REQUESTED,RETURNED— what happened to an overpayment.meetsPartialMinimumboolean | null- Whether what arrived reaches your partial minimum; null with no minimum set.
partialDispositionstring | nullHELDbelow your minimum,RELEASEDonce it was reached,ACCEPTEDif you kept it anyway,RETURN_REQUESTED/RETURNEDwhen the payment closed below it. V1.5.0.depositsarray- Each transfer: txHash, amount, senderAddress, receivedAt.
multipleSendersboolean- More than one sending wallet. Returns are made per deposit, each to its own sender.
cancelledAtstring | null- When the payment was cancelled.
returnState / returnsstring / arrayNONE,HOLD_FOR_REVIEW,RETURN_PENDING,PARTIALLY_RETURNED,RETURNED,RETURN_FAILED; each return carries kind, state, amount, who paid the network fee, txHash and times.eventstring- Webhooks only. What changed, inside the same
payment.updatedevent:payment.partially_paid,payment.paid,payment.overpaid,payment.cancelled,payment.expired,payment.return_pending,payment.return_completed,payment.return_failed, orpayment.updated.
{
"paymentState": "PARTIALLY_PAID",
"requestedAmount": "100",
"receivedAmount": "30",
"remainingAmount": "70",
"completionPercent": "30.00",
"excessAmount": "0",
"unacceptedAmount": "0",
"excessDisposition": null,
"meetsPartialMinimum": null,
"partialDisposition": null,
"depositCount": 1,
"deposits": [{ "txHash": "a1b2…", "amount": "30", "senderAddress": "TQ…", "receivedAt": "2026-10-02T09:00:00.000Z" }],
"multipleSenders": false,
"cancelledAt": null,
"returnState": "NONE",
"returns": [],
"event": "payment.partially_paid"
}- Fulfil on
paymentState: PAIDorOVERPAID— the same momentstatusreadsfinished. - The payer is asked for the rest. The hosted checkout shows what arrived and the exact amount still due, and its QR code carries that remainder.
- Partial minimum. If you set one, a partial below it is held out of your available balance until it is reached (then released automatically). If the payment expires or is cancelled first, a return to the payer is requested and reviewed by us; no platform fee is kept on money that goes back. You can accept a held partial instead — the platform fee then applies.
- Invoices collect several payments. A coin-priced invoice counts every payment raised for it: if the first payment window closes part-paid, opening the invoice again asks for exactly what is still due. The invoice reads
partially_paidwithamountReceived/amountRemaining, andinvoice.updatedfires on each change. - Returns go only to the wallet a deposit came from, never to an address you supply. A refund of accepted funds is paid in full by you (the platform fee is not refunded); a return of unaccepted funds carries no platform fee.
Create a payment
/paymentsLiveCreates a payment and returns a deposit address and the exact crypto amount.
Two ways to say what the payment is for. Send exactly one.
priceUsd— price it in your base currency. Kaskade quotes the coin amount at the live rate. This is the original behaviour and nothing about it has changed.payAmount— price it in the coin itself, as a decimal string. The payer owes exactly that amount and no exchange rate is involved in producing it.
Sending both, or neither, is a validation_error.
priceUsdnumberoptional- Fiat mode. Amount to charge in your base currency; the coin amount is quoted from it. Required unless payAmount is given.
payAmountstringoptional- Coin mode. Exact amount of payCurrency as a decimal string, e.g. "0.005". Must be a string, not a number. Required unless priceUsd is given.
payCurrencystringrequired- Coin to pay in (btc, eth, usdttrc20, usdc…). The network is part of the identifier.
orderIdstringoptional- Your order reference (≤ 200 chars). Echoed back and in webhooks.
orderDescriptionstringoptional- Human-readable description (≤ 500 chars).
curl -X POST https://kaskade.com/api/v1/payments \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"priceUsd": 50,
"payCurrency": "btc",
"orderId": "ORDER-123",
"orderDescription": "Premium plan"
}'{
"payment": {
"id": "cmq7c1ab0000xyz",
"npPaymentId": "4912345678",
"priceUsd": 50,
"payCurrency": "btc",
"payAmount": 0.00052471,
"payAddress": "bc1qexampleaddressxxxxxxxxxxxxxxxxxxxx",
"payExtraId": null,
"status": "waiting",
"expiresAt": "2026-06-20T12:34:56.000Z",
"pricingMode": "fiat",
"requestedAmount": null,
"requestedCurrency": null,
"amountDueExact": "0.00052471"
}
}Pricing in the coin itself
Ask for a coin amount and that is exactly what the payer owes. Kaskade adds nothing to it: the platform fee comes out of your settlement, not out of the customer’s amount, whatever fee mode your account uses.
{
"payAmount": "100",
"payCurrency": "usdttrc20",
"orderId": "ORDER-1001"
}{
"payAmount": "100",
"payCurrency": "usdterc20",
"orderId": "ORDER-1002"
}{
"payAmount": "0.005",
"payCurrency": "btc",
"orderId": "ORDER-1003"
}{
"payAmount": "0.25",
"payCurrency": "eth",
"orderId": "ORDER-1004"
}The same request as a copy-paste call — 0.005 BTC, exactly:
curl -X POST https://kaskade.com/api/v1/payments \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payAmount": "0.005",
"payCurrency": "btc",
"orderId": "ORDER-1003"
}'The response carries the id, the payCurrency, the exact amountDueExact, the payAddress to send to, the status and the expiresAt deadline:
{
"payment": {
"id": "cmq7c1ab0000xyz",
"npPaymentId": "fb_9f2c1d4e5a6b7c8d",
"pricingMode": "crypto",
"priceUsd": null,
"requestedAmount": "100",
"requestedCurrency": "usdttrc20",
"payCurrency": "usdttrc20",
"payAmount": 100,
"amountDueExact": "100",
"payAddress": "TExampleAddressxxxxxxxxxxxxxxxxxxxxx",
"payExtraId": null,
"status": "waiting",
"expiresAt": "2026-06-20T12:34:56.000Z"
}
}The network is part of the coin identifier
USDT exists on more than one network, so "usdt" on its own names nothing and is refused. Use the identifier from GET /currencies — today usdttrc20 (Tron) and usdterc20 (Ethereum).
{
"payAmount": "100",
"payCurrency": "usdt"
}
→ 400
{
"error": "USDT cannot be used to price a request in the coin itself. Price this one in your base currency instead, or choose another coin from GET /v1/currencies.",
"field": "payCurrency",
"code": "unsupported_asset"
}What a coin-denominated payment does and does not do
- The payer owes the exact amount. 100 USDT means 100 USDT. Nothing is added on top and nothing is rounded off.
- It settles exactly. The 1% shortfall tolerance that applies to a rate-quoted payment does not apply here — 99.999999 of 100 USDT stays
partially_paid. Fiat-priced payments are unchanged. - It is payable only in the coin it is priced in. Kaskade does not convert between coins or across networks — see below.
- Amounts are decimal strings, not numbers. A JSON number cannot carry a coin amount intact, so
payAmountmust be quoted. - Precision is the asset’s own, capped at 8 places in this release. USDT takes 6, BTC takes 8, and ETH — which carries 18 on chain — is accepted to 8 here. Anything finer is refused rather than silently rounded.
expiresAtis when the request lapses, not when a rate lapses: an exact coin amount has no rate to expire.
{
"payAmount": "0.0000001",
"payCurrency": "usdttrc20"
}
→ 400
{
"error": "USDTTRC20 amounts are accepted to at most 6 decimal places here, which is the chain's own precision.",
"field": "payAmount",
"code": "validation_error"
}Same asset, same network — this release converts nothing
A coin-denominated request is settled by that asset on that network and by nothing else. Kaskade has no conversion engine in V1.1, so there is no rate at which a different coin could be accepted.
| Priced as | Payable with | Not payable with |
|---|---|---|
100 usdttrc20 | 100 USDT on Tron | USDT on Ethereum or any other network; any other coin |
100 usdterc20 | 100 USDT on Ethereum | USDT on Tron or any other network; any other coin |
0.005 btc | 0.005 BTC | USDT, ETH or anything else |
USDT that arrives on the wrong network is not lost — it is held in custody and an operator resolves it — but it does not settle the request, and the payment stays unpaid. Because the two USDT rails share a symbol and differ only in the ticker you send, this is the mistake worth guarding in your own integration: pass through the exact payCurrency you asked for rather than a display symbol.
Cross-asset and cross-network conversion is not part of V1.1. If you need a customer to pay in a coin of their choosing, price the request in fiat instead — that is what fiat mode is for.
List payments
/paymentsLiveReturns your most recent payments (up to 100), newest first.
curl https://kaskade.com/api/v1/payments \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"{
"payments": [
{ "id": "cmq7c1ab0000xyz", "status": "finished", "priceAmount": 50, "payCurrency": "btc", "payInHash": "f4a1…", "createdAt": "2026-06-20T12:00:00.000Z" },
{ "id": "cmq7c0aa0000abc", "status": "waiting", "priceAmount": 25, "payCurrency": "usdttrc20", "createdAt": "2026-06-20T11:40:00.000Z" }
]
}Retrieve a payment
/payments/:idLiveFetches one payment by id and refreshes its status live before returning, so it always reflects the latest on-chain state.
idstringrequired- The payment id from Create a payment.
curl https://kaskade.com/api/v1/payments/cmq7c1ab0000xyz \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"{
"payment": {
"id": "cmq7c1ab0000xyz",
"status": "finished",
"priceAmount": 50,
"payCurrency": "btc",
"payAmount": 0.00052471,
"actuallyPaid": 0.00052471,
"payAddress": "bc1qexampleaddressxxxxxxxxxxxxxxxxxxxx",
"payInHash": "f4a1c9b2…",
"createdAt": "2026-06-20T12:00:00.000Z"
}
}The invoice object Live
An invoice is a hosted, shareable request for payment. It produces a checkout URL your customer opens to pay in any supported coin, and emits the same payment.updated webhooks under the hood.
idstring- Invoice id.
statusstring- open | partially_paid | paid | expired | void.
partially_paid(V1.6.0): part of a coin-priced invoice has arrived — see partial payments. amountReceived / amountRemainingstring | null- V1.6.0. Exact amount received across every payment raised for the invoice, and what is still due, in its coin. Null when not tracked (USD-priced invoices, accounts without partial payments).
pricingModestring- fiat | crypto. Which of the two amount fields carries the figure.
amountUsdnumber | null- Fiat amount requested, in your base currency. Null on a coin-denominated invoice.
requestedAmountstring | null- Coin amount requested, as a decimal string. Null in fiat mode.
requestedCurrencystring | null- Coin the invoice is priced in, and the only coin it can be paid in. Null in fiat mode.
urlstring- Hosted checkout URL to share with the customer.
orderIdstring | null- Your reference.
customerEmailstring | null- Optional; the customer is emailed when the payment is received, part-paid, overpaid or refunded.
paymentIdstring | null- Linked payment once the customer pays.
expiresAtstring | null- When the invoice expires.
createdAtstring- ISO-8601 creation time.
Create an invoice
/invoicesLiveCreates a hosted invoice and returns a shareable checkout URL. The invoice.url in the response is a page on kaskade.com that we render — give it to your customer and they pay there. Use this endpoint whenever you would rather not build a payment screen yourself; use POST /payments when you want the raw address and amount instead.
Price the invoice one of two ways. Send exactly one amount.
- Fiat-denominated —
amountUsd. The invoice is priced in your base currency, and Kaskade calculates the cryptocurrency amount due at the live rate when the customer picks a coin.{ "amountUsd": 100, "payCurrency": "usdttrc20" }means $100 worth of USDT on Tron — roughly 100 USDT, not exactly 100. AddpayCurrencyto lock the invoice to one coin, or omit it to let the customer choose any supported coin. - Coin-denominated —
payAmount+payCurrency. The invoice is priced directly in the coin, as a decimal string. No fiat conversion determines what the customer owes.{ "payAmount": "100", "payCurrency": "usdttrc20" }means exactly 100 USDT on TRON, and it is payable in that asset on that network only.
Sending both amounts, or neither, is a validation_error. See fiat mode vs coin mode for the full comparison.
amountUsdnumberoptional- Fiat mode. Amount to request in your base currency. Required unless payAmount is given.
payAmountstringoptional- Coin mode. Exact amount of payCurrency as a decimal string. Required unless amountUsd is given.
payCurrencystringoptional- Fiat mode: optional, locks the invoice to one coin. Coin mode: REQUIRED — it is what the invoice is priced in, and the only coin it can be paid in.
orderIdstringoptional- Your order reference.
orderDescriptionstringoptional- Shown on the checkout page.
customerEmailstringoptional- Emails the customer when the payment is received, part-paid, overpaid or refunded.
curl -X POST https://kaskade.com/api/v1/invoices \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "amountUsd": 120, "orderId": "INV-2026-014", "customerEmail": "buyer@example.com" }'{
"invoice": {
"id": "inv_8f2a1c",
"status": "open",
"amountUsd": 120,
"url": "https://kaskade.com/i/8f2a1c",
"orderId": "INV-2026-014",
"customerEmail": "buyer@example.com",
"expiresAt": "2026-06-21T12:00:00.000Z",
"createdAt": "2026-06-20T12:00:00.000Z",
"pricingMode": "fiat",
"requestedAmount": null,
"requestedCurrency": null
}
}A hosted invoice priced in the coin
The URL works the same way — share it and your customer pays the exact amount, in that coin only. This is the shortest route to a coin-denominated payment page over the API.
Exactly 100 USDT on TRON (TRC-20):
curl -X POST https://kaskade.com/api/v1/invoices \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payAmount": "100",
"payCurrency": "usdttrc20",
"orderId": "INV-1001",
"orderDescription": "Order #1001",
"customerEmail": "buyer@example.com"
}'{
"invoice": {
"id": "inv_8f2a1d",
"status": "open",
"pricingMode": "crypto",
"amountUsd": null,
"requestedAmount": "100",
"requestedCurrency": "usdttrc20",
"payCurrency": "usdttrc20",
"url": "https://kaskade.com/i/8f2a1d",
"orderId": "INV-1001",
"customerEmail": "buyer@example.com",
"expiresAt": "2026-06-21T12:00:00.000Z",
"createdAt": "2026-06-20T12:00:00.000Z"
}
}Exactly 100 USDT on Ethereum (ERC-20) — same call, one different ticker. This is a different blockchain, not a variant of the one above:
curl -X POST https://kaskade.com/api/v1/invoices \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payAmount": "100",
"payCurrency": "usdterc20",
"orderId": "INV-1002"
}'{
"invoice": {
"id": "inv_8f2a1e",
"status": "open",
"pricingMode": "crypto",
"amountUsd": null,
"requestedAmount": "100",
"requestedCurrency": "usdterc20",
"payCurrency": "usdterc20",
"url": "https://kaskade.com/i/8f2a1e",
"orderId": "INV-1002",
"customerEmail": null,
"expiresAt": "2026-06-21T12:00:00.000Z",
"createdAt": "2026-06-20T12:00:00.000Z"
}
}The same shape works for any coin with a recorded on-chain precision:
{
"payAmount": "0.005",
"payCurrency": "btc",
"orderId": "INV-1003"
}{
"payAmount": "0.25",
"payCurrency": "eth",
"orderId": "INV-1004"
} usdttrc20 invoice is not settled by USDT sent on Ethereum, and a usdterc20invoice is not settled by USDT sent on Tron. See the same-asset rule. Get exact tickers from GET /currencies; "usdt" on its own is rejected.List invoices
/invoicesLiveReturns your invoices, newest first.
curl https://kaskade.com/api/v1/invoices \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"Retrieve an invoice
/invoices/:idLiveFetch one invoice; its status reconciles from the linked payment before returning.
curl https://kaskade.com/api/v1/invoices/inv_8f2a1c \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"Get balances
/balanceLiveReturns your available balance per coin.
curl https://kaskade.com/api/v1/balance \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"{
"balances": {
"btc": 0.01340000,
"usdttrc20": 250.50
}
}Per-customer deposit addresses
Kaskade has no /wallets/addresses endpoint. An earlier version of this page published one as planned; it was never implemented, and it is not part of V1.
A payment gets its own deposit address. For a permanent address per customer — the same address every time, each transfer becoming its own payment — use V2 customer deposit addresses.
The payout object Live
A payout sends crypto from your balance to a destination you have already registered. Create one with Payout API v2; list and read them here. Creating one is a request, not a transfer: it is reviewed and released before anything reaches the chain, so treat status as the only statement about where the money actually is.
idstring- Payout id.
statusstring- pending | approved | submitted | paid | rejected | failed. See below.
currencystring- Coin/network sent.
amountnumber- Crypto amount sent.
addressstring- The address the funds are sent to, resolved at request time. Present even when destinationId is null, which is how you see where a settlement-wallet payout actually went.
destinationIdstring | null- The registered destination used, if any.
destinationstring | null- That destination's label.
txHashstring | null- On-chain transaction hash once broadcast.
errorstring | null- Why it did not proceed, in terms you can act on. Null unless something went wrong.
referencestring | null- Free text you set when requesting the payout, stored and echoed back unchanged. Kaskade never parses it, and it is not an idempotency key — use Idempotency-Key for that.
createdAtstring- ISO-8601 creation time.
paidAtstring | null- ISO-8601 completion time. Set only once status is paid.
Payout status
The normal path is pending → approved → submitted → paid. A payout can leave that path at any point for rejected or failed.
pendingin progress- The request has been accepted and is waiting on the next step. The amount is already reserved against your balance.
approvedin progress- Cleared to go, but not yet handed to the network. Nothing is on chain.
submittedin progress- Handed to the execution workflow and on its way to the chain. Still not confirmed.
paidterminal — success- The only status that means money moved.
txHashandpaidAtare set. Fulfil against this and nothing earlier. rejectedterminal- Refused and will not proceed. The reserved amount returns to your available balance.
failedterminal- Execution failed. The reserved amount returns to your available balance; request again rather than retrying the same payout.
Terminal: paid, rejected and failed. A payout in any of those three will not change again, so you can stop polling it. Success is paid and only paid. There is no sent status — earlier revisions of this page listed one and no such value has ever been returned by the API.
Payout destinations
/payout-destinationsLive/payout-destinationsLiveWhere you are allowed to send funds: your own wallets, a supplier, an exchange account, cold storage. A payout names a destination by id — never a raw address — because an address has to be approved as a withdrawal destination before any transfer can reach it, and because an API key that could name any address would be a key that empties your balance.
Approval takes a person and is not instant. Watch whitelistStatus(pending → approved); payable is the same answer as a boolean. Register destinations before you need to pay them.
labelstringrequired- What to call it. Shown to whoever approves the address.
currencystringrequired- Coin/network, e.g. usdttrc20. Echoed back as currency on the destination.
addressstringrequired- Destination address.
tagstringoptional- Memo / destination tag. Required on the chains that route by one.
curl -X POST https://kaskade.com/api/v1/payout-destinations \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "label": "Acme Supplies", "currency": "usdttrc20", "address": "TXdest…" }'{
"destination": {
"id": "pd_7a2f10",
"label": "Acme Supplies",
"currency": "usdttrc20",
"address": "TXdest…",
"tag": null,
"whitelistStatus": "pending",
"payable": false,
"createdAt": "2026-08-19T12:00:00.000Z"
}
}Create a payout
/payoutsLive409 fee_approval_required, and nothing is reserved. Quote with POST /v2/payouts/quote, then create with POST /v2/payouts. Listing and reading payouts through V1 are unchanged.Withdraws from your available balance to one of your approved destinations. OmitdestinationId to send to your settlement wallet for that coin.
Creating a payout moves nothing. It is a request: we release it, and the custody policy asks a person again after that. Follow it with the payout.updated webhook or by polling the payout — only paid means it is on chain, and failed means the amount is back in your available balance.
currencystringrequired- Coin/network to send.
amountnumberrequired- Crypto amount to send.
destinationIdstringoptional- An approved destination. Defaults to your settlement wallet for the coin.
referencestringoptional- Free text for your own reconciliation (max 500 chars). Not an idempotency key.
fee_approval_required409- Always, on this V1 route: the network fee has to be authorised, which needs Payout API v2.
destination_pending409- The destination is not approved yet.
destination_deleted409- The destination is no longer registered for withdrawals. Add it again.
destination_disabled409- The destination has been retired and is no longer available.
destination_not_authorized409- Nobody on the account has authorised payouts to this destination yet.
destination_unavailable409- The destination's approval could not be confirmed right now. Retry shortly.
insufficient_balance400- More than the available balance, which already excludes payouts in flight.
no_wallet409- No settlement wallet on file for that coin.
unknown_destination409- No such destination on this account.
direct_settlement409- This account settles straight to its own wallet, so there is no balance here.
payouts_restricted403- Withdrawals are restricted on this account.
kyc_required403- Identity verification is not approved yet.
curl -X POST https://kaskade.com/api/v1/payouts \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "currency": "usdttrc20", "amount": 100, "destinationId": "pd_7a2f10", "reference": "WD-771" }'{
"payout": {
"id": "po_5c1a9d",
"status": "pending",
"currency": "usdttrc20",
"amount": 100,
"address": "TXdest…",
"destinationId": "pd_7a2f10",
"destination": "Acme Supplies",
"txHash": null,
"error": null,
"reference": "WD-771",
"createdAt": "2026-08-19T12:00:00.000Z",
"paidAt": null
}
}List / retrieve payouts
/payoutsLive/payouts/:idLiveYour payouts, newest first. Filter with ?status= (pending,approved, submitted, paid, failed,rejected) and cap with ?limit= (max 100). Payouts created through V2 are listed here too; GET /v2/payouts/:id adds the network fee.
curl https://kaskade.com/api/v1/payouts?status=submitted \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"API v2 — overview & conventions
V2 is served from https://kaskade.com/api/v2 with the same keys and the same two authentication schemes as V1. It adds what V1's frozen shapes could not carry, and V1 keeps working beside it. The endpoint paths below are written from /api, so /v2/payouts/quote means https://kaskade.com/api/v2/payouts/quote.
Idempotency-Keyrequired- Every V2
POSTneeds one; without it the answer is400 idempotency_key_required. Same key and body within 24 hours replays the first response; a different body is409 idempotency_conflict. Amountsdecimal string- Exact decimal strings such as
"50"or"0.25", never JSON numbers. Listsenvelope{ "data": [ … ], "hasMore": bool }, newest first, with a cursor — see pagination.Network feesauthorised- Anything that sends money is quoted first and created with an explicit
feeAuthorization: { "accepted": true, "maxFee": "…" }. The recipient always receives the full amount; the network fee is yours, reserved with the payout, and never above yourmaxFee. Account switchesper account- Customer addresses, recipients and disbursements are switched on for your account by Kaskade on request. Until then they answer
403with a code naming the switch.
The full machine-readable contract is the V2 OpenAPI document; the Postman collection includes every V2 request.
Payouts with a fee quote Live
The way to send a payout. Two calls: quote (reads only — reserves and sends nothing), then create against that quote with your fee authorisation. The key needs the payouts:write scope. Destinations, statuses, release and the payout.updated webhook are exactly those of the payout object.
Quote a payout
/v2/payouts/quoteLivecurrencystringrequired- Coin/network to send, e.g. usdttrc20.
amountstringrequired- Exact decimal string — what the destination receives.
destinationIdstringoptional- An approved payout destination. Omit for your settlement wallet for that coin.
feeAssetstringoptional- Asset the network fee is settled in, where the rail allows a choice. Defaults to the payout asset.
curl -X POST https://kaskade.com/api/v2/payouts/quote \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "currency": "usdttrc20", "amount": "50", "destinationId": "pd_7a2f10" }'{
"quote": {
"quoteId": "pq_3b81c2",
"quoteVersion": 1,
"principal": "50",
"payoutAsset": "usdttrc20",
"destinationId": "pd_7a2f10",
"feeSettlementAsset": "usdttrc20",
"estimatedFee": "3",
"maximumAuthorizedFee": "3",
"feeReserve": "3",
"totalRequiredBalance": [{ "asset": "usdttrc20", "amount": "53" }],
"recipientReceives": "50",
"networkCost": { "asset": "trx", "amount": "10" },
"fx": { "rate": "0.3", "source": "market", "quotedAt": "2026-10-14T12:00:00.000Z" },
"railReadiness": { "payoutsEnabled": true, "status": "AUTO_PAYOUT_READY", "productionProven": true },
"createdAt": "2026-10-14T12:00:00.000Z",
"expiresAt": "2026-10-14T12:03:00.000Z"
}
}A quote is single use and expires after about 3 minutes. totalRequiredBalance is what must be spendable, per asset: the amount plus the fee reserve.
Create a payout
/v2/payoutsLiveSend the same terms as the quote, the quoteId, and the most fee you accept. Kaskade re-checks everything at this moment — the quote, the rail, the destination, your verification, the fee re-priced now, your spendable balance and your limits — and then reserves the amount and the fee together and uses up the quote, all in one step. If the fee is now above your maxFee, nothing is reserved and you get 409 FEE_REQUOTE_REQUIRED: quote again.
quoteIdstringrequired- From the quote.
currencystringrequired- Same as the quote.
amountstringrequired- Same as the quote, as a decimal string.
destinationIdstringoptional- Same as the quote.
feeAssetstringoptional- Same as the quote.
feeAuthorizationobjectrequired{ "accepted": true, "maxFee": "3.5" }— your explicit consent to a network fee up tomaxFee, in the fee asset.referencestringoptional- Free text for your reconciliation (max 500).
curl -X POST https://kaskade.com/api/v2/payouts \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Idempotency-Key: wd-2026-10-14-0001" \
-H "Content-Type: application/json" \
-d '{
"quoteId": "pq_3b81c2",
"currency": "usdttrc20",
"amount": "50",
"destinationId": "pd_7a2f10",
"feeAuthorization": { "accepted": true, "maxFee": "3.5" },
"reference": "WD-771"
}'The answer is 201 with the payout object plus quoteId and a networkFee object:
{
"paidBy": "merchant",
"settlementAsset": "usdttrc20",
"reserved": "3",
"actual": null,
"settled": false
}FEE_REQUOTE_REQUIRED409- The fee is now above maxFee, or the quote expired. Nothing was reserved. Quote again.
quote_consumed409- That quote was already used.
quote_mismatch409- The request differs from the quote (asset, amount, destination or fee asset).
quote_not_found404- No such quote on this account.
insufficient_balance409- Spendable balance is below amount + fee reserve.
invalid_fee_authorization400- maxFee is not a non-negative decimal string.
insufficient_scope403- The key lacks payouts:write.
idempotency_key_required400- Send an Idempotency-Key.
destination_* / kyc_required / payouts_restricted409 / 403- As on V1.
Retrieve a payout
/v2/payouts/:idLiveOne payout with its networkFee — the reserve, and the actual fee once settled. Any valid key. To list payouts use GET /payouts (V1); GET /v2/payouts answers 405 not_supported.
/v2/payoutsLivecurl https://kaskade.com/api/v2/payouts/po_5c1a9d \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"Customer deposit addresses Live
A permanent deposit address per customer of yours and per coin. Asking again for the same customer and coin always returns the same address, and every transfer to it becomes its own payment for exactly the amount received — made for balance top-ups and repeat customers. Each such payment carries your customerId and the customerAddressId on the payment object and in payment.updated.
- Switched on per account (
403 customer_addresses_not_enableduntil then). Not available to accounts that settle straight to their own wallet (409 direct_settlement). No scope needed. - Every deposit runs the normal payment path — screening, crediting, fees, settlement, returns. It is priced in the coin itself (
pricingMode: crypto), readsconfirmingwhile final and screened, thenfinished. There is no quote to expire; the deposit is valued in your base currency on arrival, for fees and reports. - Money that arrives is never refused. Switching the feature off stops new addresses, not deposits to existing ones.
- An address accepts its own coin only. Another asset sent to it is not credited automatically — contact support.
/v2/customer-addressesLivecustomerIdstringrequired- Your id for the customer: 1–100 of letters, digits and
. _ : @ -. payCurrencystringrequired- A coin your account accepts — see
GET /currencies. labelstringoptional- Up to 100 characters, shown in your dashboard.
curl -X POST https://kaskade.com/api/v2/customer-addresses \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Idempotency-Key: addr-user_8812-usdttrc20" \
-H "Content-Type: application/json" \
-d '{ "customerId": "user_8812", "payCurrency": "usdttrc20", "label": "Jane Doe" }'{
"customerAddress": {
"id": "cmh7a…",
"customerId": "user_8812",
"payCurrency": "usdttrc20",
"network": "Tron",
"address": "TXa9…f3",
"tag": null,
"label": "Jane Doe",
"status": "ACTIVE",
"createdAt": "2026-10-06T12:00:00.000Z"
}
}/v2/customer-addressesLiveNewest first. Query: customerId (one customer's addresses), limit(1–100, default 50), starting_after. Answers { "data": [ … ], "hasMore": false }.
/v2/customer-addresses/:idLiveOne of your addresses; another account's id is 404.
validation_error400- Bad
customerId,payCurrencyorlabel;fieldnames it. currency_not_available400- The coin is not available for your account.
customer_addresses_not_enabled403- Not switched on for your account.
payments_disabled403- Your account cannot accept payments right now.
direct_settlement409- Your account settles to its own wallet.
unavailable502 / 503- The address could not be created now. Retry with the same Idempotency-Key.
Public addresses Live
Your own permanent deposit address per coin, for anyone to pay to — publish it on a page, an invoice footer or a donation box. One is created automatically for every coin your account accepts (within minutes of the feature being switched on, or of you accepting a new coin); asking for a coin always returns the same address, and every transfer to it becomes its own payment for exactly the amount received, with the publicAddressId on the payment object and in payment.updated. The same rules as customer addressesapply (normal fees, screening and settlement), except that every account has them — no switch to request — plus:
- Who paid is not known. The payment carries the sending wallet only (
customerIdisnull). A refund can only go back to that wallet. - Transfers below the coin's payment minimum are not credited — a published address attracts dust. They stay in custody; contact support if one was real.
/v2/public-addressesLivepayCurrencystringrequired- A coin your account accepts — see
GET /currencies.
curl -X POST https://kaskade.com/api/v2/public-addresses \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Idempotency-Key: public-usdttrc20" \
-H "Content-Type: application/json" \
-d '{ "payCurrency": "usdttrc20" }'{
"publicAddress": {
"id": "cmh9p…",
"payCurrency": "usdttrc20",
"network": "Tron",
"address": "TQm4…8k",
"tag": null,
"status": "ACTIVE",
"createdAt": "2026-10-19T12:00:00.000Z"
}
}/v2/public-addressesLiveEvery coin's address, oldest first: { "data": [ … ] }. Errors are those of customer addresses.
Recipients Live
Your payees — customers, vendors, partners — one record each, keyed by your own reference, with the wallets each may be paid at. They are what disbursements pay. (Your own business wallets are payout destinations, not recipients.) Switched on per account (403 recipients_not_enabled); scopes recipients:read / recipients:write.
/v2/recipientsLivemerchantRecipientRefstringrequired- Your id for the payee: up to 128 of
A–Z a–z 0–9 . _ : -, unique per account. typestringrequiredINDIVIDUALorBUSINESS.namestringoptional- Stored encrypted.
emailstringoptional- Stored encrypted.
countrystringoptional- ISO 3166-1 alpha-2.
metadataobjectoptional- Up to 2 KB of JSON, stored verbatim, never interpreted.
201 creates it. The same reference with identical details answers 200 with the existing recipient — safe to repeat; with different details, 409 recipient_exists: a payee's identity is never overwritten.
/v2/recipientsLiveNewest first; limit (1–100, default 25), starting_after, and ref to find one by your reference. Addresses are not included in the list.
/v2/recipients/:idLiveThe recipient with its addresses.
/v2/recipients/:id/addressesLivecoinstringrequired- Coin and network, e.g.
usdttrc20. addressstringrequired- Validated like a payout destination: a valid address on that chain, and never a Kaskade deposit address.
ownershipTypestringrequiredMERCHANT_OWNED,CUSTOMER_OWNED,THIRD_PARTY,VENDORorUNKNOWN.tagstringoptional- Memo / destination tag on chains that route by one. If that wallet genuinely has none, send
"metadataRequirement": "metadata_not_required"— an omitted tag is never assumed unnecessary. labelstringoptional- Your name for the wallet.
{
"address": {
"id": "cma1…",
"recipientId": "cmr9…",
"coin": "usdttrc20",
"network": "tron",
"address": "TQ…",
"tag": null,
"ownershipType": "CUSTOMER_OWNED",
"label": "main wallet",
"status": "REVIEW_REQUIRED",
"rejectReason": null,
"screening": "NOT_SCREENED",
"payable": false,
"firstUsedAt": null,
"lastUsedAt": null,
"createdAt": "2026-10-14T12:00:00.000Z",
"updatedAt": "2026-10-14T12:00:00.000Z"
}
}REVIEW_REQUIREDwaiting- Saved; screening and approval pending. Cannot be paid.
APPROVEDpayable- Approved. Payable once
payableis true (its registration for sending has cleared). There is no cool-off period. REJECTEDfinal- Will not be paid;
rejectReasonsays why. ARCHIVEDfinal- Retired.
Addresses are reviewed by Kaskade. An account can instead choose, in the dashboard (Mass payouts → Settings, with an authenticator code), to approve its new recipient wallets automatically; every disbursement is still screened. The recipient.address.* webhooks tell you when a review ends.
Disbursements Live
A disbursement pays an approved recipient address from your balance. It is a payout underneath, with every payout control: quote, explicit fee authorisation, balance and fee reserve, screening, destination policy, risk tier and limits. Switched on per account (403 disbursements_not_enabled); scopes disbursements:read / disbursements:write. The recipient always receives the full amount; the network fee is yours.
/v2/disbursements/quoteLive{ "recipientAddressId", "amount", "feeAsset"? } → { quote }, the same shape as a payout quote plus recipientAddressId. Reads only; single use; about 3 minutes.
/v2/disbursementsLivequoteIdstringrequired- From the quote.
recipientAddressIdstringrequired- Same as the quote.
amountstringrequired- Same as the quote, as a decimal string.
feeAuthorizationobjectrequired{ "accepted": true, "maxFee": "…" }.referencestringoptional- Free text, max 500.
{
"disbursement": {
"id": "po_9d0e…",
"object": "disbursement",
"status": "PENDING",
"recipientAddressId": "cma1…",
"recipientId": "cmr9…",
"merchantRecipientRef": "cust-001",
"asset": "usdttrc20",
"amount": "25",
"recipientReceives": "25",
"address": "TQ…",
"reference": "PAYOUT-2026-10-14-1",
"txHash": null,
"error": null,
"quoteId": "pq_77c0…",
"batch": null,
"networkFee": { "paidBy": "merchant", "settlementAsset": "usdttrc20", "reserved": "3", "actual": null, "settled": false },
"createdAt": "2026-10-14T12:00:00.000Z",
"completedAt": null
}
}/v2/disbursements/:idLive/v2/disbursementsLiveNewest first; limit, startingAfter and batchId.
PENDINGin progress- Accepted; amount and fee reserved; waiting to be released.
BLOCKEDfinal- The risk policy refused it. It will not be sent.
SUBMITTEDin progress- Handed over for sending.
COMPLETEDfinal — success- On chain;
txHashis set. FAILEDfinal- Not sent. The reservation is released.
recipient_address_in_review409- The address is still being reviewed.
recipient_address_pending409- Approved, but its registration for sending has not cleared yet.
recipient_address_not_payable409- Rejected or archived, or the recipient is not active.
unknown_recipient_address404- No such address on this account.
FEE_REQUOTE_REQUIRED / quote_* / insufficient_balance409 / 404- As for V2 payouts.
Disbursements raise disbursement.* webhooks, not payout.updated.
Mass disbursements Live
Pay many wallets in one action: send the rows over the API (or upload a CSV in the dashboard). Each row becomes an ordinary disbursement — a batch adds no new way for money to leave, only a way to prepare and authorise many at once. Same switch and scopes as disbursements.
- Create.
POST /v2/disbursements/batcheswithrows. Nothing is reserved or sent. Each row is{ coin, address, amount, tag?, reference?, recipientRef?, recipientName?, recipientType? }. An address not yet on file is registered as a recipient address and goes to review. - Validate. Every row gets a verdict —
READY,WAITING_ADDRESS_REVIEW,REJECTED_ADDRESSorINVALID— and the batch shows, per asset, the total, the estimated and suggested maximum fee per row, what is needed and what is spendable. Large batches finish in the background (VALIDATING→VALIDATED);POST …/validatecontinues it. - Approve. By default a person approves in the dashboard with an authenticator code. The approval covers the reviewed
batchHash, a mode (send_ready_noworwait_for_all), a maximum network fee per row for each asset, and requires the whole batch to fit your spendable balance. It is valid for 72 hours. - Pay. Rows become disbursements one by one, each with its own reservation, screening and limits. A row whose fee exceeds the approved maximum, or that no longer fits the balance, fails on its own; the rest continue.
/v2/disbursements/batchesLive/v2/disbursements/batchesLive/v2/disbursements/batches/:idLiveThe batch and its rows; filter rows with status, page with afterRow and limit (1–500, default 100).
/v2/disbursements/batches/:id/validateLive/v2/disbursements/batches/:id/cancelLiveCancels every row not yet turned into a disbursement. Disbursements already created are not recalled.
/v2/disbursements/batches/:id/approveLiveAutomatic API approval only. Kaskade can allow your account to approve its own batches from your system; you then choose it in the dashboard (Mass payouts → Settings, with an authenticator code). The call takes { "batchHash", "mode", "feeCaps": { "usdttrc20": "2.5" } } and runs the same checks as a person's approval. Without both switches it answers 403 approval_requires_panel.
DRAFT → VALIDATING → VALIDATEDpreparing- Nothing reserved.
APPROVED → PROCESSINGpaying- Rows are becoming disbursements.
COMPLETED / PARTIALLY_COMPLETED / CANCELLEDfinal- Every row became a disbursement or ended. Follow each disbursement for the money.
approval_requires_panel403- API approval is not switched on; approve in the dashboard.
batch_changed409- The batch changed since the batchHash you approved. Review again.
fee_cap_required400- Give a maximum fee per row for every asset in the batch.
insufficient_balance409- The whole batch (amounts plus maximum fees) does not fit your spendable balance. Nothing was reserved.
Conversions
Convert a balance from one asset into another — either on request, or automatically when a customer pays in something other than your settlement asset. The rate, our fee, the trading cost and the network cost are always reported separately; none of them is folded into the rate.
Enabled per account. Conversion is switched on for a merchant, a pair and a network, with caps. Until it is enabled for yours these endpoints answer 409 conversion_disabled, and your payments settle in the asset they were paid in — exactly as they do today.
Scopes. Reading needs conversions:read; creating needs conversions:write, which includes read. Keys have neither until Kaskade grants them (see scopes), and read never implies write.
The fee. The default Kaskade conversion service fee is 1.00% of the gross proceeds, in the destination asset, rounded down to that asset's precision. Your commercial agreement may set another rate. It is charged only when a conversion actually executes.
Costs, and who pays them. The provider trading fee, the source network fee, the venue withdrawal fee and the return network fee are each allocated to you or to Kaskade by your effective commercial policy — they are not always yours. Every quote and every conversion includes an economics object with the service fee, each cost, its payer, the totals you and Kaskade bear, and your net amount. A conversion keeps the policy it was created under. Slippage is not a fee.
Minimums and availability. Minimums and maximums belong to each route (see Available routes). A conversion below its route's minimum is refused with 400 conversion_below_minimum, above its maximum with 400 conversion_above_maximum. Some conversions may also be unavailable below configured economic thresholds for your account; those are refused with 400 conversion_uneconomic at the quote and at creation, before anything is reserved. A larger amount may be accepted.
Statuses. PENDING, APPROVED_FOR_EXECUTION, the in-flight states, then one of COMPLETED, HELD (a person is looking at it; your balance stays reserved), FAILED or RECOVERED (the source came back to you). Only COMPLETED means the destination asset is yours.
What a payment settles into
Two separate choices, and they never infer each other:
- Accepted payment assets — which rails your customers may pay on. Set in Settings → Settlement. Empty means every rail available on your account, which is the default and what every existing account has. Hosted checkout shows exactly this set: one eligible rail is selected for the customer, several show a picker, and none shows an unavailable state rather than a fallback list.
- Settlement asset — what you want to hold afterwards. Unset means you keep whatever was paid.
Precedence, in order:
- the
settlementAsseton the invoice, if it has one; - otherwise your saved Settlement Preference;
- otherwise none — the payment settles in the asset it was paid in.
There is no fourth rule. An invalid settlementAsset is refused when you create the invoice; it is never quietly downgraded to your preference.
POST /v1/invoices
{
"amountUsd": 100,
"payCurrency": "sol",
"settlementAsset": "usdttrc20",
"settlementNetwork": "tron"
}{
"payCurrency": "sol",
"payNetwork": "Solana",
"settlementAsset": "usdttrc20",
"settlementNetwork": "tron",
"conversionRequired": true,
"conversionId": "cmu4…",
"conversionStatus": "APPROVED_FOR_EXECUTION"
}{
"payCurrency": "usdttrc20",
"payNetwork": "Tron",
"settlementAsset": "usdttrc20",
"settlementNetwork": "tron",
"conversionRequired": false,
"conversionId": null,
"conversionStatus": null
}Nothing about payCurrency changed. Send it and the payment is created on exactly that rail, with no selector and no conversion you did not ask for. Omit it and hosted checkout offers the customer your accepted assets. Both were true before V1.2.0 and are true after it. A rail is offered — and a fixed payCurrency accepted — only when it is supported, enabled, launched for your account, screenable by transaction monitoring and, if you configured them, one of your accepted assets; anything else is refused with 400 on the server, not just hidden. Avalanche (avax) was withdrawn in V1.2.0 for that reason: its deposits cannot be screened.
Available routes
/conversion-routesLiveConvert between supported asset/network pairs enabled for your account. Which pairs those are is decided by Kaskade's Production route matrix — not every asset can be converted into every other, and a pair is listed only once it is enabled for your account. Each route carries its own minimum and maximum. Narrow the list to one source with ?sourceAsset=btc&sourceNetwork=bitcoin.
{
"routes": [
{ "source": { "asset": "sol", "network": "solana" },
"destination": { "asset": "usdttrc20", "network": "tron" },
"minimumSourceAmount": "0.26", "maximumSourceAmount": null }
]
}Asset and network together are the identity: USDT on TRON and USDT on Ethereum are different assets, and converting between them is a route like any other. A pair that is not listed answers 409 conversion_route_unavailable.
Price a conversion
/conversion-quotesLiveA price, and nothing else: no conversion is created and no balance is reserved.
{
"sourceAsset": "sol",
"sourceNetwork": "solana",
"destinationAsset": "usdttrc20",
"destinationNetwork": "tron",
"amount": "0.25"
}{
"quote": {
"source": { "asset": "sol", "network": "solana", "amount": "0.25" },
"destination": { "asset": "usdttrc20", "network": "tron",
"estimatedGrossAmount": "25.750000", "estimatedNetAmount": "25.621250" },
"rate": "103",
"fees": { "conversionFeeBps": 50, "conversionFee": "0.128750",
"providerFee": "0", "networkCost": null },
"maxSlippageBps": 50,
"minimumSourceAmount": "0.26",
"maximumSourceAmount": null,
"expiresAt": "2026-09-16T12:00:30.000Z"
}
}networkCost is null at quote time: the settlement transfer is priced when it is made, and an estimate you might plan around would be a guess.
Create a conversion
/conversionsLivePrices the conversion, records it, and reserves the source balance so the same funds cannot back two conversions. Execution follows; watch status or the conversion.* webhooks.
Send an Idempotency-Key. The same key always returns the same conversion — never a second one — which is what makes a retry after a lost response safe.
curl -X POST https://kaskade.com/api/v1/conversions \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Idempotency-Key: conv-2026-09-16-001" \
-H "Content-Type: application/json" \
-d '{"sourceAsset":"sol","sourceNetwork":"solana","destinationAsset":"usdttrc20","destinationNetwork":"tron","amount":"0.25"}'List / retrieve conversions
/conversionsLive/conversions/:idLiveNewest first. Page with ?limit= (max 100) and ?starting_after=, and filter with ?status=.
curl https://kaskade.com/api/v1/conversions?limit=10 \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"Webhooks
Set a webhook URL in your dashboard; we POST an event every time a payment — or a payout, conversion, recipient address, disbursement or batch — changes status. This is the reliable way to know when to fulfil — never rely on the customer's browser returning.
{
"id": "evt_cmq7c1ab0000xyz_finished",
"event": "payment.updated",
"created": "2026-08-19T12:00:00.000Z",
"data": {
"payment": {
"id": "cmq7c1ab0000xyz",
"status": "finished",
"orderId": "ORDER-123",
"priceAmount": 50,
"payCurrency": "btc",
"payAmount": 0.001,
"payInHash": "f4a1c9b2…",
"pricingMode": "fiat",
"requestedAmount": null,
"requestedCurrency": null,
"amountDueExact": "0.001"
}
}
}Telling the two pricing modes apart. A coin-denominated payment carries pricingMode: "crypto" with requestedAmount and requestedCurrency set — so "100 USDT-TRON was requested and 100 USDT-TRON arrived" is distinguishable from "$100 was requested and roughly that much USDT arrived". In coin mode priceAmount is Kaskade's fiat reference valuation at creation, recorded for your reports; it is not what anyone was asked for. These fields appear on events raised from V1.1.0 onward; a redelivered older event replays its original bytes unchanged.
Every event uses this envelope: id, event, created, and the object nested under data. Read data.payment, data.invoice, data.payout, data.conversion, data.recipientAddress (with data.recipient), data.disbursement or data.batch — never a top-level resource key. id is stable per object and transition, which makes it the natural key for de-duplicating retries.
Event types
| Event | When | |
|---|---|---|
payment.updated | A payment changes status (incl. finished). | Live |
invoice.paid | A hosted invoice is fully paid. | Live |
invoice.updated | An invoice that collects several payments received funds or changed status (partially_paid, paid, expired). One event per change, each with its own id. V1.6.0. | Live |
payout.updated | A payout changes status — pending, approved, submitted, paid, rejected or failed. | Live |
conversion.created | A conversion was accepted and its source reserved. | Live |
conversion.processing | Execution started (funding, trading, returning, reconciling). | Live |
conversion.completed | The destination asset is yours. | Live |
conversion.held | A person is looking at it; your balance stays reserved. | Live |
conversion.failed | It did not happen — including a recovered one, where the source came back to you. | Live |
recipient.address.review_required | A recipient address was added and waits for review. | Live |
recipient.address.approved | The address may be paid. | Live |
recipient.address.rejected | It will not be paid; rejectReason says why. | Live |
disbursement.created | A disbursement was accepted; amount and fee reserved. | Live |
disbursement.blocked | The risk policy refused it; it will not be sent. | Live |
disbursement.submitted | Handed over for sending. | Live |
disbursement.completed | On chain. | Live |
disbursement.failed | Not sent; the reservation is released. | Live |
disbursement.batch.validated | A mass batch finished validating. | Live |
disbursement.batch.approved | A mass batch was approved. | Live |
disbursement.batch.completed | Every row became a disbursement or ended (also sent when partially completed). | Live |
All events share the same envelope (event + resource object) and the same signature scheme, so one handler covers them all.
Verifying signatures
Each delivery carries an x-kaskade-signature header — HMAC-SHA256 (hex) of the raw request body, keyed with your webhook signing secret. Always verify before trusting the event.
import crypto from "crypto";
// Express raw-body handler for POST /webhooks/kaskade
app.post("/webhooks/kaskade", (req, res) => {
const signature = req.headers["x-kaskade-signature"];
const expected = crypto
.createHmac("sha256", process.env.KASKADE_WEBHOOK_SECRET)
.update(req.rawBody) // the exact bytes we sent
.digest("hex");
if (signature !== expected) return res.status(401).end();
const { event, payment } = JSON.parse(req.rawBody);
if (payment.status === "finished") {
// ✅ fulfil the order (idempotently)
}
res.status(200).end();
});Delivery & retries
Immediate attemptsup to 3- One delivery plus two retries, with a short increasing backoff (~0.4s, then ~0.8s) and an 8-second per-attempt timeout.
Then, asynchronouslyup to 2 more rounds- A delivery still unacknowledged is picked up again later — at least 15 minutes apart — and each round is itself up to 3 attempts.
Total attemptsup to 9- Three rounds of three. After that the delivery stays failed and is not retried automatically again.
Guaranteeat-least-once- Duplicates are expected and ordering is not guaranteed. Make your handler idempotent.
Manual re-sendavailable- Failed deliveries are listed under Webhooks in the dashboard, where you can re-send one by hand at any time. A re-send is the same event again, not a new one.
Deduplicate on id. The envelope id identifies the logical event — a resource and the transition it made — and is the same on the first delivery, on every automatic retry and on a manual re-send. Record the ids you have processed and ignore one you have seen. There is no timestamp header and no replay window.
id, same created, same data, byte for byte. If the underlying resource has moved on since (a payout that was submitted is now paid), that is a separate event with its own id, not a rewrite of this one. So two deliveries carrying the same id always carry the same body, and you can safely keep the first and discard the rest.Fiat ramp
Let a customer buy crypto with a card or bank transfer (on-ramp), or sell it back to fiat (off-ramp). Kaskade creates the order with our ramp partner and returns a hosted checkout URL to redirect the customer to; they complete KYC and payment there.
Availability depends on the customer's country and payment method, and on the ramp being enabled for the platform — otherwise these endpoints return 503. These create real orders: send an Idempotency-Key so a network retry cannot create a duplicate.
Create an on-ramp order
/ramp/onrampLivepriceUsdnumberrequired- Amount of fiat the customer will spend.
payCurrencystringoptional- Crypto to deliver. Defaults to your account's setting.
fiatstringoptional- 3-letter fiat code, e.g. USD.
externalCustomerIdstringoptional- Your stable customer id — improves KYC recognition on repeat orders.
redirectUrlstringoptional- Where to send the customer when they finish.
orderIdstringoptional- Your order reference.
curl -X POST https://kaskade.com/api/v1/ramp/onramp \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c8a3e-1b2d-4c5f-8a90-abc123def456" \
-d '{ "priceUsd": 100, "orderId": "ORDER-10428" }'Create an off-ramp order
/ramp/offrampLivecryptoAmountnumberrequired- Amount of crypto the customer will sell.
walletAddressstringrequired- The customer's sending wallet.
payCurrencystringoptional- Crypto being sold.
fiatstringoptional- 3-letter fiat code to pay out in.
externalCustomerIdstringoptional- Your stable customer id.
redirectUrlstringoptional- Where to send the customer when they finish.
orderIdstringoptional- Your order reference.
Retrieve a ramp order
/ramp/orders/:idLivecurl https://kaskade.com/api/v1/ramp/orders/rmp_1a2b3c \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"Client libraries
Kaskade does not publish official SDKs today, and none is scheduled. We would rather say that than name packages you cannot install.
The API is plain HTTPS and JSON with bearer auth, so it needs no client library: the code examples below are copy-paste ready, the Postman collection covers every endpoint, and the OpenAPI documents ( V1, V2) can generate a typed client in your language with the generator you already use.
Postman collection
Import the collection to call every V1 and V2 endpoint with your key pre-wired. It includes baseUrl, baseUrlV2 and apiKey variables — set them once and go. V2 requests sit in folders marked v2 and carry a generated Idempotency-Key.
In Postman: Import → Upload Files → select the file, then set the collection variables baseUrl, baseUrlV2 and apiKey.
Code examples
Create a payment
curl -X POST https://kaskade.com/api/v1/payments \
-H "Authorization: Bearer ks_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"priceUsd": 50,
"payCurrency": "btc",
"orderId": "ORDER-123",
"orderDescription": "Premium plan"
}'Authenticate with request signing
import crypto from "crypto";
const clientId = "YOUR_CLIENT_ID"; // = your key's Public ID
const clientSecret = "ks_sk_YOUR_CLIENT_SECRET"; // shown once when you enable signing
const method = "POST";
const url = "https://kaskade.com/api/v1/payments"; // the full URL, exactly as called
const ts = new Date().toISOString();
const body = JSON.stringify({ priceUsd: 50, payCurrency: "btc", orderId: "ORDER-123" });
// canonical = BOM + METHOD + URL + clientId + timestamp + body
const canonical = "\ufeff" + method + url + clientId + ts + body;
const signature = crypto.createHmac("sha256", clientSecret)
.update(canonical, "utf8").digest("base64");
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
"X-Kaskade-Client": clientId,
"X-Kaskade-Timestamp": ts,
"X-Kaskade-Signature": signature,
},
body,
});Verify a webhook
import crypto from "crypto";
// Express raw-body handler for POST /webhooks/kaskade
app.post("/webhooks/kaskade", (req, res) => {
const signature = req.headers["x-kaskade-signature"];
const expected = crypto
.createHmac("sha256", process.env.KASKADE_WEBHOOK_SECRET)
.update(req.rawBody) // the exact bytes we sent
.digest("hex");
if (signature !== expected) return res.status(401).end();
const { event, payment } = JSON.parse(req.rawBody);
if (payment.status === "finished") {
// ✅ fulfil the order (idempotently)
}
res.status(200).end();
});Integration options
There's more than one way to accept crypto with Kaskade Payment Solution — from sharing a link in seconds to wiring the full API into your platform. Pick the lightest option that meets your need; you can mix them, and they all settle to the same account and emit the same webhooks.
| Option | Best for | Effort |
|---|---|---|
| Hosted invoice | One-off bills, freelancers, "send me a payment link" | No code |
| Payment links & buttons | A fixed-price product or donate/checkout button on any site | Copy-paste |
| POS terminal | Taking crypto in person at a counter | No code |
| Donations | Tips, fundraising, "pay what you want" | No code |
| Subscriptions | Recurring/membership billing | Low code |
| REST API + webhooks | Custom checkout, marketplaces, full control | Developer |
| Platform plugins | WooCommerce, WHMCS and other off-the-shelf stores | Install |
Plugins & platforms
Drop-in plugins let popular platforms accept crypto through Kaskade Payment Solution without custom code. Packaged plugins are rolling out; in the meantime every platform below can be wired up in minutes with the Create a payment endpoint and a webhook.
WooCommerce Planned
For WordPress + WooCommerce stores. The official gateway plugin (Planned) adds "Pay with crypto" at checkout, redirects the shopper to a hosted invoice, and marks the order paid on the invoice.paid webhook. Until it ships, integrate with a few lines in your theme or a small custom gateway:
- Create an API key and set it in your server config (never in client JS).
- On order placement, call
POST /invoiceswith the order total asamountUsdand the WooCommerce order id asorderId. - Redirect the customer to the returned invoice
url. - On the
invoice.paidwebhook (verify the signature), mark the matching order complete.
WHMCS Planned
For hosting and SaaS billing on WHMCS. The official payment-gateway module (Planned) registers Kaskade Payment Solution as a gateway so invoices can be paid in crypto and auto-reconciled. Manual integration mirrors WooCommerce: create a Kaskade Payment Solution invoice from the WHMCS invoice, send the client to the hosted checkout, and apply the payment when invoice.paid arrives (map by orderId = WHMCS invoice id).
Team & multi-user access
Most teams need more than one person — or service — touching the gateway. Here's how access works today and what's coming.
API keys as machine identities Live
Create a separate API key for each app, environment or service that talks to Kaskade Payment Solution. Keys are independent — name them ("production server", "staging", "billing worker"), rotate or revoke any one instantly without affecting the others, enable request signing per key, and ask for scopes only on the keys that need them. This is the practical way to give each part of your stack its own credential and audit trail.
Team seats & roles Live
Invite teammates to the dashboard from Team. Members are invited by email and set their own password from a single-use link. Each member carries a preset — admin, staff, viewer or custom — and the preset is only a label: actual access is a set of per-area permissions you tick, so custom is the honest choice whenever the presets do not fit. A member is invited, active or disabled, and disabling one keeps their audit trail.
Team seats are a dashboard feature. There is no V1 endpoint for managing them, and a team member is not an API credential — use a per-service API key for automation.
Export transaction history
Pull your payments for bookkeeping, reconciliation or accounting in two ways.
From the dashboard (CSV) Live
Open Payments and click Export all time to download your full payment history as a spreadsheet-ready file. Columns:
idstring- Kaskade Payment Solution payment id.
created_atstring- ISO-8601 creation time (UTC).
statusstring- Final or current status.
price_usdnumber- Amount charged in USD.
pay_currencystring- Coin paid.
pay_amountnumber- Crypto amount due.
actually_paidnumber- Crypto amount received.
order_idstring- Your order reference.
deposit_tx_hashstring- On-chain deposit TXID, if any.
provider_payment_idstring- Internal settlement reference.
Via the API Live
For automated exports or syncing to your own ledger, page through List paymentsand persist what you need. Combine with webhooks to keep your records live instead of polling.
curl https://kaskade.com/api/v1/payments \
-H "Authorization: Bearer ks_live_YOUR_API_KEY"Migrating & support
Already integrated with another crypto gateway? Kaskade Payment Solution maps cleanly: a “create charge” becomes Create a payment, IPNs become webhooks, and your order reference rides along as orderId. Because the contract is provider-agnostic (see why), you integrate once and never re-migrate.
Stuck on something? Open a ticket from your dashboard or contact support.
FAQ
Do I need a separate key for signing?
No. Every API key can use Bearer auth, and you can additionally enable a signing secret on the same key.
What happens to my integration when you change crypto infrastructure?
Nothing. The contract on this page is stable across backend changes — see Infrastructure & stability.
How do I test without real money?
There is no sandbox or test mode today — every key is a live ks_live_ key. Test with a small live payment (e.g. $5+) paid on-chain, and check your signing code against the signing test vector before you send real requests.
Can I restrict an API key to my server's IP addresses?
Not today. See key scopes & access for how to protect a key instead.
How do I send a payout over the API?
With Payout API v2: quote, then create with your fee authorisation. The V1 create call is refused with fee_approval_required.
Which status means “paid”?
finished. Fulfil orders on that webhook, idempotently.
Need help integrating? Contact support or open a ticket from your dashboard.