API Reference · v1 & v2

Kaskade Payment Solution API

The Kaskade Payment Solution API lets you accept and send cryptocurrency from your own app or backend. Price in USD, let customers pay in 7 coins across 4 networks, reconcile with signed webhooks, and pay out to external wallets — all behind one stable, JSON-over-HTTPS contract.

Availability tags. Live means callable today and frozen under V1 — build against it. Beta means live but still moving.Planned means not built: no endpoint, no committed shape, and nothing to integrate against yet. Every endpoint on this page, V1 and V2, is Live; the only Planned items are the off-the-shelf platform plugins. Some features are switched on per account (customer addresses, recipients, disbursements, conversions) — their sections say so, and until then they answer 403 or 409 with a code naming the switch.

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.

What is guaranteed stable
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).
Bottom line: integrate once. When we swap or in-source the crypto infrastructure, your code keeps working with no migration on your side.

Base URL & environments

All endpoints are served over HTTPS from one versioned base URL. Plain HTTP is rejected.

Base URL
https://kaskade.com/api/v1
Conventions
Content-Type
headerrequired
All request and response bodies are application/json.
Amounts
numberoptional
Fiat amounts are decimal USD. Crypto amounts are decimal in the coin's own unit.
Timestamps
stringoptional
ISO-8601 in UTC, e.g. 2026-06-20T12:34:56.000Z.
IDs
stringoptional
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

  1. Create an API key. In your dashboard, open API Keys and create a key. Copy the secret — shown only once.
  2. Create a payment. POST a USD price and the coin to pay in; you get a deposit address and the exact crypto amount.
  3. Collect payment. Show the address (or our hosted page) to your customer.
  4. Fulfil on webhook. Act when you receive a payment.updated event with status finished.
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.

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 wantCallBody
Hosted invoice — exactly 100 USDT on TRONPOST /invoices{ "payAmount": "100", "payCurrency": "usdttrc20" }
Hosted invoice — exactly 100 USDT on EthereumPOST /invoices{ "payAmount": "100", "payCurrency": "usdterc20" }
Direct payment — exactly 0.005 BTCPOST /payments{ "payAmount": "0.005", "payCurrency": "btc" }
Direct payment — exactly 0.25 ETHPOST /payments{ "payAmount": "0.25", "payCurrency": "eth" }
Hosted invoice — $100, customer pays in cryptoPOST /invoices{ "amountUsd": 100 } — add payCurrency to lock it to one coin
Direct payment — $50, customer pays in BTCPOST /payments{ "priceUsd": 50, "payCurrency": "btc" }
There is no 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-denominatedCoin-denominated
SendpriceUsd (payments)
amountUsd (invoices)
payAmount + payCurrency (both)
Example{ "amountUsd": 100, "payCurrency": "usdttrc20" }{ "payAmount": "100", "payCurrency": "usdttrc20" }
Priced inYour base currency. Kaskade calculates the coin amount from the live rate.The coin itself. You state the coin amount; no rate is involved.
Customer owesWhatever 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.
TypeJSON number — 100Decimal string — "100"
Payable inAny supported coin, unless you set payCurrency.That coin and network only — see same-asset rule.
Response says"pricingMode": "fiat""pricingMode": "crypto"
Send exactly one amount. 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 meanpayCurrencyNetwork
USDT on Tron (TRC-20)usdttrc20Tron
USDT on Ethereum (ERC-20)usdterc20Ethereum
USDT, network unspecifiedusdt — 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.

Header
Authorization: Bearer ks_live_YOUR_API_KEY
Keep keys server-side. A live key can move money — never ship it in a browser, mobile app or public repo. Revoking and rotating are instant in the dashboard.

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

