SDK v0.1.6TypeScriptESM + CommonJSNode 18+

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

KaspaRocket redirects a third of what it charges to the partner who brought the trade. Not a referral bonus, not a campaign — it is how the fee is split, on every swap, trade and order fill you build.

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.

CapabilityEarns 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

bash
npm install @kasparocket/sdk

Both module formats are shipped, so existing bot codebases work unchanged:

javascript
import { KaspaRocket } from "@kasparocket/sdk"       // ESM
const { KaspaRocket } = require("@kasparocket/sdk")  // CommonJS

TypeScript 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:

next.config.ts
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

A key is optional — everything works without one. It identifies you as a partner, which is what earns you a share of the fees.

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.

KindPrefixWhereRestriction
Secretkr_live_…Server onlynone
Publishablekrpub_live_…Browserdeclared origins
typescript
// 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 design

The 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

Resolve the pool, quote, preview, build, sign. Every trade goes through these five steps.
buy.ts
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

Kaspa amounts are 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.
typescript
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

typescript
JSON.stringify(quote)   // ✗ Do not know how to serialize a BigInt
fee.total / 2           // ✗ Cannot mix BigInt and other types

Logging a quote, caching it in Redis, returning it from a webhook — a bot does this constantly, and every one of those throws.

typescript
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 bigint

toJsonSafe 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

Tokens travel as integer raw units, exactly as KAS travels in sompi. Print one as-is and it reads a thousand times too large.
typescript
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 token

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

typescript
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"
FieldUnitUse it to
amountTokensraw bigintCompute, compare, pass back to the SDK
amountTokensDisplaytokens, stringDisplay. 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

The SDK does not sign. You supply a Signer, and your key never passes through our code.
typescript
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:

  • sigOpCount leaves the digest — both the aggregate hash after the sequences, and the per-input byte. rusty-kaspa gates both on tx.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.

typescript
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

A limit order carries one extra field that must survive the round trip. Drop it and your user's funds are stranded.

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.

typescript
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

One call returns the token list in the order KaspaRocket shows it — paid placements first, then volume.
typescript
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`)
}
RankplacementWhy it is there
1sponsoredA creator paid for the slot
2skinA creator paid for a skin — skin_id says which
3organicRanked 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.

typescript
// 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.

typescript
// 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 you

One 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

typescript
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 share

Total 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

Without a key nothing changes. With one, the platform fee splits between us and you.
RecipientWithout partnerWith partner
KaspaRocket treasurymax(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 tradedDirect totalTotal via youYou earn
10 KAS2 KAS3 KAS1 KAS
100 KAS2 KAS3 KAS1 KAS
200 KAS3 KAS3 KAS1 KAS
1,000 KAS15 KAS15 KAS5 KAS
10,000 KAS150 KAS150 KAS50 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.

typescript
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 you

Estimating without a round trip

typescript
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

Launch a token from your own interface and earn 50 KAS on each one. Requires the 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.

launch.ts
// 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 / poolStats

Read `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

Every failure is a KaspaRocketError carrying a kind you can branch on. The message is documentation, not a contract — do not parse it.
kindMeaningRetryable
authKey missing, malformed, revoked, or for another networkno
forbiddenValid key, missing scopeno
rate_limitQuota exceeded — see retryAfterMsyes
invalid_requestBad amount, address, or missing fieldno
conflictChain state refuses: liquidity, balance, order consumedno
unavailableNode or database downyes
unsupportedNot implemented on this pathno
networkTimeout, dropped connection, unreadable responseyes
typescript
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

MethodReturnsBillable
quote(params)Price against current pool state. Show amountTokensDisplay, not amountTokensno
route(params)Curve / book split, cost, commitmentno
estimateFees(flow)Fee split, computed locallyno
planTrade(params)Unsigned plan — curve and bookyes
planSwap(params)Unsigned plan — curve onlyyes
planFillOrder(params)Unsigned plan — consume one orderyes
planCreateOrder(params)Unsigned plan — place a limit order. Returns order_rowno
planCancelOrder(params)Unsigned plan — reclaim fundsno
planSponsor(params)Unsigned plan — buy a promoted slot. You keep 30 %yes
planMerge(params)Unsigned plan — consolidate scattered token UTXOsno
planSend(params)Unsigned plan — send KASno
planTransfer(params)Unsigned plan — send tokensno
submit(transactions, orderRow?)Broadcast signed transactionsno
signAndSubmit(plan, signer)Sign then broadcast, in order. Forwards order_rowno

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.

MethodAnswers
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

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

typescript
// 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 at

The 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

LimitValueNotes
Requests per minute120 per keyRaised on request
Order fills per transaction3The router splits beyond that
KCC20 inputs per transaction10Contract-enforced
Smallest limit orderlimits.minOrderValueSompiTokens × price, at placement
Request timeout30 s defaultConfigurable 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

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

Documenting SDK v0.1.6. The rates on this page are read from the protocol configuration, not typed by hand — they cannot drift from what the server charges.