Official TypeScript SDK for the Kash public API.
- Native
fetch— zero runtime dependencies beyond Zod. - ESM + CJS +
.d.ts— Node 22+, modern browsers, Deno, Bun. - End-to-end typed — every response is Zod-validated; drift between API and SDK fails CI.
- Stripe-style resources —
kash.markets.list(),kash.trades.create(). - Production-grade resilience — bounded retries, exponential backoff with
jitter,
Retry-Afterhonouring, AbortController support. - Observability built in —
onRequest/onResponse/onRetry/onErrorhooks for logging, tracing, metrics — without the SDK picking a logger.
- Getting started — sign up, get a key, make your first call
- Install
- Which package do I need? —
@kashdao/sdkvs@kashdao/protocol-sdk - API keys — scopes, limits, allowlists, rotation
- Test mode vs live mode
- Quickstart
- Configuration
- API versioning — pinning behaviour to a specific dated server release
- Resources
- Pagination
- Polling
- Errors
- Idempotency
- Rate limits & retries
- Timeouts and aborts
- Observability
- Webhook signature verification
- Troubleshooting
- Testing your integration —
@kashdao/sdk/testingmock client - Recipes
- What's NOT in this SDK yet
- Compatibility
- Bundle size
- Versioning
- Support & community
- Contributing
- License
A first trade in five steps. Total time: about 5 minutes.
🧪 Staging release. Production endpoints (
api.kash.bot) and the self-service key-issuance dashboard are not yet live. Today onlykash_test_*keys work — the SDK auto-routes them to staging (api-staging.kash.bot). To request a key, emailengineering@kash.botwith your intended use case. Self-service issuance,kash_live_*keys, and the production API all land with the v1.0 launch.
Sign up at https://app.kash.bot. The app walks you through wallet provisioning — a Privy-managed smart account is created automatically; you don't need to bring your own wallet.
Email engineering@kash.bot with your
intended use case to request a kash_test_* staging key. Operators
will issue the key (scoped, with the limits below) and reply with the
plaintext secret out-of-band. When self-service issuance ships at
v1.0 this step moves to Settings → API Keys → Create API Key in
the app. The key shape:
| Field | What it does |
|---|---|
| Name | Free-form label, shown in audit logs (e.g. acme-prod-trader). |
| Mode | Live (real USDC on Base mainnet) or Test (simulated, Base Sepolia). |
| Scopes | Permission set — see API keys. |
| IP allowlist | Optional list of CIDR ranges. Empty = any IP. Recommended for production keys. |
The plaintext secret is shown once — copy it now. The value looks like:
kash_live_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuV
kash_test_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuV
The live/test prefix tells the SDK and the API which network the key
operates on. They're not interchangeable.
# .env (or your CI / Vercel / Railway / Fly secrets store)
KASH_API_KEY=kash_test_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuV
⚠️ Never commit keys to source control. Use environment variables, a secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault, 1Password, Doppler), or your platform's encrypted config (Vercel/Netlify/Render).
npm install @kashdao/sdk # or pnpm add / yarn add / bun addRequires Node.js 22+, or any modern browser, Deno 1.37+, or Bun 1.0+. See Compatibility + the Install section for all install paths.
import { KashClient } from '@kashdao/sdk';
const kash = new KashClient({ apiKey: process.env.KASH_API_KEY });
// First call. Every resource method needs an API key — only
// `kash.healthCheck()` is fully unauthenticated.
const markets = await kash.markets.list({ status: 'ACTIVE', limit: 5 });
console.log(`Found ${markets.data.length} active markets`);
console.log(markets.data[0]?.title);If that prints a market title, you're connected. Continue to Quickstart for the full trade lifecycle.
npm install @kashdao/sdk
# or
pnpm add @kashdao/sdk
# or
yarn add @kashdao/sdk
# or
bun add @kashdao/sdkIf you're working from main before a tag is cut (e.g. trying a fix
before it lands on npm), install directly from the GitHub mirror:
npm install github:KashDAO/sdk-typescript#mainThe package ships ESM + CJS dual builds with full .d.ts. No bundler
configuration required — works out of the box with Next.js, Remix,
Vite, esbuild, Webpack, Turbopack, Rollup, tsc.
Kash ships two independent SDKs for two different audiences. They have no shared package boundary — install whichever fits your use case (or both, if you need both).
Every Kash SDK and the public API are non-custodial. User funds always sit in Privy-managed MPC smart accounts that the user controls — Kash never holds keys, never custodies funds, never moves value, and is not a money-services business. The split below is about who orchestrates execution (Kash backend vs. you), not custody. See SECURITY.md § Non-custodial design for the full statement.
import { KashClient } from '@kashdao/sdk';
const kash = new KashClient({ apiKey: process.env.KASH_API_KEY });
const trade = await kash.trades.create({...});- What it wraps: the Kash public REST API (
api.kash.bot/v1/*). - Auth: Kash API key (
kash_live_…/kash_test_…) — a scoped, revocable delegation the user issues against their own Privy-managed smart account. Kash never sees signing keys; Privy MPC keeps them with the user. - Custody: every state-changing on-chain action is signed by the user's smart account inside Privy MPC. Kash never holds funds, never moves funds, and never signs anything. See SECURITY.md § Non-custodial design for the full statement.
- Dependencies: just Zod. No viem, no bundler client, no chain RPC required.
- Best for: trading bots, dashboards, fintech integrations, anywhere you want a single REST surface over the user's smart account without running your own signer/RPC/bundler.
Sibling package — @kashdao/protocol-sdk (self-orchestrated, on-chain)
A standalone package for talking directly to the Kash protocol contracts without going through the Kash API. Brings your own signer (HSM, Fireblocks, web3signer, viem account, browser wallet), your own RPC, your own bundler. Transactions never touch Kash servers. (Same non-custodial model as the wrapper above; the difference is the orchestration layer is yours, not Kash's.)
- Install separately:
npm install @kashdao/protocol-sdk viem. - Best for: market makers, AI-agent runners, self-custody UIs, anyone building infra where Kash should not be in the latency or trust path.
- See its README for the full surface.
npm install @kashdao/sdk @kashdao/protocol-sdk — they coexist
cleanly. A common pattern is using @kashdao/sdk for fast public
reads (markets list, quotes) and @kashdao/protocol-sdk for the
actual on-chain trade signing.
If you want a one-binary entry point that wraps both packages above
behind a single kash … command — with multi-profile auth, JSON
output for AI agents, shell completion, and kash protocol … for
the on-chain path — see @kashdao/cli.
The on-chain protocol SDK ships a Python sibling at
kashdao-protocol-sdk —
the canonical Hummingbot integration path. Cross-language parity is
validated by byte-equal test fixtures.
API keys are issued by emailing engineering@kash.bot (during the
staging release; self-service issuance under Settings → API Keys
ships at v1.0).
A key carries a comma-separated list of scopes. The SDK calls the right
endpoint for each operation; if your key lacks the scope, the request
returns 403 INSUFFICIENT_SCOPE and the SDK throws
KashAuthorizationError.
| Scope | Unlocks |
|---|---|
markets:read |
kash.markets.list(), kash.markets.get(), kash.markets.predictions(). Granted by default on every tier. |
markets:quote |
kash.quotes.buy(), kash.quotes.sell(). Split from markets:read because quotes are RPC-heavy and customers may want to throttle quote traffic independently. Granted by default. |
trades:read |
kash.trades.list(), kash.trades.get(), kash.traces.get(). |
trades:write |
kash.trades.create(), kash.trades.confirm(), kash.trades.waitForCompletion(). |
portfolio:read |
kash.portfolio.get(), kash.portfolio.positions(). |
webhooks:manage |
kash.webhooks.list(), kash.webhooks.redeliver(), kash.webhooks.rotateSecret(). |
auth:manage |
kash.account.usage() plus API key self-service in the app (issue / list / revoke own keys — those operations aren't exposed in the SDK yet). |
kash.healthCheck()is the only fully public method — no API key required. Every other resource method authenticates via API key.
💡 Principle of least privilege: a read-only dashboard should hold a key with only
markets:read+markets:quote+portfolio:read+trades:read. Reservetrades:writefor the service that actually places trades.
Optional ip_allowlist (list of CIDRs) on each key. Empty allowlist = any
IP. When configured, requests from non-allowlisted IPs return
403 IP_NOT_ALLOWED (the SDK throws KashAuthorizationError).
For production keys, set an allowlist that includes your server egress IPs. For desktop/CLI keys, leave empty.
Keys never auto-rotate. To rotate:
- Create a new key in the app with the same scopes.
- Deploy your service with the new key.
- Verify it's working (check the dashboard's "last used" timestamp on each key).
- Revoke the old key.
For webhook signing secrets, the SDK ships a one-call rotation:
const { secret } = await kash.webhooks.rotateSecret();
console.log('new secret (shown once):', secret);The previous secret is retained server-side for 7 days for emergency
rollback (operator-only, via kash-admin). It's NOT used for signing —
deliveries are signed with the new secret immediately upon rotation.
During the staging release, email engineering@kash.bot with the
key prefix (kash_test_XXXXXX…) to request revocation; operators
revoke effective immediately. After v1.0, self-service revocation
lands under Settings → API Keys → … → Revoke. Either way,
subsequent requests return 401 API_KEY_REVOKED
(KashAuthenticationError).
Two parallel environments, distinguished by the API key prefix:
| Test | Live | |
|---|---|---|
| Key prefix | kash_test_… |
kash_live_… |
| API base URL | https://api-staging.kash.bot/v1 |
https://api.kash.bot/v1 |
| Blockchain | Base Sepolia (testnet) | Base mainnet |
| USDC | Test USDC (faucet via the app) | Real USDC |
| Markets | Test markets only | Live markets only |
| Webhook secrets | whsec_… (separate from live) |
whsec_… (separate) |
// kash_test_… → staging
const test = new KashClient({ apiKey: process.env.KASH_TEST_KEY });
// kash_live_… → production
const live = new KashClient({ apiKey: process.env.KASH_LIVE_KEY });
// No key → production base URL by default. Construction succeeds,
// but every resource method except `healthCheck()` will throw
// `KashAuthenticationError` until you set an apiKey or env var.
const probe = new KashClient();
await probe.healthCheck(); // ✓ unauthenticatedThe base URL is inferred from the key prefix. You can still override explicitly when you need to (private mirror, local mock, future region):
new KashClient({
apiKey: process.env.KASH_TEST_KEY,
baseUrl: 'https://my-private-mirror.example.com/v1',
});The SDK picks the first match:
- Explicit
baseUrlin the constructor config KASH_BASE_URLenvironment variable- Inferred from
apiKeyprefix (kash_test_*→ staging;kash_live_*→ production) - Default:
https://api.kash.bot/v1(production)
The mirroring constants are exported if you need them:
import { PRODUCTION_BASE_URL, STAGING_BASE_URL } from '@kashdao/sdk';Most apps just need the auto-route. If you want to be extra explicit (or you're switching between mock servers in CI), the manual form still works:
const isProd = process.env.NODE_ENV === 'production';
export const kash = new KashClient({
apiKey: isProd ? process.env.KASH_LIVE_KEY! : process.env.KASH_TEST_KEY!,
// baseUrl is auto-routed from the key, but you can pin it:
// baseUrl: isProd ? PRODUCTION_BASE_URL : STAGING_BASE_URL,
});
⚠️ A live key sent to the staging URL (or vice versa) returns401 API_KEY_INVALID. The auto-route prevents this for you, but if you overridebaseUrlexplicitly, the prefix and URL must match.
The auto-route covers api.kash.bot (production) and
api-staging.kash.bot (staging). For anything else — record-and-replay
tooling, a private mirror, a corporate egress proxy, or a mock HTTP
server you maintain yourself — pass baseUrl explicitly or set
KASH_BASE_URL:
new KashClient({
apiKey: process.env.KASH_API_KEY,
baseUrl: 'http://localhost:4010/v1', // your proxy / mock server
});The cross-mode hint that fires on staging↔production key/URL mismatch is silent for non-canonical URLs, so custom hosts work without false warnings.
There's no Kash-managed local API server you can spin up — the API itself is hosted infrastructure. If you're trying to develop your integration without hitting a real server, you have two better choices:
-
Unit tests with no HTTP at all — use the
@kashdao/sdk/testingmock client. It's structurally compatible withKashClient, returns wire-shape-valid fixtures by default, and lets you override any method to simulate success or error paths. Zero network, zerofetch, zero retries. -
Integration tests against a real server — use a
kash_test_*key. The auto-route sends every call to staging (api-staging.kash.bot), where trades execute on Base Sepolia with simulated funds. No real money, full lifecycle. See Test mode vs live mode.
The baseUrl override exists for the genuinely-custom cases above —
not as a stand-in for a local Kash environment that doesn't exist.
import { KashClient } from '@kashdao/sdk';
const kash = new KashClient({ apiKey: process.env.KASH_API_KEY });
const trade = await kash.trades.create({
marketId: 'market-uuid',
outcomeIndex: 0,
amount: '100',
side: 'buy',
});
const completed = await kash.trades.waitForCompletion(trade.id, {
timeoutMs: 60_000,
onStatus: (t) => console.log('status:', t.status),
});
console.log('done:', completed.txHash);That's the trade lifecycle: create → wait → done. For the high-value
confirmation path, see examples/06-high-value-trade.ts.
The SDK reads KASH_API_KEY and KASH_BASE_URL from the environment
when you don't pass them explicitly:
// Auto-discovery — picks up KASH_API_KEY + KASH_BASE_URL from env
const kash = new KashClient();
// Or explicit (always wins over env):
const kash = new KashClient({ apiKey: 'kash_live_…' });
// Health probe — returns { ok, latencyMs, version } without throwing.
// Run this on app boot to verify connectivity:
const health = await kash.healthCheck();
if (!health.ok) console.error('Cannot reach Kash');Full config:
new KashClient({
apiKey: process.env.KASH_API_KEY, // required for every resource method except healthCheck()
baseUrl: 'https://api.kash.bot/v1', // default
timeoutMs: 30_000, // per-request timeout
maxRetries: 3, // 0 disables retry
retryBaseDelayMs: 200, // exponential backoff base
retryMaxDelayMs: 10_000, // cap per attempt
retryAfterMaxMs: 60_000, // cap on server-supplied Retry-After
userAgentSuffix: 'acme-trader/1.4.2', // identifies your app in our logs
headers: { traceparent: '00-…' }, // custom default headers (cannot override SDK headers)
fetch: customFetch, // for tests / proxies
hooks: {
/* onRequest, onResponse, onRetry, onError — see Observability */
},
});Misconfiguration throws KashConfigurationError — every issue is exposed via
.issues so structured loggers can render the full list. The returned
kash.config is Object.freeze-d; mutations throw at runtime.
The apiKey is shape-validated (kash_(live|test)_<32 alphanumeric>)
at construction. If you accidentally paste your whsec_… webhook secret
in place of your API key, the constructor catches it within microseconds
and explains the difference instead of letting the server return a 401.
| Variable | Default | Purpose |
|---|---|---|
KASH_API_KEY |
(none — required at call time) | Used when apiKey config is omitted |
KASH_BASE_URL |
https://api.kash.bot/v1 |
Used when baseUrl config is omitted |
Explicit config always wins over env. Browsers and edge runtimes
without process.env simply skip the auto-discovery — no error.
const tenantA = kash.withConfig({ apiKey: tenantAKey });
const slow = kash.withConfig({ timeoutMs: 120_000 });withConfig returns a brand-new client — the original is untouched.
Validation runs again, so invalid overrides throw KashConfigurationError.
The Kash API uses dated versioning (Stripe-style): every breaking
server-side change ships under a new dated version (YYYY-MM-DD).
Older dated versions keep working — your code only sees the new
behaviour when you opt in.
Every SDK release pins a default API version — the dated version the SDK was tested against. When the server ships a newer dated version with breaking behaviour, your existing SDK install keeps working unchanged.
import { KashClient, SDK_API_VERSION } from '@kashdao/sdk';
// Sends `X-Kash-Api-Version: <SDK_API_VERSION>` on every request:
const kash = new KashClient({ apiKey });
// Override to opt into newer behaviour ahead of an SDK upgrade:
const kash = new KashClient({ apiKey, apiVersion: '2026-08-15' });
// Or pin a specific older version for compatibility testing:
const kash = new KashClient({ apiKey, apiVersion: '2026-01-01' });The constant SDK_API_VERSION is exported so consumers can log it
alongside their error reports:
import { SDK_API_VERSION, SDK_VERSION } from '@kashdao/sdk';
logger.info(`kash sdk@${SDK_VERSION} api=${SDK_API_VERSION}`);To pick up newer server-side behaviour:
- Read the API version policy at docs.kash.bot/developer-docs/rest-api/overview — every dated version's lifecycle (Sunset / Deprecation headers) is documented there.
- Upgrade
@kashdao/sdk(which bumpsSDK_API_VERSIONand runs the contract tests against the new version). SetapiVersion: 'YYYY-MM-DD'to declare the version your client is built against.
Server behaviour: the public API reads the X-Kash-Api-Version
request header (sent automatically by the SDK from your
apiVersion config) and routes the request through a
version-appropriate code path. Header absent → server uses its
canonical PUBLIC_API_VERSION. Header present and recognised →
request honours the pin. Header present but unrecognised → 410 API_VERSION_UNSUPPORTED with metadata.supported listing every
accepted version. The server's canonical version is also emitted
as the X-API-Version response header on every request — surfaced
on the SDK's onResponse hook as apiVersion so you can detect
server-side rollouts:
const kash = new KashClient({
apiKey,
hooks: {
onResponse: ({ apiVersion }) => {
if (apiVersion && apiVersion !== SDK_API_VERSION) {
log.warn('server.api-version drift', { server: apiVersion, sdk: SDK_API_VERSION });
}
},
},
});The full list of currently-accepted versions is surfaced in every
410 API_VERSION_UNSUPPORTED response body under
metadata.supported, and listed at
api.kash.bot/v1/openapi/index.json.
The hosted error-catalogue page at
docs.kash.bot/developer-docs/api-errors/API_VERSION_UNSUPPORTED
documents the full negotiation contract.
Versions are strict ISO dates: YYYY-MM-DD. The SDK rejects malformed
values at construction with KashConfigurationError.
const market = await kash.markets.get('market-uuid');
const page = await kash.markets.list({ status: 'ACTIVE', limit: 50 });
// Recent trades feed (live activity). Cursor-paginated; needs `markets:read` scope.
const feed = await kash.markets.predictions('market-uuid', {
side: 'buy', // optional filter
outcomeIndex: 0, // optional filter
limit: 50, // default 50, max 100
});
for (const t of feed.data) console.log(t.side, t.price, t.timestamp);
// Or stream every trade across pages:
for await (const t of await kash.markets.predictions('market-uuid')) {
// newest first; break early to stop
}On-chain price quotes — calls the AMM's view functions via the public
API and returns the projected outcome of a buy or sell without actually
executing it. Requires an API key with the markets:quote scope
(granted by default on every tier; split from markets:read because
quotes are RPC-heavy).
// Buy quote: 100 USDC into outcome 0
const buy = await kash.quotes.buy({
marketId,
outcomeIndex: 0,
amountUsdcAtomic: 100n * 1_000_000n, // 100 USDC * 1e6
});
console.log(`tokens out: ${buy.tokensOut} (WAD)`);
console.log(`implied probability after trade: ${buy.impliedProbability}`);
// Sell quote: 1 outcome-0 token
const sell = await kash.quotes.sell({
marketId,
outcomeIndex: 0,
tokensInWad: 1n * 10n ** 18n,
});
console.log(`USDC out (atomic): ${sell.usdcOut}`);All bigint contract returns surface as decimal strings — JSON has no
native bigint, so the API uses strings to round-trip losslessly. Cast to
BigInt(...) for math. The response also carries the embedded market
summary (id, contract, outcomes, status) so a single quote call gives
you everything you need to render a trade preview.
The endpoint is cached 10 seconds server-side. Quote at the size you want to trade — slippage scales non-linearly with size, so don't extrapolate from a 1-USDC quote.
// Place a trade. Returns the trade itself with `idempotent` flag and
// optional `confirmation` (set when the high-value gate fires).
const trade = await kash.trades.create(body, { idempotencyKey: 'order-1' });
if (trade.confirmation) {
await kash.trades.confirm(trade.id, { token: trade.confirmation.token });
}
// Fetch one.
const t = await kash.trades.get(id);
// List with cursor pagination.
const trades = await kash.trades.list({ status: 'pending,completed', limit: 50 });
// Block until terminal status.
const done = await kash.trades.waitForCompletion(id);Every kash.trades.create() call accepts an optional metadata map —
Stripe-pattern customer-supplied tags that ride through to the trade
row and every webhook payload's data.metadata field. Use it to
correlate trades with your own order ids, strategy names, tenant
identifiers, A/B-test cohorts, etc.
const trade = await kash.trades.create({
marketId,
outcomeIndex: 0,
amount: '100',
side: 'buy',
metadata: {
orderId: 'order_2026_05_02_001',
strategy: 'momentum-v2',
tenantId: 'acme-corp',
},
});
// Always present on read (defaults to `{}` when none supplied).
console.log(trade.metadata.strategy); // → 'momentum-v2'Constraints (enforced server-side; the SDK validates client-side via
TradeMetadataSchema):
- ≤ 10 keys per trade
- Keys: 1–64 chars,
[a-zA-Z0-9_\-.]only - Values: strings only, ≤ 500 chars
import { TradeMetadataSchema } from '@kashdao/sdk';
// Validate before sending — surfaces a typed Zod issue rather than a 400.
const result = TradeMetadataSchema.safeParse({ orderId: 'order_1' });
if (!result.success) console.error(result.error.issues);Avoid spelling status enum literals at every branch — use the typed guards:
import {
isAwaitingConfirmation,
isCompletedTrade,
isFailedTrade,
isPendingTrade,
isRejectedTrade,
isTerminalTrade,
} from '@kashdao/sdk';
const t = await kash.trades.get(id);
if (isAwaitingConfirmation(t)) {
// t.status is narrowed to 'pending_confirmation'
} else if (isPendingTrade(t)) {
// t.status is narrowed to 'pending' | 'validating' | 'executing'
} else if (isTerminalTrade(t)) {
// t.status is narrowed to 'completed' | 'failed' | 'rejected'
if (isCompletedTrade(t)) console.log('tx:', t.txHash);
if (isFailedTrade(t) || isRejectedTrade(t)) console.error(t.errorCode, t.errorMessage);
}The three top-level guards (isAwaitingConfirmation, isPendingTrade,
isTerminalTrade) are mutually exclusive and exhaustive over every
trade status — the else chain above never falls through.
const summary = await kash.portfolio.get();
const positions = await kash.portfolio.positions({ marketId });// Verify the signature AND parse into a typed event in one call
// (Stripe pattern). Throws KashWebhookSignatureError on bad signature.
const event = await kash.webhooks.constructEvent(rawBody, header, secret);
// Browse recent deliveries — newest first; cursor-paginated.
// Filter by status to find stuck or failed events:
const page = await kash.webhooks.list({ status: 'failed', limit: 50 });
for (const event of page.data) {
console.log(event.id, event.status, event.delivery.lastFailureCode);
}
// Async-iterate every retrying event across pages:
for await (const event of await kash.webhooks.list({ status: 'retrying' })) {
// newest first; break early to stop
}
// Replay a previously-shipped event.
await kash.webhooks.redeliver(eventId);
// Rotate the signing secret. Returns the new secret ONCE.
const { secret } = await kash.webhooks.rotateSecret();// Per-key telemetry: trade volume + success rate + p50/p99 latency
// (24h / 7d / 30d windows), webhook delivery success rate (7d), and
// auth-failure / rate-limit-rejection counts (24h). Same data the
// app's API Keys settings page renders inline.
const usage = await kash.account.usage();
console.log(`24h success rate: ${usage.trades['24h'].successRate}`);
console.log(`24h p99 latency: ${usage.trades['24h'].latencyMs.p99}ms`);Requires the auth:manage scope (the same scope that issues + revokes
your own keys — "tell me about my own account" lives in the same lane).
Every trade emits a stream of events as it moves through the pipeline
(intent parsing → funding → bridge → execution → webhook delivery).
The traces endpoint returns the curated, sanitized event timeline
for a given correlation id — enough signal to reconstruct what
happened without exposing internal pipeline state.
// trade.correlationId is on every TradeResource.
const trace = await kash.traces.get(trade.correlationId);
for (const event of trace.events) {
console.log(event.occurredAt, event.type, event.data);
}Requires the trades:read scope. 404 covers both "unknown
correlation id" AND "you don't own the trade behind it" — the server
collapses the two by design to prevent enumeration. Both surface as
KashNotFoundError.
markets.list() and trades.list() return a Page<T> — at once the first
page (with .data, .hasMore, .nextCursor) and an AsyncIterable<T> that
walks every subsequent page on demand.
// Just the first page:
const page = await kash.markets.list({ limit: 50 });
console.log(page.data, page.hasMore);
// Walk explicitly:
const next = await page.getNextPage(); // null if there's no next page
// Stream every market across pages:
for await (const market of await kash.markets.list({ status: 'ACTIVE' })) {
console.log(market.id);
}The iterator terminates when pagination.cursor === null, when the server
returns a short page, or after 1,000 pages (safety cap against an upstream
cursor bug).
trades.waitForCompletion(id) polls until the trade reaches a terminal
status (completed, failed, or rejected).
const done = await kash.trades.waitForCompletion(tradeId, {
timeoutMs: 60_000, // default
pollIntervalMs: 2_000, // default
onStatus: (t) => console.log(t.status),
signal: abortController.signal,
});The helper refuses to poll a trade that is pending_confirmation — call
kash.trades.confirm(id, { token }) first with the token from the 202
response of trades.create().
Every failure throws a typed subclass of KashError. Branch on instanceof
or on the stable code string.
import {
KashAuthenticationError,
KashConfigurationError,
KashConflictError,
KashRateLimitError,
KashServerError,
KashTimeoutError,
KashValidationError,
} from '@kashdao/sdk';
try {
await kash.trades.create({ ...body });
} catch (err) {
if (err instanceof KashRateLimitError) {
console.error(`Rate limited; retry after ${err.retryAfterSeconds}s`);
} else if (err instanceof KashConflictError) {
console.error('Conflict:', err.code, err.message);
} else if (err instanceof KashAuthenticationError) {
console.error('Bad API key:', err.code);
} else if (err instanceof KashServerError) {
console.error('Server error:', err.code, err.requestId);
}
throw err;
}| Subclass | When | isRetryable |
|---|---|---|
KashAuthenticationError |
401 — bad / missing / revoked key | false |
KashAuthorizationError |
403 — missing scope, IP not allowed | false |
KashNotFoundError |
404 | false |
KashConflictError |
409 / 410 — idempotency, account state | false |
KashRateLimitError |
429, exposes retryAfterSeconds |
true |
KashValidationError |
400 / 422 + SDK-side schema mismatch | false |
KashServerError |
5xx, optional retryAfterSeconds |
true |
KashMaintenanceError |
503 kill switch | true |
KashTimeoutError |
SDK timeout fired | true |
KashNetworkError |
fetch threw (DNS, TLS, refused) |
true |
KashAbortedError |
Caller aborted (AbortSignal) |
false |
KashConfigurationError |
Constructor validation failure | false |
KashWebhookSignatureError |
webhooks.constructEvent signature mismatch |
false |
isRetryable: true errors only escape the SDK after maxRetries is exhausted.
When errors travel between realms (worker ↔ main thread, iframe ↔ parent,
or a hoisted-monorepo with two SDK copies), plain instanceof KashError
returns false because each realm has its own class identity. Use the
static helper:
if (KashError.isKashError(err)) {
// err.code, err.statusCode, err.requestId all typed
}The check is implemented via a Symbol.for('@kashdao/sdk:KashError')
brand that survives realm boundaries.
Every error carries:
code— stable, machine-readable string (e.g.MARKET_NOT_TRADEABLE)statusCode— HTTP status (when applicable)requestId—X-Request-IDfrom the response, for support ticketscontext: { method, path, attempt }— the failing call + how many triescause— the underlying error, if any.toJSON()— structured payload safe for production logs (nocause/stack)
Two independent mechanisms — pick one or both:
// Header-level — generic across all POST routes (24h TTL).
await kash.trades.create(body, { idempotencyKey: crypto.randomUUID() });
// Body-level — survives missing headers; tied to (user_id, client_request_id).
await kash.trades.create({ ...body, clientRequestId: 'order-2026-04-30-001' });Replays of the same idempotent request return the original response with
_meta.idempotent: true. Replays with a different body return 409 IDEMPOTENCY_KEY_CONFLICT (or 409 CLIENT_REQUEST_ID_CONFLICT). The SDK
does not auto-retry these — they signal a logical conflict the caller
must resolve.
The SDK retries 429 / 5xx / network errors / timeouts with exponential
backoff and full jitter, bounded by maxRetries (default 3).
Retry-After is parsed in both delta-seconds and HTTP-date forms. If the
server says "wait longer than retryAfterMaxMs," the SDK surfaces the error
immediately rather than retrying too soon — that respects the server's
intent and lets your application decide whether to back off further.
new KashClient({
apiKey,
maxRetries: 5,
retryAfterMaxMs: 30_000, // server says wait > 30s? throw, don't sleep + retry
});After maxRetries is exhausted, the final error exposes
retryAfterSeconds so application-level backoff can pick up where the SDK
left off.
Default per-request timeout is 30 seconds. Override globally via timeoutMs
or pass an AbortSignal to honour caller cancellations:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
await kash.trades.waitForCompletion(id, { signal: controller.signal });
// → throws KashAbortedError if it doesn't terminate firstKashAbortedError is not retryable — the consumer asked us to stop, so
we honour that even mid-backoff.
Wire the lifecycle hooks to your logger:
const kash = new KashClient({
apiKey,
hooks: {
onRequest: (e) => log.info({ msg: 'kash.request', ...e }),
onResponse: (e) => log.info({ msg: 'kash.response', ...e, ms: e.durationMs }),
onRetry: (e) => log.warn({ msg: 'kash.retry', ...e }),
onError: (e) => log.error({ msg: 'kash.error', ...e }),
},
});Hook payloads include method, url, attempt, idempotencyKey,
durationMs, status, requestId, code, reason. A throwing hook
cannot break the request path — exceptions in handlers are intentionally
swallowed.
onResponse and every KashError carry a rateLimit field parsed
from the server's X-RateLimit-Limit / X-RateLimit-Remaining /
X-RateLimit-Reset headers. Use it for client-side throttling:
const kash = new KashClient({
apiKey,
hooks: {
onResponse: (e) => {
if (e.rateLimit && e.rateLimit.remaining < 5) {
// ~5 calls left in the bucket — pause issuing requests.
scheduler.pauseFor(e.rateLimit.resetSeconds * 1000);
}
},
},
});
// And on the catch path:
try {
await kash.trades.create(body);
} catch (err) {
if (err instanceof KashRateLimitError && err.rateLimit) {
console.warn(`Rate limited; ${err.rateLimit.resetSeconds}s until reset.`);
}
}rateLimit is null when the response carried no rate-limit headers
(network errors, timeouts, upstream proxy failures), and undefined
when the error originated before any HTTP exchange (configuration
errors).
The recommended one-call helper is kash.webhooks.constructEvent
(Stripe pattern) — it verifies the X-Kash-Signature header AND
parses the body into a typed, narrowable WebhookEvent:
import express from 'express';
import { KashClient, KashWebhookSignatureError } from '@kashdao/sdk';
const kash = new KashClient({ apiKey: process.env.KASH_API_KEY! });
app.post('/webhooks/kash', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await kash.webhooks.constructEvent(
req.body.toString('utf8'),
req.headers['x-kash-signature'] as string,
process.env.KASH_WEBHOOK_SECRET!
);
// event.type narrows event.data:
if (event.type === 'trade.completed') {
await markOrderShipped(event.data.tradeId, event.data.txHash);
} else if (event.type === 'trade.failed') {
await flagOrder(event.data.tradeId, event.data.errorCode);
} else if (event.type === 'trade.confirmation-required') {
await notifyOperator(event.data.tradeId, event.data.confirmationExpiresAt);
}
return res.status(204).end();
} catch (err) {
if (err instanceof KashWebhookSignatureError) {
return res.status(400).send(err.message);
}
throw err;
}
});Customer-supplied metadata from the original trades.create()
call is echoed on every payload's data.metadata, so handlers can
branch (event.data.metadata.strategy === 'momentum-v2') without
re-fetching the trade.
Dedupe on event.id (also returned in the X-Kash-Event-Id header)
to make repeated deliveries safe — operator-triggered redeliveries
reuse the same id.
If you want to handle parsing yourself (e.g. you serialise the body
into a queue for downstream processing and the schema parse happens
elsewhere), use verifySignature directly:
The webhook delivery worker signs payloads with X-Kash-Signature:
X-Kash-Signature: t=1730000000000,v1=<hex-hmac-sha256>
Verify with:
import express from 'express';
import { KashClient } from '@kashdao/sdk';
const kash = new KashClient({ apiKey: process.env.KASH_API_KEY! });
app.post('/webhooks/kash', express.raw({ type: 'application/json' }), async (req, res) => {
const rawBody = req.body.toString('utf8');
const header = req.headers['x-kash-signature'] as string;
const result = await kash.webhooks.verifySignature(
rawBody,
header,
process.env.KASH_WEBHOOK_SECRET!
);
if (!result.valid) {
console.warn('bad signature:', result.reason);
return res.status(400).end();
}
// …process the event…
return res.status(204).end();
});
⚠️ Pass the raw bytes, not a parsed JSON object.JSON.stringify-ing a parsed object will not produce the same bytes (key order and whitespace differ) and the signature will silently fail.
⚠️ The verifier rejects empty secrets — fail-fast so a missing environment variable doesn't silently produce a "valid" signature against an empty key.
Replays carry the same X-Kash-Event-Id; dedupe on that to make repeated
deliveries (and operator-triggered redeliveries) safe.
Three usual causes:
- Mode mismatch. A
kash_test_…key sent tohttps://api.kash.bot/v1(live URL) is rejected, and vice versa. Check the table in Test mode vs live mode. - The key was revoked. Email
engineering@kash.botwith the key prefix to confirm (or, after v1.0, check Settings → API Keys). - The env var isn't loaded.
console.log(process.env.KASH_API_KEY?.slice(0, 10))should printkash_live_orkash_test_— if it printsundefined, your dotenv loader isn't running.
The endpoint you're calling needs a scope your key doesn't have. Check the scopes table. Add the missing scope in the app, then either rotate the key or revoke + reissue.
You're likely on Node ≤ 18. The SDK requires Node 22+ (or modern browser /
Deno / Bun). Check node --version.
Your baseUrl is pointed at something that isn't the API (a load balancer
default page, an HTML error page, etc.). Verify you're hitting
https://api.kash.bot/v1 (live) or https://api-staging.kash.bot/v1 (test).
Three usual causes:
- You parsed the body before verifying. Use
express.raw()/ Fastify's raw-body parser /await request.text()in Next.js. - The wrong secret. Confirm the value in
KASH_WEBHOOK_SECRETmatches the one rotated most recently for this API key. - Clock skew > 5 minutes. The SDK rejects timestamps outside a
5-minute tolerance by default. Tighten or relax via
toleranceMs.
Either raise timeoutMs or check for upstream issues. The default 30s
matches the server-side timeout cap — KashTimeoutError past 30s usually
means transport-layer trouble (DNS, TLS).
Inspect .issues for the precise field. Most common: apiKey is ''
because the env var wasn't set.
The trade hit the high-value confirmation gate. Capture the confirmation.token
from the 202 response and call kash.trades.confirm(id, { token }).
waitForCompletion refuses to poll until you do.
Your account's smart-wallet provisioning hasn't completed. New accounts take a few seconds to fully provision; the app shows a "wallet ready" indicator. Wait for that, then retry.
Your smart account doesn't have enough USDC to cover the trade. Top up via the on-ramp in the Kash app (production) or the test-USDC faucet (staging).
@kashdao/sdk/testing ships a fixture-driven mock client that satisfies
the same public surface as KashClient — no fetch, no retry, no
network. Use it in unit tests to assert behaviour without mocking the
HTTP layer:
import { createMockKashClient, DEFAULT_MARKET, DEFAULT_TRADE } from '@kashdao/sdk/testing';
import { KashRateLimitError } from '@kashdao/sdk';
// Unconfigured — every method returns a wire-shape-valid default:
const kash = createMockKashClient();
const market = await kash.markets.get('mkt-uuid'); // → DEFAULT_MARKET (with id='mkt-uuid')
const trade = await kash.trades.get('trd-uuid'); // → DEFAULT_TRADE
const page = await kash.markets.list(); // → Page<MarketResource>
// Override per resource:
const kash = createMockKashClient({
markets: {
get: (id) => ({ ...DEFAULT_MARKET, id, title: `Mock ${id}` }),
list: () => [DEFAULT_MARKET, { ...DEFAULT_MARKET, id: 'mkt-2' }],
},
trades: {
create: (body) => ({ ...DEFAULT_TRADE, marketId: body.marketId, idempotent: false }),
},
});
// Throw to simulate a server error (any KashError subclass works):
const kash = createMockKashClient({
trades: {
create: () => {
throw new KashRateLimitError('mock 429', {
code: 'RATE_LIMITED',
statusCode: 429,
retryAfterSeconds: 30,
});
},
},
});The mock is structurally compatible with KashClient — your code under
test takes a KashClient-shaped dependency and the mock plugs in
without any cast. Imports come from the testing subpath only, so
production bundles never pull in fixtures or factory code.
See examples/05-observability.ts.
Disable the SDK's retry and roll your own:
const kash = new KashClient({ apiKey, maxRetries: 0 });
async function withMyRetry<T>(fn: () => Promise<T>): Promise<T> {
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await fn();
} catch (err) {
if (!(err instanceof KashError) || !err.isRetryable) throw err;
await sleep(myBackoff(attempt));
}
}
throw new Error('exhausted');
}const isProd = process.env.NODE_ENV === 'production';
export const kash = new KashClient({
apiKey: isProd ? process.env.KASH_LIVE_KEY! : process.env.KASH_TEST_KEY!,
baseUrl: isProd ? 'https://api.kash.bot/v1' : 'https://api-staging.kash.bot/v1',
});const clients = new Map<string, KashClient>();
function clientFor(apiKey: string): KashClient {
let c = clients.get(apiKey);
if (!c) {
c = new KashClient({ apiKey });
clients.set(apiKey, c);
}
return c;
}The high-value gate fires before the trade enters the pipeline. Capture the token from the create response, confirm, then poll:
const trade = await kash.trades.create(body, { idempotencyKey: 'order-1' });
if (trade.confirmation) {
// Show a UI / log / require human approval — token is shown ONCE.
await kash.trades.confirm(trade.id, { token: trade.confirmation.token });
}
const done = await kash.trades.waitForCompletion(trade.id, { timeoutMs: 60_000 });
if (isCompletedTrade(done)) console.log('tx:', done.txHash);A 409 IDEMPOTENCY_KEY_CONFLICT means the same key was reused with a
different body. Don't retry — fix the caller. A 409 CLIENT_REQUEST_ID_CONFLICT
means the same client-request-id was reused; same rule.
try {
await kash.trades.create(body, { idempotencyKey: 'order-1' });
} catch (err) {
if (err instanceof KashConflictError && err.code === 'IDEMPOTENCY_KEY_CONFLICT') {
// The body changed since the first call. Either generate a new key
// (treating this as a different logical request) or fetch the
// original by id to inspect the prior result. NEVER auto-retry.
throw err;
}
throw err;
}Inject a traceparent per call so the API request shows up in your
trace alongside upstream spans:
const traceparent = `00-${traceId}-${spanId}-01`;
await kash.trades.create(body, {
idempotencyKey: orderId,
headers: { traceparent },
});Or set it as a default for every request from one logical scope:
const tenantClient = kash.withConfig({
headers: { traceparent: req.headers['traceparent'] },
});Rotation publishes a new secret immediately. The previous one is retained
for 7 days for emergency rollback (operator-only) — not used for signing.
Store both values during the rollover and try them in order. This is
one of the few cases where you want the lower-level verifySignature
primitive instead of constructEvent — constructEvent throws on the
first bad signature and short-circuits the fallback.
import { createHash } from 'node:crypto';
let active: string = process.env.KASH_WEBHOOK_SECRET!;
let previous: string | undefined;
app.post('/webhooks/kash', express.raw({ type: 'application/json' }), async (req, res) => {
const body = req.body.toString('utf8');
const sig = req.headers['x-kash-signature'] as string;
for (const secret of [active, previous].filter(Boolean) as string[]) {
const result = await kash.webhooks.verifySignature(body, sig, secret);
if (result.valid) return res.status(204).end();
}
return res.status(400).end();
});
// Rotate from a CLI / cron — in-memory swap is the simple version;
// distributed deployments need a shared store (Redis, KMS, secret manager):
async function rotate() {
const { secret } = await kash.webhooks.rotateSecret();
previous = active;
active = secret;
// Persist + notify the rest of the fleet via your secret store.
}kash.markets.predictions(id) is cursor-paginated newest-first. For a
live feed, poll the head every few seconds and stop once you reach the
last id you've already seen:
async function pollMarketTrades(marketId: string, lastSeen: string | null) {
const page = await kash.markets.predictions(marketId, { limit: 50 });
const fresh = lastSeen ? page.data.takeWhile((t) => t.id !== lastSeen) : page.data;
return { fresh, newLastSeen: page.data[0]?.id ?? lastSeen };
}
setInterval(async () => {
const { fresh, newLastSeen } = await pollMarketTrades(marketId, lastSeenId);
fresh.forEach(renderTrade);
lastSeenId = newLastSeen;
}, 3_000);For exporting / ETL, walk every page lazily — the iterator stops the
moment your break fires, so you never fetch beyond what you consume:
const cutoff = new Date('2026-01-01').toISOString();
const out: TradeResource[] = [];
for await (const trade of await kash.trades.list({ status: 'completed', limit: 100 })) {
if (trade.createdAt < cutoff) break;
out.push(trade);
}Both KashServerError and KashTimeoutError are retryable, but they
mean different things — the server saying "try again later" versus the
SDK giving up on a stuck socket. The error class is the discriminator:
try {
await kash.trades.waitForCompletion(id);
} catch (err) {
if (err instanceof KashTimeoutError) {
// SDK gave up; the trade may still complete — fetch it to find out.
const t = await kash.trades.get(id);
if (isTerminalTrade(t)) return t;
}
if (err instanceof KashServerError) {
// Server signalled an outage — back off and retry the get.
}
if (err instanceof KashConflictError && err.code === 'TRADE_NOT_AWAITING_CONFIRMATION') {
// You called waitForCompletion on a trade still at pending_confirmation.
}
throw err;
}For full transparency on the v0.1 surface — the following capabilities exist on the Kash platform but are not wrapped by this SDK release. They're either accessible via the API directly or planned for a future version:
- API key self-service (
auth:managescope) — issue/list/revoke keys programmatically. During the staging release, emailengineering@kash.botfor issuance/revocation; self-service ships at v1.0. - Streaming / WebSocket subscriptions — the API doesn't expose a
streaming endpoint yet. For now, poll with
waitForCompletion()for trade status and consume webhook deliveries for events. - Bridge / on-ramp / off-ramp flows — these go through the Kash app UI in v1, not the public API.
If any of these matter to you, open a discussion — prioritisation is driven by what real consumers need.
| Runtime | Status |
|---|---|
| Node.js 22+ | ✅ |
| Chrome 110+ | ✅ |
| Firefox 110+ | ✅ |
| Safari 16+ | ✅ |
| Deno 1.37+ | ✅ |
| Bun 1.0+ | ✅ |
| Cloudflare Workers | ✅ |
| Vercel Edge runtime | ✅ |
| Node.js ≤ 18 | ❌ |
The SDK uses native fetch, URL, AbortController, crypto.subtle, and
TextEncoder — all standard in modern runtimes.
| Format | Minified | Gzipped |
|---|---|---|
ESM (index.js) |
~82 KB | ~22 KB |
CJS (index.cjs) |
~86 KB | ~23 KB |
Bundle size is gated in CI at ≤ 24 KB gzipped ESM. Verify locally
with pnpm build && gzip -c dist/index.js | wc -c.
Zod is the only runtime dependency; it is not bundled (it's a peer dependency-style external).
0.x.y — pre-1.0; minor versions may include breaking changes. All
breaking changes are documented in CHANGELOG.md.
After 1.0:
- Major version: breaking change to public types or runtime behaviour.
- Minor version: new methods, new error subclasses, new config options.
- Patch version: bug fixes, doc improvements, internal refactors.
| Need | Where to go |
|---|---|
| Request a staging key | Email engineering@kash.bot with your use case — self-service issuance ships at v1.0 |
| Bug reports | GitHub issues — please include the SDK version, runtime, and requestId from the failing call |
| Feature requests | GitHub discussions |
| Security vulnerabilities | security@kash.bot — see SECURITY.md. Do NOT file public issues. |
| Examples | examples/ — runnable end-to-end scripts + framework starters/ (Next.js, Express, Cloudflare) |
When you file a bug, the most useful piece of context is the requestId
on the error you saw — it lets us pull server logs for the failing call
in seconds:
catch (err) {
if (KashError.isKashError(err)) {
console.error({
code: err.code,
requestId: err.requestId, // ← include this when reporting
context: err.context, // method/path/attempt
});
}
}See CONTRIBUTING.md. Security disclosures: see
SECURITY.md.
MIT — see LICENSE.