Build on KaspaRocket
Quotes, swaps, limit orders and market data on Kaspa — from a Telegram bot, a website, or a wallet. Earn 0.5% of the flow your users trade.
No key custody
The SDK builds unsigned transactions. Your signer holds the keys — always.
One transaction
Curve and order book settle atomically. No half-executed plans.
Browser-ready
Publishable keys, origin-restricted. No proxy to run.
Get started
You earn a third of every trading fee
1KAS
The least you earn on a swap, trade or fill — whatever its size.
0.5%
Of the flow your users trade, once it clears the floor. No cap.
1/3
Of the platform fee, redirected to you rather than kept by the treasury.
The floor works in your favour
0.5% is a rate, not a ceiling. On trades below 200 KAS the 1 KAS floor pays you more than 0.5% of the flow — a 10 KAS swap earns you 1 KAS, which is 10% of it. Small, frequent trades are the ones a bot generates most, and they are the ones the floor rewards best.
Above 200 KAS your user pays exactly the standard rate. Your share comes out of KaspaRocket's, never out of their pocket — so nothing about your integration makes trading more expensive for them.
What the library gives you
One package, three services. Everything below runs against the same API that powers kasparocket.com — the site is a consumer of this SDK, not a special case.
| Capability | Earns you a share |
|---|---|
| Swaps, trades and order fills Curve and order book, settled atomically | yes |
| Quotes and routing Best execution across curve + book, priced before you commit | free to call |
| Limit orders Place and cancel — you earn when they fill | free to call |
| Market data Order book, holdings, candles, pool stats | free to call |
| Token creation Launch a token from your own interface | 50 KAS |
| Token catalogue Names, images, comments, activity | free to call |
Quoting, routing and reading the book cost you nothing — call them as often as your interface needs. The share is charged once, on the transaction that actually moves value.
Get started
Installation
npm install @kasparocket/sdkBoth module formats are shipped, so existing bot codebases work unchanged:
import { KaspaRocket } from "@kasparocket/sdk" // ESM
const { KaspaRocket } = require("@kasparocket/sdk") // CommonJSTypeScript types resolve under node10, node16 and bundler. Node 18 or newer is required for native fetch; on Node 16 the client refuses to construct with an explicit message rather than failing later.
Consuming from a linked workspace
If you install via file: in a monorepo, Next.js needs to know the package lives outside your project root:
transpilePackages: ["@kasparocket/sdk"],
turbopack: { root: path.join(__dirname, "..") },
outputFileTracingRoot: path.join(__dirname, ".."),Without it the build fails on Module not found without mentioning the link. Installing from npm avoids this entirely.
Get started
Authentication
Two kinds of key
A bot runs on a server; a site or a wallet runs in a browser. The same key cannot serve both — inside a bundle it is public, and that is a property of the medium, not an oversight.
| Kind | Prefix | Where | Restriction |
|---|---|---|---|
| Secret | kr_live_… | Server only | none |
| Publishable | krpub_live_… | Browser | declared origins |
// Bot, backend, scheduled job
new KaspaRocket({ apiKey: process.env.KR_SECRET_KEY }) // kr_live_…
// Site, extension, wallet
new KaspaRocket({ apiKey: "krpub_live_a3f2c1d0_…" }) // in the bundle, by designThe prefix makes the distinction visible at a glance: seeing krpub_ in a bundle is normal, seeing kr_live_ is an incident. You can tell from a screenshot, without querying anything.
A stolen publishable key cannot take your funds
Your fee address lives server-side. Whoever uses your key pays you rather than robbing you. The real exposure is quota exhaustion — which is what origin checking bounds, without pretending to a confidentiality the browser cannot provide.
Publishable keys are only accepted when the request carries a declared Origin header. The browser sets it itself; it is the one header a page cannot forge. Outside a browser the key is rejected with a message pointing you to a secret key.
Getting a key
There is no self-serve signup yet — access is granted by hand while the programme settles. Tell us what you are building, the target network (mainnet or testnet-10), the Kaspa address that should receive your share, and — for browser clients — the origins you will call from (https://yoursite.com, no path, no trailing slash).
Keys are shown once. We store only a hash, so we cannot re-display one — only replace it. A testnet-10 key (kr_test_…) comes with every account; start there.
Get started
Quickstart
import { KaspaRocket, kasToSompi, formatKas } from "@kasparocket/sdk"
const kr = new KaspaRocket({
apiKey: process.env.KR_SECRET_KEY,
network: "testnet-10", // the only network deployed today
})
// No host to configure: one origin serves both APIs — transactions under
// /pro-api, the catalogue under /api. Override with `apiUrl` and
// `catalogueUrl` only if you run your own instance.
// 0 — Resolve the pool. Every trading call needs `lpCovenantId`, and neither
// token route carries it: it lives on the covenant, not the token record.
const pool = await kr.catalogue.poolCovenant(tokenId)
if (!pool) throw new Error("this token has no pool yet")
const lpCovenantId = pool.lp_amm_covenant_id
// 1 — Quote. Commits nothing.
const quote = await kr.quote({
tokenId, lpCovenantId,
direction: "buy",
kasBudgetSompi: kasToSompi("100"),
})
// `amountTokens` is in RAW units (1000 = 1 token) — display `amountTokensDisplay`.
console.log(`${quote.amountTokensDisplay} tokens, ${formatKas(quote.fee.total)} KAS in fees`)
// 1b — On a SELL, show `netKasSompi`, not the gross flow. It is what the user
// pockets, fees deducted, and it can be NEGATIVE: selling 1,000 tokens can
// return 0.12 KAS against a 3 KAS fee floor.
if (quote.uneconomic) {
return refuse("this sale costs more in fees than it returns")
}
// 2 — Preview the route. This is what you show the user.
const preview = await kr.route({
tokenId, lpCovenantId, address,
side: "buy", kind: "market",
budgetSompi: kasToSompi("100"),
})
// 3 — Build, carrying the preview's commitment.
const plan = await kr.planTrade({
tokenId, lpCovenantId, address,
side: "buy", kind: "market",
budgetSompi: kasToSompi("100"),
expected: preview.attendu,
})
// 4 — Sign and broadcast. Your key never touches our code.
await kr.signAndSubmit(plan, mySigner)Where `lpCovenantId` comes from
It is the single thing that trips up a first integration. quote, route, planTrade, planSwap, poolStats and candles all require it, and neither catalogue.tokens() nor catalogue.token(id) returns it. A token and its pool are two different covenants; poolCovenant() is the bridge.
It returns null rather than throwing when a token has no pool — one created seconds ago legitimately has none yet, and that is not an error while you are listing a catalogue.
Core concepts
Amounts and BigInt
u64 in sompi (1 KAS = 100,000,000 sompi). A JavaScript number is a double: past 253 it drops units silently. The SDK never exposes a number for an amount.kasToSompi("1.5") // 150000000n
formatKas(150_000_000n) // "1.5"kasToSompi truncates below one sompi rather than rounding — nobody expects 0.000000009 KAS to become 0.00000001.
Two things that will bite you in production
JSON.stringify(quote) // ✗ Do not know how to serialize a BigInt
fee.total / 2 // ✗ Cannot mix BigInt and other typesLogging a quote, caching it in Redis, returning it from a webhook — a bot does this constantly, and every one of those throws.
import { toJsonSafe, formatKas } from "@kasparocket/sdk"
await redis.set(key, JSON.stringify(toJsonSafe(quote)))
bot.sendMessage(id, `Fee: ${formatKas(fee.total)} KAS`)
fee.total / 2n // arithmetic stays in biginttoJsonSafe emits amounts as strings, not numbers: converting back to a float would reintroduce exactly the precision loss the bigint avoids. A string reads back with BigInt(…) losslessly.
Core concepts
Token amounts
import { formatTokens, tokensToRaw, tokenPriceSompi, formatKas } from "@kasparocket/sdk"
formatTokens(5_000_000n) // "5000" — not "5000000"
formatTokens(1n) // "0.001" — one raw unit is not nothing
tokensToRaw("1.234") // 1234n
tokensToRaw("0.0009") // 0n — below one raw unit, TRUNCATED
// A PRICE scales the other way: sompi per RAW unit, so multiply.
formatKas(tokenPriceSompi(23_000n)) // "0.23" per tokenNothing in the payload warns you which one you are holding: a bigint of raw units looks exactly like a bigint of tokens. The first bot to hold 5,000 tokens announced 5,000,000, and no type complained.
Showing a quote
A quote carries the amount twice: the raw bigint to compute with, and a string already formatted to show. Estimates have been displayed with the decimal point missing — 1234567 tokens for 1234.567 — because the raw field is the first one anyone prints.
const quote = await kr.quote({ tokenId, lpCovenantId, direction: "buy", kasBudgetSompi })
quote.amountTokens // 1234567n — raw: arithmetic, comparisons, planTrade
quote.amountTokensDisplay // "1234.567" — what the user reads
// Before SDK 0.1.6, same result:
formatTokens(quote.amountTokens) // "1234.567"| Field | Unit | Use it to |
|---|---|---|
amountTokens | raw bigint | Compute, compare, pass back to the SDK |
amountTokensDisplay | tokens, string | Display. Exactly formatTokens(amountTokens) |
Only the quote comes pre-formatted
route() and planTrade() return the server's shape as-is: tokens_executed, pool_leg.tokens, fills[].tokens and created_order.tokens are all raw. Pass each one through formatTokens() before showing it. The summary string is the exception — it is already written in tokens and KAS.
Dividing a price is the tempting mistake
A quantity is divided by the scale; a price is multiplied by it. Get it backwards and you print a number a thousand times too small that still looks entirely plausible — 0.00023 KAS instead of 0.23. Use tokenPriceSompi and the direction is not yours to remember.
One scale for every token
There is deliberately no per-token decimals field: the contracts are compiled around RAW_PER_TOKEN, so it cannot change without a redeploy. kr.protocol() publishes it under token all the same — compare it against the constant your package ships, and a version mismatch becomes something you detect rather than something a customer reports.
Core concepts
Signing
import {
instructionsToSign, scriptP2pkh, spliceCovenantSignature,
} from "@kasparocket/sdk"
import type { Signer, PlannedTransaction } from "@kasparocket/sdk"
const mySigner: Signer = {
getAddress: () => address,
async sign(planned: PlannedTransaction) {
// Every input that needs a signature — both kinds, never the "final" ones.
for (const instruction of instructionsToSign(planned)) {
const input = planned.tx.inputs[instruction.inputIndex]
// Your job: a raw 65-byte signature over this input's sighash.
const signature = await mySignature(planned.tx, instruction.inputIndex)
input.signatureScript = instruction.kind === "p2pkh"
? scriptP2pkh(signature) // write a new script
: spliceCovenantSignature(instruction, signature) // splice into the prebuilt one
}
return planned.tx
},
}Three kinds of input, not two
final inputs are already complete — the contract requires no signature and the server built the whole script. Write nothing to them. Overwriting one gets the transaction rejected with script ran, but verification failed, a message that does not say which input is at fault.
The subtle one is covenant-leader. It looks like a covenant input, so it is tempting to leave it alone — but it does require the user's signature, spliced into the prebuilt script at the offsets the server supplies. Every order fill has one : the vault of the order being filled. A signer that only handles p2pkh leaves it unsigned and every fill fails.
Both helpers above validate before they write — spliceCovenantSignature refuses a placeholder that is not zeroed, rather than signing a script it was not shown.
Copy each input's computeBudget through untouched. It is not part of the sighash, but the node refuses the transaction without it.
The sighash, and one trap that costs a day
You do not sign the transaction. You sign a 32-byte digest computed from it, and Kaspa's rules for that digest change with the transaction version. Plain sends are version: 0; every trade is version: 1. At version 1:
sigOpCountleaves the digest — both the aggregate hash after the sequences, and the per-input byte. rusty-kaspa gates both ontx.version < 1.- the output hash commits to each output's covenant. Miss this and every pool trade fails, while plain sends keep working.
Check your digest against ours before you sign
Every signable instruction carries a sighash — the exact 32 bytes that input expects, hex encoded. Compare yours before broadcasting and the mistake below costs you a line of logging instead of a day.
for (const ins of instructionsToSign(planned)) {
const mine = toHex(myDigest(planned.tx, ins.inputIndex))
if (ins.sighash && mine !== ins.sighash) {
throw new Error(`digest mismatch on input ${ins.inputIndex}`)
}
}Nothing secret is disclosed: the digest derives entirely from the transaction you already hold. It is a convenience, not an authorisation — we still cannot sign anything, and neither can anyone who reads it.
The field is optional. Absent means we could not compute it, never that yours is wrong — so a missing digest must not block a trade. final inputs never carry one: they have nothing to sign.
kaspa-wasm cannot sign a pool trade for you
Its Transaction type has nowhere to hold an output covenant, and deserializeFromSafeJSON drops it silently. The digest it then computes is missing a committed field, so createInputSignature can never produce a valid signature for a trade — no matter how correct your code is.
Compute the digest from the plan object we return, which carries the covenants, and sign that digest directly (signScriptHash or your own BIP-340 signer). Reported by the first integrator, who lost a day to it — plain sends were accepted first try, so every sign pointed at the covenant input instead of the digest.
The failure mode gives you almost nothing to go on: false stack entry at end of script execution, with no indication of which input. And a single rejection does not mean a single bad input — the script engine stops at the first failure, so your p2pkh input in the same transaction is very likely wrong too.
A plan may carry several chained transactions when many UTXOs must be swept. Kaspa transaction IDs exclude signature scripts, so the chain's outpoints are already final at build time: sign everything offline, then submit in order. signAndSubmit handles that.
Core concepts
Price commitment
route() returns an attendu field. Passing it to planTrade({ expected }) makes the build refuse if the market moved between preview and signature.Omitting it means agreeing to execute at a price your user never saw. An arbitrage bot may want that; a human-facing interface should always pass it.
Core concepts
Placing orders
Plans that place an order — planCreateOrder, a planTrade that leaves a remainder, a planSwap given an order — come back with an extra order_row. It has to be handed back to submit() after broadcasting.
const plan = await kr.planCreateOrder({
address, tokenId, lpCovenantId,
side: "sell", priceSompi: kasToSompi("0.000033"), tokens: 9_000_000n,
})
// signAndSubmit forwards `plan.order_row` for you.
await kr.signAndSubmit(plan, mySigner)
// Calling submit() yourself? Pass it as the second argument.
await kr.submit([signedTx], plan.order_row)This is not a cache — it is the order
Under P2SH, nothing that defines an order is readable on chain before its first spend. The chain holds a hash: not the price, not the quantity, not the creator. order_row is what makes the order visible.
Drop it and the order still exists on chain and still locks the funds — but it is not in the book, so nobody fills it, so it is never revealed, so nobody can ever discover it. The funds are stranded with no recovery path your code can take.
Placing charges nothing. The platform fee is taken when the order fills, and it goes to whoever builds the filling transaction. So you earn on the fills you build, never on the placements you broadcast — including your own users' orders, which someone else may fill.
Core concepts
What to show, and in what order
const { tokens } = await kr.catalogue.feed({ limit: 10 })
for (const t of tokens) {
const badge = t.placement === "sponsored" ? " · Sponsored"
: t.placement === "skin" ? " · Featured"
: ""
bot.send(`$${t.ticker}${badge} — ${t.volume_24h_kas.toFixed(0)} KAS traded`)
}| Rank | placement | Why it is there |
|---|---|---|
| 1 | sponsored | A creator paid for the slot |
| 2 | skin | A creator paid for a skin — skin_id says which |
| 3 | organic | Ranked by 24-hour traded volume |
Within a rank, volume decides, so the order is stable between calls rather than following insertion order. Each row also carries created_at — a brand new token has no volume yet, so the ranking alone cannot tell yesterday's launch from one that has been around for months.
Watching for NEW tokens? This ranking will lose them
A token minted a minute ago has no 24-hour volume, so it lands at the bottom of rank 3, among every other zero-volume token. Measured on the live catalogue on 2026-09-22: of thirty tokens, positions 9 to 30 were all at zero. Read the first twenty and a launch can be missed outright — on a catalogue of hundreds it will be missed nearly every time, and raising limit only moves the cliff.
Paging does not fix it either. Use since.
// Detection: everything created after an instant, OLDEST FIRST.
let since = new Date(Date.now() - 3600_000).toISOString()
for (;;) {
const page = await kr.catalogue.feed({ since, limit: 50 })
for (const t of page.tokens) await snipe(t)
// null when nothing new came back — keep the cursor you had, or you would
// skip whatever is minted between this call and the next.
since = page.next_since ?? since
// Still behind after a burst? Loop again before sleeping.
if (page.pagination.total_count > page.tokens.length) continue
await sleep(5_000)
}Oldest first is the point. Newest-first plus a limit drops the oldest unseen rows — precisely the ones you have not handled yet. Ascending, you always receive the next slice you owe, and total_count tells you how far behind you still are. The bound is exclusive, so nothing arrives twice.
Two calls, two purposes
Cursor mode changes the order, not the rows: every token still carries placement, placement_ends_at and skin_id. But it is chronological, so a sponsored token is not hoisted — it arrives when it was minted, like any other.
So call feed() without since for the list you show people, and keep since for detection. A bot reading only the cursor surfaces paid placements no better than chance, and those placements are what a creator paid for.
One token, by id, no list involved. catalogue.token(id) for the metadata, catalogue.poolCovenant(id) for the pool — and the second is not a convenience: quote, route, planSwap, poolStats and candles all need lp_amm_covenant_id, and neither token route returns it. It answers null rather than throwing when a token is too new to have a pool, which is exactly the case a sniper hits.
Read `placement`, and show it
A token that is both sponsored and skinned appears once, at the higher rank — so counting rows tells you nothing about why something is first. The field is explicit for that reason.
And show it to your users. A paid placement that does not announce itself is advertising in disguise: they have no way to tell it from a ranking they can trust, and it is your interface their trust lands on, not ours.
Selling a promotion — and keeping 30 % of it
Your users can buy that first rank from inside your bot. When they do, 30 % of the price is yours — you brought the user and you carry the interface. Bought from the KaspaRocket site there is no intermediary, so the treasury takes all of it.
// What it costs right now — dynamic, and commits to nothing.
const prices = await kr.catalogue.adPricing(tokenId)
console.log(prices.price_60_min, "KAS for an hour")
const plan = await kr.planSponsor({ address, tokenId, durationMinutes: 60 })
await kr.signAndSubmit(plan, mySigner) // forwards `sponsor_row` for youOne transaction, two outputs
The split is not settled afterwards — it is written into the transaction, one output each. Two separate transfers is one that can land without the other; a single transaction makes both shares one truth.
It also makes the promotion verifiable. Before recording anything, the server re-reads the broadcast transaction's outputs and refuses if either share is missing or short. So do not build the payment yourself — a hand-rolled one is not cheaper, it simply does not register, and the KAS is spent either way.
Your address never travels in the request. It is read from your key, server-side, for the same reason your fee address is.
Checking a token before you buy it
const pool = await kr.catalogue.poolCovenant(tokenId)
if (!pool) return // no pool yet — nothing to buy
// Who holds what. allHolders lifts the 25-row display cap.
const s = await kr.poolStats(pool.lp_amm_covenant_id, tokenId,
{ points: 0, allHolders: true })
if (s.holders_truncated) return // incomplete list — do not conclude from it
const total = s.holders.filter(h => !h.is_lp_pool)
.reduce((n, h) => n + Number(h.amount), 0)
const dev = s.holders.find(h => h.address === token.creator)
const part = dev ? Number(dev.amount) / total : 0 // the creator's shareTotal supply is the same for every token
It is a protocol constant — 1,000,000 display units (109 raw, 3 decimals), exported as RAW_TOTAL_SUPPLY. So “how much does the dev hold” is never a supply question: it is a share of the holders, which is what the snippet above computes.
holders_count is the real number of holders, counted server-side over all of them — holders.length is capped. Tokens locked in a sell order are attributed to the seller, not to the escrow covenant holding them, so the shares reflect who can actually move what.
`marketCap` on the detail route is always 0
It is a legacy analytics column that nothing maintains — on every network, testnet and mainnet alike. Do not wait for it to fill in.
Derive it instead: price_sompi from poolStats is the price per RAW unit, so the cap in KAS is price_sompi × RAW_TOTAL_SUPPLY ÷ 10⁸. That is what the site itself displays.
Reference
Revenue share
| Recipient | Without partner | With partner |
|---|---|---|
| KaspaRocket treasury | max(2 KAS, 1.5%) | max(2 KAS, 1%) |
| Your address | — | max(1 KAS, 0.5%) |
The two floors are independent, not a split of the direct floor. What that means in practice:
| Flow traded | Direct total | Total via you | You earn |
|---|---|---|---|
| 10 KAS | 2 KAS | 3 KAS | 1 KAS |
| 100 KAS | 2 KAS | 3 KAS | 1 KAS |
| 200 KAS | 3 KAS | 3 KAS | 1 KAS |
| 1,000 KAS | 15 KAS | 15 KAS | 5 KAS |
| 10,000 KAS | 150 KAS | 150 KAS | 50 KAS |
Above 200 KAS of flow, your user pays the standard rate
KaspaRocket gives up a third of its share — your user pays nothing extra. Below 200 KAS the surcharge is capped at 1 KAS.
What is billable
swap, fill, trade. Nothing else.
Placing a limit order costs nothing — the fee is charged at fill time. So you earn nothing on a placement, but you earn on every fill you build. The beneficiary is whoever builds the transaction, not whoever placed the order: they are already the one paying the fee.
Your fee address is not a parameter
It is attached to your key, server-side. The SDK has no way to put it in a request, deliberately: no contract verifies that output, so an address travelling in the request body would let anyone claim your share.
const p = await kr.protocol()
p.partner?.feeAddress // your address, as registered
p.partner?.scopes // what your key may call
p.fees // the schedule in force for youEstimating without a round trip
kr.estimateFees(kasToSompi("1000"))
// { treasury: 1000000000n, partner: 500000000n, total: 1500000000n }Purely local — safe to call on every keystroke while a slider moves. Rates come from kr.protocol(), so call it once at startup. The authoritative amount is always the one in the plan.
Reference
Token creation
create_token scope.100KAS
Base deposit — funds the deployment itself
50KAS
Your share, added on top
150KAS
What your user sends, in one transfer
This one is added, not shared
On a trade you take a third of an existing fee, and above 200 KAS of flow your user pays nothing extra. Creation is the opposite: your 50 KAS is added to the deposit, so launching through you costs 150 KAS instead of 100.
Worth understanding before you price your product. The base deposit is not margin — it pays for transaction mass, covenant dust and the initial buy. Splitting it would make some creations run at a loss, so the platform cannot give up a share. You charge for the service you add, and your users see that price.
Custodial, unlike everything else
Deploying a KCC20 covenant, then a pool, then buying the first tokens takes a chain of transactions signed by a key that controls the deposit — a key your user does not have and should not need. So the server issues a single-use deposit address and drives the deployment itself.
There is no unsigned transaction to hand back here, and no way to create a token without your user paying. The deposit address is the payment.
// 1 — Open a session. Nothing is deployed, nothing is charged.
const session = await kr.tokens.createSession({
creatorAddress: userAddress, // who will own the token
name: "Rocket Coin",
ticker: "RKT",
description: "…",
imageUrl: "https://…",
})
// Show the user what to send, and where.
console.log(formatKas(session.amountSompi)) // "150"
console.log(formatKas(session.platformFeeSompi)) // "100" — the deployment
console.log(formatKas(session.partnerFeeSompi)) // "50" — yours
console.log(session.depositAddress)
// 2 — Wait for the transfer.
const { balance_sompi } = await kr.tokens.depositBalance(session.sessionId)
// 3 — Deploy, streaming each step.
const result = await kr.tokens.run(session.sessionId, (event) => {
if (event.message) bot.sendMessage(chatId, event.message)
})
console.log(result.status) // "completed"
console.log(result.tokenId) // the token id — trade it, list it, link to it
console.log(result.lpCovenantId) // its pool, needed by quote / swap / poolStatsRead `tokenId`, not `result.covenant_id`
The raw payload carries both, and the better-named one is the decoy. covenant_id is the hash of the script: it designates nothing on chain. The id the catalogue, the feed, poolStats and every quote speak is the one read back after genesis — which is what tokenId holds.
Getting it wrong fails silently: the decoy is 64 hex characters too, so nothing throws. Every later call simply returns empty or 404, with nothing pointing at the cause. Same pair for the pool — use lpCovenantId, not result.lp_covenant_id.
Never call `run` twice
The server runs the flow to completion in the background — it is never cut in the middle of a transaction, even if your process dies or the stream drops. If you lose the connection, poll kr.tokens.session(id) to find out where it got to.
Calling run again would pay for a second deployment.
An image is required, not optional
imageUrl is the one metadata field the server refuses to do without: a session opened without it comes back 422, before anything is deployed or charged. The SDK types it as required so your compiler says so first.
It is a deployment rule, not a style guide. A token with no image is indistinguishable from every other token with no image everywhere it appears — listings, search, the holder's wallet — and it cannot be fixed after the fact without a separate edit that only the creator can make.
When you get paid
Your share is an output of the final sweep — the transaction that returns the deposit's remainder to the treasury. It lands once the deployment succeeds, in the same transaction, and shows up in your statement marked broadcast. A session your user never funds costs neither of you anything and never appears.
Reference
Errors
KaspaRocketError carrying a kind you can branch on. The message is documentation, not a contract — do not parse it.| kind | Meaning | Retryable |
|---|---|---|
auth | Key missing, malformed, revoked, or for another network | no |
forbidden | Valid key, missing scope | no |
rate_limit | Quota exceeded — see retryAfterMs | yes |
invalid_request | Bad amount, address, or missing field | no |
conflict | Chain state refuses: liquidity, balance, order consumed | no |
unavailable | Node or database down | yes |
unsupported | Not implemented on this path | no |
network | Timeout, dropped connection, unreadable response | yes |
import { KaspaRocketError } from "@kasparocket/sdk"
try {
await kr.planTrade({ /* … */ })
} catch (e) {
if (e instanceof KaspaRocketError) {
if (e.kind === "rate_limit") await sleep(e.retryAfterMs ?? 1000)
if (e.retryable) return retry()
report(e.kind, e.message)
}
}Why `conflict` is not retryable
The chain state moved — you must re-quote. Retrying as-is would fail identically, or worse, succeed at a price your user never approved.
The SDK retries rate_limit, unavailable and network automatically (two attempts, exponential backoff with jitter). submit is excluded: replaying a broadcast can double-send, and that call is yours to make.
Reference
Methods
Trading
| Method | Returns | Billable |
|---|---|---|
quote(params) | Price against current pool state. Show amountTokensDisplay, not amountTokens | no |
route(params) | Curve / book split, cost, commitment | no |
estimateFees(flow) | Fee split, computed locally | no |
planTrade(params) | Unsigned plan — curve and book | yes |
planSwap(params) | Unsigned plan — curve only | yes |
planFillOrder(params) | Unsigned plan — consume one order | yes |
planCreateOrder(params) | Unsigned plan — place a limit order. Returns order_row | no |
planCancelOrder(params) | Unsigned plan — reclaim funds | no |
planSponsor(params) | Unsigned plan — buy a promoted slot. You keep 30 % | yes |
planMerge(params) | Unsigned plan — consolidate scattered token UTXOs | no |
planSend(params) | Unsigned plan — send KAS | no |
planTransfer(params) | Unsigned plan — send tokens | no |
submit(transactions, orderRow?) | Broadcast signed transactions | no |
signAndSubmit(plan, signer) | Sign then broadcast, in order. Forwards order_row | no |
Settling
Broadcasting is not settling. Chaining a plan onto the previous one's outputs before the network has caught up fails on UTXO not found — and between two legs of a split order you must wait for the indexer, not just the node, or you route against a stale book.
| Method | Answers |
|---|---|
isUtxoConfirmed(address, txid) | Is the node aware of this UTXO? |
txIndexed(txid) | Has the indexer digested it? Stronger, and a different question |
waitTxIndexed(txid, timeoutMs?) | Polls until it has. Throws on timeout rather than returning false |
Market data
| Method | Returns |
|---|---|
protocol() | Fee schedule and limits in force for you |
orderBook(tokenId) | Resting bids and asks |
orders(address, opts?) | An address's resting orders. { includeClosed: true } adds the filled and cancelled ones |
holdings(address) | Tokens held |
balance(address) | KAS balance |
candles(lp, tokenId, opts) | OHLC candles |
poolStats(lp, tokenId) | Pool statistics |
catalogue.feed(opts) | The token list to SHOW — sponsored, then skins, then 24 h volume. With since, a chronological feed of new launches instead |
Catalogue
Names, images, comments, activity — and the covenant lookup every trade needs. Nothing here touches the chain and nothing is billed. Reads are public; writes require the catalogue:write scope.
// The pool of a token. Start here — see Quickstart.
const pool = await kr.catalogue.poolCovenant(tokenId) // CovenantRecord | null
await kr.catalogue.covenants(tokenId) // the raw list
// A flat ARRAY, not an envelope. The route currently ignores paging.
const tokens = await kr.catalogue.tokens() // TokenRecord[]
const token = await kr.catalogue.token(tokenId) // the DETAIL shape
await kr.catalogue.search("rocket")
await kr.catalogue.sections()
// Paginated routes, each with its OWN payload key.
const { comments, pagination } = await kr.catalogue.comments(tokenId)
const { items } = await kr.catalogue.activity(address)
const { tokens: rows } = await kr.catalogue.analytics({ page: 1 })
// The championship: levels and standings in one call.
const c = await kr.catalogue.championship()
nextLevel(c.levels, c.tokens[0]) // the level being aimed atThe list and the detail are not the same shape
/api/tokens returns token_name and image_url; /api/tokens/:id returns name and image, plus creator, marketCap (always 0 — see Discovery) and socials. Declaring one shape for both would have been a lie that typechecks, so TokenRecord marks everything but token_id optional. Reading a name from the list? token_name ?? name.
The three paginated routes wrap their payload under three different keys — comments, items, tokens — all beside a shared pagination. That is the server's doing; the SDK states it rather than papering over it with a mapping layer that would silently yield undefined the day a key is renamed.
Reference
Limits
| Limit | Value | Notes |
|---|---|---|
| Requests per minute | 120 per key | Raised on request |
| Order fills per transaction | 3 | The router splits beyond that |
| KCC20 inputs per transaction | 10 | Contract-enforced |
| Smallest limit order | limits.minOrderValueSompi | Tokens × price, at placement |
| Request timeout | 30 s default | Configurable via timeoutMs |
A publishable key's quota is shared across all your visitors. If you expect real traffic, tell us up front.
Read the order floor before sizing an input
const { limits } = await kr.protocol()
if (tokens * priceSompi < limits.minOrderValueSompi) {
return refuse("order below the minimum value")
}It used to surface only as a 422 at submit time, after the user had already typed an amount and signed. Note that the floor applies to placement only — an order filled down below it stays fillable, so never hide a resting order because it now sits under the threshold.