Headers
X-Kaskade-Client
stringrequired
Your Client ID (the key's Public ID).
X-Kaskade-Timestamp
stringrequired
ISO-8601 UTC time, within 5 minutes of server time (replay protection).
X-Kaskade-Signature
stringrequired
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:

canonical =
BOM + METHOD + fullURL + clientId + timestamp + rawBody
Parts
BOM
constantrequired
The Unicode byte-order mark .
METHOD
stringrequired
Upper-case HTTP method, e.g. POST.
fullURL
stringrequired
Full request URL as called, e.g. https://kaskade.com/api/v1/payments.
clientId
stringrequired
Same as the X-Kaskade-Client header.
timestamp
stringrequired
Same as the X-Kaskade-Timestamp header.
rawBody
stringrequired
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.

Inputs
method
string
POST
full URL
string
https://kaskade.com/api/v1/payments
clientId
string
EXAMPLE-CLIENT-ID-0000-0000
timestamp
string
2026-01-01T00:00:00.000Z
rawBody
string
{"priceUsd":50,"payCurrency":"usdttrc20","orderId":"ORDER-1"}
clientSecret
string
ks_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.

Canonical string (JSON-escaped, so the BOM is visible)
"POSThttps://kaskade.com/api/v1/paymentsEXAMPLE-CLIENT-ID-0000-00002026-01-01T00:00:00.000Z{"priceUsd":50,"payCurrency":"usdttrc20","orderId":"ORDER-1"}"
Expected X-Kaskade-Signature
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.

Scopes
payouts:write
V2
Quote and create payouts: POST /v2/payouts/quote, POST /v2/payouts.
conversions:read
V1
Routes, quotes and reading conversions. conversions:write includes it.
conversions:write
V1
Create conversions (POST /conversions).
recipients:read
V2
List and read recipients.
recipients:write
V2
Create recipients and add their addresses. Includes read.
disbursements:read
V2
Read disbursements and mass batches.
disbursements:write
V2
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 response
{
  "error": "The invoice number is too long — 500 characters, and the most is 200.",
  "field": "orderId",
  "code": "validation_error"
}
CodeMeaning
200OK.
400Bad request — invalid params or below the coin's minimum.
401Unauthorized — missing, invalid, or unsigned credentials.
403Forbidden — 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.
404Not found — resource doesn't exist or isn't yours.
405Method not supported on this path (e.g. GET /v2/payouts — list through V1).
409Conflict — 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.
429Too many requests — slow down (see rate limits).
500Server error.
502Upstream settlement error — safe to retry.
503Temporarily 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.

Header
Idempotency-Key: 9f1c8a3e-1b2d-4c5f-8a90-abc123def456

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

What you can get back
409 idempotency_conflict
error
The same key with a different body. Fix the key or the body — do not retry as-is.
409 idempotency_in_progress
error
The first attempt is still running. Retry shortly and you will get its response.
409 idempotency_recovery_required
error
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_unavailable
error
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:

Planned envelope
{
  "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.

usdttrc20USDT · Tron (TRC-20)usdterc20USDT · Ethereum (ERC-20)usdcUSDC · Ethereum (ERC-20)btcBTC · BitcoinethETH · EthereumtrxTRX · TronsolSOL · Solana

List currencies

GET/currenciesLive

Returns 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"
Response
{
  "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

GET/currencies/:codeLive

Metadata 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"
Response
{
  "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

GET/rates/estimateLive

Quotes how much of currency equals a given USD amount at the current rate.

Query parameters
amount
numberrequired
USD amount to convert.
currency
stringrequired
Target coin ticker (e.g. btc).
curl https://kaskade.com/api/v1/rates/estimate?amount=50&currency=btc \
  -H "Authorization: Bearer ks_live_YOUR_API_KEY"
Response
{
  "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

GET/feesLive

Returns the fee schedule that applies to your account.

curl https://kaskade.com/api/v1/fees \
  -H "Authorization: Bearer ks_live_YOUR_API_KEY"
Response
{
  "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.

id
string
Kaskade Payment Solution payment id — use it to look the payment up later.
status
string
Current state. See statuses.
pricingMode
string
fiat — you named a price and the coin amount was quoted from it. crypto — you named the coin amount itself.
requestedAmount
string | null
In coin mode, the exact amount you asked for, as a decimal string. Null in fiat mode.
requestedCurrency
string | null
In coin mode, the coin the request was priced in. Null in fiat mode.
amountDueExact
string | null
The amount due as a decimal string, in both modes. Same value as payAmount; use this behind a copy button.
priceUsd
number | null
Amount charged in your base currency. Null in coin mode, where you named no fiat price.
payCurrency
string
Coin the customer pays in.
payAmount
number
Exact crypto amount due.
payAddress
string
Deposit address to show the customer.
payExtraId
string | 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.
payInHash
string | null
On-chain deposit transaction hash, once detected.
actuallyPaid
number
Amount received so far (handles underpayment).
orderId
string | null
Your reference, echoed back.
customerId
string | null
Set when the payment came in through a customer deposit address: your id for that customer. Null on every other payment.
customerAddressId
string | null
The customer deposit address the payment arrived at. Null on every other payment.
publicAddressId
string | null
Set when the payment came in through your public address. Null on every other payment.
expiresAt
string | 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.
createdAt
string
ISO-8601 creation time.

Payment statuses

StatusMeaning
waitingCreated; awaiting the customer's funds.
confirmingDeposit seen on-chain; awaiting confirmations.
confirmedConfirmed on-chain; settling.
sendingFunds routing to settlement.
partially_paidUnderpaid; 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.
finishedSettled. Fulfil the order here.
failedPayment failed.
refundedPayment refunded.
expiredNo 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.

paymentState
string
WAITING, PARTIALLY_PAID, PAID, OVERPAID, EXPIRED, EXPIRED_PARTIAL (closed part-paid; the funds stay yours to settle), CANCELLED, CANCELLED_PARTIAL.
requestedAmount / receivedAmount / remainingAmount
string
What was asked for, the exact sum of every deposit, and what is still due. Requested and remaining are null for an open amount.
completionPercent
string | null
0.00–100.00, capped at 100.
excessAmount
string
Received beyond the requested amount.
unacceptedAmount
string
Received but not yet yours to spend: overpayment excess held under your overpayment policy, or a partial below your partial minimum.
excessDisposition
string | null
ACCEPTED, HELD, RETURN_REQUESTED, RETURNED — what happened to an overpayment.
meetsPartialMinimum
boolean | null
Whether what arrived reaches your partial minimum; null with no minimum set.
partialDisposition
string | null
HELD below your minimum, RELEASED once it was reached, ACCEPTED if you kept it anyway, RETURN_REQUESTED / RETURNED when the payment closed below it. V1.5.0.
deposits
array
Each transfer: txHash, amount, senderAddress, receivedAt.
multipleSenders
boolean
More than one sending wallet. Returns are made per deposit, each to its own sender.
cancelledAt
string | null
When the payment was cancelled.
returnState / returns
string / array
NONE, HOLD_FOR_REVIEW, RETURN_PENDING, PARTIALLY_RETURNED, RETURNED, RETURN_FAILED; each return carries kind, state, amount, who paid the network fee, txHash and times.
event
string
Webhooks only. What changed, inside the same payment.updated event: payment.partially_paid, payment.paid, payment.overpaid, payment.cancelled, payment.expired, payment.return_pending, payment.return_completed, payment.return_failed, or payment.updated.
data.payment.p12 — 30 of 100 USDT received
{
  "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: PAID or OVERPAID — the same moment status reads finished.
  • 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_paid with amountReceived / amountRemaining, and invoice.updated fires 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

POST/paymentsLive

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

Body parameters
priceUsd
numberoptional
Fiat mode. Amount to charge in your base currency; the coin amount is quoted from it. Required unless payAmount is given.
payAmount
stringoptional
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.
payCurrency
stringrequired
Coin to pay in (btc, eth, usdttrc20, usdc…). The network is part of the identifier.
orderId
stringoptional
Your order reference (≤ 200 chars). Echoed back and in webhooks.
orderDescription
stringoptional
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"
  }'
Response
{
  "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.

100 USDT on TRON (TRC-20)
{
  "payAmount": "100",
  "payCurrency": "usdttrc20",
  "orderId": "ORDER-1001"
}
100 USDT on Ethereum (ERC-20)
{
  "payAmount": "100",
  "payCurrency": "usdterc20",
  "orderId": "ORDER-1002"
}
0.005 BTC
{
  "payAmount": "0.005",
  "payCurrency": "btc",
  "orderId": "ORDER-1003"
}
0.25 ETH
{
  "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:

Response
{
  "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).

Refused — ambiguous asset
{
  "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 payAmount must 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.
  • expiresAt is when the request lapses, not when a rate lapses: an exact coin amount has no rate to expire.
Refused — more precision than the asset carries
{
  "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 asPayable withNot payable with
100 usdttrc20100 USDT on TronUSDT on Ethereum or any other network; any other coin
100 usdterc20100 USDT on EthereumUSDT on Tron or any other network; any other coin
0.005 btc0.005 BTCUSDT, 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

GET/paymentsLive

Returns your most recent payments (up to 100), newest first.

curl https://kaskade.com/api/v1/payments \
  -H "Authorization: Bearer ks_live_YOUR_API_KEY"
Response
{
  "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

GET/payments/:idLive

Fetches one payment by id and refreshes its status live before returning, so it always reflects the latest on-chain state.

Path parameters
id
stringrequired
The payment id from Create a payment.
curl https://kaskade.com/api/v1/payments/cmq7c1ab0000xyz \
  -H "Authorization: Bearer ks_live_YOUR_API_KEY"
Response
{
  "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.

id
string
Invoice id.
status
string
open | partially_paid | paid | expired | void. partially_paid (V1.6.0): part of a coin-priced invoice has arrived — see partial payments.
amountReceived / amountRemaining
string | 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).
pricingMode
string
fiat | crypto. Which of the two amount fields carries the figure.
amountUsd
number | null
Fiat amount requested, in your base currency. Null on a coin-denominated invoice.
requestedAmount
string | null
Coin amount requested, as a decimal string. Null in fiat mode.
requestedCurrency
string | null
Coin the invoice is priced in, and the only coin it can be paid in. Null in fiat mode.
url
string
Hosted checkout URL to share with the customer.
orderId
string | null
Your reference.
customerEmail
string | null
Optional; the customer is emailed when the payment is received, part-paid, overpaid or refunded.
paymentId
string | null
Linked payment once the customer pays.
expiresAt
string | null
When the invoice expires.
createdAt
string
ISO-8601 creation time.

Create an invoice

POST/invoicesLive

Creates 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. Add payCurrency to 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.

Body parameters
amountUsd
numberoptional
Fiat mode. Amount to request in your base currency. Required unless payAmount is given.
payAmount
stringoptional
Coin mode. Exact amount of payCurrency as a decimal string. Required unless amountUsd is given.
payCurrency
stringoptional
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.
orderId
stringoptional
Your order reference.
orderDescription
stringoptional
Shown on the checkout page.
customerEmail
stringoptional
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" }'
Response
{
  "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"
  }'
Response
{
  "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"
  }'
Response
{
  "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:

0.005 BTC
{
  "payAmount": "0.005",
  "payCurrency": "btc",
  "orderId": "INV-1003"
}
0.25 ETH
{
  "payAmount": "0.25",
  "payCurrency": "eth",
  "orderId": "INV-1004"
}
A coin-denominated invoice is payable in its own asset and network only. A 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

GET/invoicesLive

Returns your invoices, newest first.

curl https://kaskade.com/api/v1/invoices \
  -H "Authorization: Bearer ks_live_YOUR_API_KEY"

Retrieve an invoice

GET/invoices/:idLive

Fetch 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

GET/balanceLive

Returns your available balance per coin.

curl https://kaskade.com/api/v1/balance \
  -H "Authorization: Bearer ks_live_YOUR_API_KEY"
Response
{
  "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.

id
string
Payout id.
status
string
pending | approved | submitted | paid | rejected | failed. See below.
currency
string
Coin/network sent.
amount
number
Crypto amount sent.
address
string
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.
destinationId
string | null
The registered destination used, if any.
destination
string | null
That destination's label.
txHash
string | null
On-chain transaction hash once broadcast.
error
string | null
Why it did not proceed, in terms you can act on. Null unless something went wrong.
reference
string | 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.
createdAt
string
ISO-8601 creation time.
paidAt
string | 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.

What each status means
pending
in progress
The request has been accepted and is waiting on the next step. The amount is already reserved against your balance.
approved
in progress
Cleared to go, but not yet handed to the network. Nothing is on chain.
submitted
in progress
Handed to the execution workflow and on its way to the chain. Still not confirmed.
paid
terminal — success
The only status that means money moved. txHash and paidAt are set. Fulfil against this and nothing earlier.
rejected
terminal
Refused and will not proceed. The reserved amount returns to your available balance.
failed
terminal
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

POST/payout-destinationsLive
GET/payout-destinationsLive

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

Body parameters
label
stringrequired
What to call it. Shown to whoever approves the address.
currency
stringrequired
Coin/network, e.g. usdttrc20. Echoed back as currency on the destination.
address
stringrequired
Destination address.
tag
stringoptional
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…" }'
Response
{
  "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

POST/payoutsLive
Superseded — use Payout API v2. Every payout now carries a network fee that you pay and must authorise explicitly, and this V1 request has no field to carry that authorisation. So it is refused with 409 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.

Body parameters
currency
stringrequired
Coin/network to send.
amount
numberrequired
Crypto amount to send.
destinationId
stringoptional
An approved destination. Defaults to your settlement wallet for the coin.
reference
stringoptional
Free text for your own reconciliation (max 500 chars). Not an idempotency key.
Error codes
fee_approval_required
409
Always, on this V1 route: the network fee has to be authorised, which needs Payout API v2.
destination_pending
409
The destination is not approved yet.
destination_deleted
409
The destination is no longer registered for withdrawals. Add it again.
destination_disabled
409
The destination has been retired and is no longer available.
destination_not_authorized
409
Nobody on the account has authorised payouts to this destination yet.
destination_unavailable
409
The destination's approval could not be confirmed right now. Retry shortly.
insufficient_balance
400
More than the available balance, which already excludes payouts in flight.
no_wallet
409
No settlement wallet on file for that coin.
unknown_destination
409
No such destination on this account.
direct_settlement
409
This account settles straight to its own wallet, so there is no balance here.
payouts_restricted
403
Withdrawals are restricted on this account.
kyc_required
403
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" }'
Response
{
  "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

GET/payoutsLive
GET/payouts/:idLive

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

What is different in V2
Idempotency-Key
required
Every V2 POST needs one; without it the answer is 400 idempotency_key_required. Same key and body within 24 hours replays the first response; a different body is 409 idempotency_conflict.
Amounts
decimal string
Exact decimal strings such as "50" or "0.25", never JSON numbers.
Lists
envelope
{ "data": [ … ], "hasMore": bool }, newest first, with a cursor — see pagination.
Network fees
authorised
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 your maxFee.
Account switches
per account
Customer addresses, recipients and disbursements are switched on for your account by Kaskade on request. Until then they answer 403 with 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

POST/v2/payouts/quoteLive
Body parameters
currency
stringrequired
Coin/network to send, e.g. usdttrc20.
amount
stringrequired
Exact decimal string — what the destination receives.
destinationId
stringoptional
An approved payout destination. Omit for your settlement wallet for that coin.
feeAsset
stringoptional
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" }'
201
{
  "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

POST/v2/payoutsLive

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

Body parameters
quoteId
stringrequired
From the quote.
currency
stringrequired
Same as the quote.
amount
stringrequired
Same as the quote, as a decimal string.
destinationId
stringoptional
Same as the quote.
feeAsset
stringoptional
Same as the quote.
feeAuthorization
objectrequired
{ "accepted": true, "maxFee": "3.5" } — your explicit consent to a network fee up to maxFee, in the fee asset.
reference
stringoptional
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:

payout.networkFee
{
  "paidBy": "merchant",
  "settlementAsset": "usdttrc20",
  "reserved": "3",
  "actual": null,
  "settled": false
}
Error codes
FEE_REQUOTE_REQUIRED
409
The fee is now above maxFee, or the quote expired. Nothing was reserved. Quote again.
quote_consumed
409
That quote was already used.
quote_mismatch
409
The request differs from the quote (asset, amount, destination or fee asset).
quote_not_found
404
No such quote on this account.
insufficient_balance
409
Spendable balance is below amount + fee reserve.
invalid_fee_authorization
400
maxFee is not a non-negative decimal string.
insufficient_scope
403
The key lacks payouts:write.
idempotency_key_required
400
Send an Idempotency-Key.
destination_* / kyc_required / payouts_restricted
409 / 403
As on V1.

Retrieve a payout

GET/v2/payouts/:idLive

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

GET/v2/payoutsLive
curl 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_enabled until 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), reads confirming while final and screened, then finished. 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.
POST/v2/customer-addressesLive
Body parameters
customerId
stringrequired
Your id for the customer: 1–100 of letters, digits and . _ : @ -.
payCurrency
stringrequired
A coin your account accepts — see GET /currencies.
label
stringoptional
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" }'
201 created · 200 already existed (same address)
{
  "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"
  }
}
GET/v2/customer-addressesLive

Newest first. Query: customerId (one customer's addresses), limit(1–100, default 50), starting_after. Answers { "data": [ … ], "hasMore": false }.

GET/v2/customer-addresses/:idLive

One of your addresses; another account's id is 404.

Error codes
validation_error
400
Bad customerId, payCurrency or label; field names it.
currency_not_available
400
The coin is not available for your account.
customer_addresses_not_enabled
403
Not switched on for your account.
payments_disabled
403
Your account cannot accept payments right now.
direct_settlement
409
Your account settles to its own wallet.
unavailable
502 / 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 (customerId is null). 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.
POST/v2/public-addressesLive
Body parameters
payCurrency
stringrequired
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" }'
201 created · 200 already existed (same address)
{
  "publicAddress": {
    "id": "cmh9p…",
    "payCurrency": "usdttrc20",
    "network": "Tron",
    "address": "TQm4…8k",
    "tag": null,
    "status": "ACTIVE",
    "createdAt": "2026-10-19T12:00:00.000Z"
  }
}
GET/v2/public-addressesLive

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

POST/v2/recipientsLive
Body parameters
merchantRecipientRef
stringrequired
Your id for the payee: up to 128 of A–Z a–z 0–9 . _ : -, unique per account.
type
stringrequired
INDIVIDUAL or BUSINESS.
name
stringoptional
Stored encrypted.
email
stringoptional
Stored encrypted.
country
stringoptional
ISO 3166-1 alpha-2.
metadata
objectoptional
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.

GET/v2/recipientsLive

Newest first; limit (1–100, default 25), starting_after, and ref to find one by your reference. Addresses are not included in the list.

GET/v2/recipients/:idLive

The recipient with its addresses.

POST/v2/recipients/:id/addressesLive
Body parameters
coin
stringrequired
Coin and network, e.g. usdttrc20.
address
stringrequired
Validated like a payout destination: a valid address on that chain, and never a Kaskade deposit address.
ownershipType
stringrequired
MERCHANT_OWNED, CUSTOMER_OWNED, THIRD_PARTY, VENDOR or UNKNOWN.
tag
stringoptional
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.
label
stringoptional
Your name for the wallet.
201 — the address
{
  "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"
  }
}
Address status
REVIEW_REQUIRED
waiting
Saved; screening and approval pending. Cannot be paid.
APPROVED
payable
Approved. Payable once payable is true (its registration for sending has cleared). There is no cool-off period.
REJECTED
final
Will not be paid; rejectReason says why.
ARCHIVED
final
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.

POST/v2/disbursements/quoteLive

{ "recipientAddressId", "amount", "feeAsset"? } → { quote }, the same shape as a payout quote plus recipientAddressId. Reads only; single use; about 3 minutes.

POST/v2/disbursementsLive
Body parameters
quoteId
stringrequired
From the quote.
recipientAddressId
stringrequired
Same as the quote.
amount
stringrequired
Same as the quote, as a decimal string.
feeAuthorization
objectrequired
{ "accepted": true, "maxFee": "…" }.
reference
stringoptional
Free text, max 500.
201
{
  "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
  }
}
GET/v2/disbursements/:idLive
GET/v2/disbursementsLive

Newest first; limit, startingAfter and batchId.

Disbursement status
PENDING
in progress
Accepted; amount and fee reserved; waiting to be released.
BLOCKED
final
The risk policy refused it. It will not be sent.
SUBMITTED
in progress
Handed over for sending.
COMPLETED
final — success
On chain; txHash is set.
FAILED
final
Not sent. The reservation is released.
Error codes
recipient_address_in_review
409
The address is still being reviewed.
recipient_address_pending
409
Approved, but its registration for sending has not cleared yet.
recipient_address_not_payable
409
Rejected or archived, or the recipient is not active.
unknown_recipient_address
404
No such address on this account.
FEE_REQUOTE_REQUIRED / quote_* / insufficient_balance
409 / 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.

  1. Create. POST /v2/disbursements/batches with rows. 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.
  2. Validate. Every row gets a verdict — READY, WAITING_ADDRESS_REVIEW, REJECTED_ADDRESS or INVALID — 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 …/validate continues it.
  3. Approve. By default a person approves in the dashboard with an authenticator code. The approval covers the reviewed batchHash, a mode (send_ready_now or wait_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.
  4. 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.
POST/v2/disbursements/batchesLive
GET/v2/disbursements/batchesLive
GET/v2/disbursements/batches/:idLive

The batch and its rows; filter rows with status, page with afterRow and limit (1–500, default 100).

POST/v2/disbursements/batches/:id/validateLive
POST/v2/disbursements/batches/:id/cancelLive

Cancels every row not yet turned into a disbursement. Disbursements already created are not recalled.

POST/v2/disbursements/batches/:id/approveLive

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

Batch status
DRAFT → VALIDATING → VALIDATED
preparing
Nothing reserved.
APPROVED → PROCESSING
paying
Rows are becoming disbursements.
COMPLETED / PARTIALLY_COMPLETED / CANCELLED
final
Every row became a disbursement or ended. Follow each disbursement for the money.
Error codes
approval_requires_panel
403
API approval is not switched on; approve in the dashboard.
batch_changed
409
The batch changed since the batchHash you approved. Review again.
fee_cap_required
400
Give a maximum fee per row for every asset in the batch.
insufficient_balance
409
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:

  1. the settlementAsset on the invoice, if it has one;
  2. otherwise your saved Settlement Preference;
  3. 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.

Pay-X → Settle-Y: accept SOL, hold USDT
POST /v1/invoices
{
  "amountUsd": 100,
  "payCurrency": "sol",
  "settlementAsset": "usdttrc20",
  "settlementNetwork": "tron"
}
The payment that results
{
  "payCurrency": "sol",
  "payNetwork": "Solana",
  "settlementAsset": "usdttrc20",
  "settlementNetwork": "tron",
  "conversionRequired": true,
  "conversionId": "cmu4…",
  "conversionStatus": "APPROVED_FOR_EXECUTION"
}
Same asset on both sides: no conversion happens
{
  "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

GET/conversion-routesLive

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

Response
{
  "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

POST/conversion-quotesLive

A price, and nothing else: no conversion is created and no balance is reserved.

Request
{
  "sourceAsset": "sol",
  "sourceNetwork": "solana",
  "destinationAsset": "usdttrc20",
  "destinationNetwork": "tron",
  "amount": "0.25"
}
Response
{
  "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

POST/conversionsLive

Prices 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

GET/conversionsLive
GET/conversions/:idLive

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

POST to your URL
{
  "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

EventWhen
payment.updatedA payment changes status (incl. finished).Live
invoice.paidA hosted invoice is fully paid.Live
invoice.updatedAn 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.updatedA payout changes status — pending, approved, submitted, paid, rejected or failed.Live
conversion.createdA conversion was accepted and its source reserved.Live
conversion.processingExecution started (funding, trading, returning, reconciling).Live
conversion.completedThe destination asset is yours.Live
conversion.heldA person is looking at it; your balance stays reserved.Live
conversion.failedIt did not happen — including a recovered one, where the source came back to you.Live
recipient.address.review_requiredA recipient address was added and waits for review.Live
recipient.address.approvedThe address may be paid.Live
recipient.address.rejectedIt will not be paid; rejectReason says why.Live
disbursement.createdA disbursement was accepted; amount and fee reserved.Live
disbursement.blockedThe risk policy refused it; it will not be sent.Live
disbursement.submittedHanded over for sending.Live
disbursement.completedOn chain.Live
disbursement.failedNot sent; the reservation is released.Live
disbursement.batch.validatedA mass batch finished validating.Live
disbursement.batch.approvedA mass batch was approved.Live
disbursement.batch.completedEvery 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

Respond 2xx quickly. Anything else counts as a failure.
What we actually do
Immediate attempts
up 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, asynchronously
up 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 attempts
up to 9
Three rounds of three. After that the delivery stays failed and is not retried automatically again.
Guarantee
at-least-once
Duplicates are expected and ordering is not guaranteed. Make your handler idempotent.
Manual re-send
available
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.

An event never changes. A retry re-sends the original event exactly as it was first raised — same 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

POST/ramp/onrampLive
Body parameters
priceUsd
numberrequired
Amount of fiat the customer will spend.
payCurrency
stringoptional
Crypto to deliver. Defaults to your account's setting.
fiat
stringoptional
3-letter fiat code, e.g. USD.
externalCustomerId
stringoptional
Your stable customer id — improves KYC recognition on repeat orders.
redirectUrl
stringoptional
Where to send the customer when they finish.
orderId
stringoptional
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

POST/ramp/offrampLive
Body parameters
cryptoAmount
numberrequired
Amount of crypto the customer will sell.
walletAddress
stringrequired
The customer's sending wallet.
payCurrency
stringoptional
Crypto being sold.
fiat
stringoptional
3-letter fiat code to pay out in.
externalCustomerId
stringoptional
Your stable customer id.
redirectUrl
stringoptional
Where to send the customer when they finish.
orderId
stringoptional
Your order reference.

Retrieve a ramp order

GET/ramp/orders/:idLive
curl 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.

Download collection (.json)

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.

OptionBest forEffort
Hosted invoiceOne-off bills, freelancers, "send me a payment link"No code
Payment links & buttonsA fixed-price product or donate/checkout button on any siteCopy-paste
POS terminalTaking crypto in person at a counterNo code
DonationsTips, fundraising, "pay what you want"No code
SubscriptionsRecurring/membership billingLow code
REST API + webhooksCustom checkout, marketplaces, full controlDeveloper
Platform pluginsWooCommerce, WHMCS and other off-the-shelf storesInstall
Not sure? If you run a store on a known platform, start with a plugin. If you bill people directly, use a hosted invoice. If you're building your own product, go straight to the API.

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:

  1. Create an API key and set it in your server config (never in client JS).
  2. On order placement, call POST /invoices with the order total as amountUsd and the WooCommerce order id as orderId.
  3. Redirect the customer to the returned invoice url.
  4. On the invoice.paid webhook (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).

Want early access to a plugin? Tell us your platform from the dashboard and we'll prioritise it. The integration recipe above keeps you live today regardless.

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.

Best practice. One key per service, least privilege, rotate on staff changes, and never share a single key across environments — so revoking one never takes down everything.

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:

id
string
Kaskade Payment Solution payment id.
created_at
string
ISO-8601 creation time (UTC).
status
string
Final or current status.
price_usd
number
Amount charged in USD.
pay_currency
string
Coin paid.
pay_amount
number
Crypto amount due.
actually_paid
number
Crypto amount received.
order_id
string
Your order reference.
deposit_tx_hash
string
On-chain deposit TXID, if any.
provider_payment_id
string
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.

API Documentation — Kaskade Payment Solution