JavaScript SDK
Official TypeScript client for the PropDAO trading API. One file, zero dependencies, Node 18+ and browsers.
Installation REQUIRED
The SDK is a single TypeScript file with zero dependencies. Runs in Node 18+, Bun, Deno and browsers (uses fetch).
Option A — copy-paste. Save the source below as propdao.ts (or strip the types for propdao.js).
Option B — npm (package publishing soon):
npm install @propdao/sdk
Environment
export PROPDAO_API_KEY=pd_live_...
In the browser pass the key to the constructor instead — and never ship a live key in client code.
SDK source COPY & PASTE
Save as propdao.ts. Typed OrderOpts, Market and Risk, throws PropDAOError, auto intentId per order, waits out 409 races.
/**
* PropDAO agent SDK. Zero dependencies, works in Node 18+ and browsers.
*
* import { PropDAO } from "@propdao/sdk";
* const pd = new PropDAO(); // reads PROPDAO_API_KEY
* const [acct] = await pd.getAccounts();
* const { roomUsd } = await pd.getRisk(acct.account_id);
* await pd.marketBuy(acct.account_id, "BTCUSDC", 0.01, { leverage: 2, sl: 60000 });
*/
export type Side = "BUY" | "SELL";
export type OrderType = "market" | "limit" | "stop_market" | "stop_limit" | "take_market" | "take_limit" | "scale" | "twap";
export interface OrderOpts {
orderType?: OrderType;
leverage?: number;
limitPrice?: number;
triggerPrice?: number;
sl?: number;
tp?: number;
/** "gtc" (default) | "ioc" | "alo" (post-only) */
tif?: "gtc" | "ioc" | "alo";
reduceOnly?: boolean;
/** Idempotency key; auto-generated if omitted so a retry never double-fills. */
intentId?: string;
twapMs?: number; twapMin?: number; twapMax?: number; twapTrigger?: number;
twapRandomize?: boolean; twapMaxSlippagePct?: number;
scaleStart?: number; scaleEnd?: number; scaleCount?: number; scaleDist?: string;
}
export interface Market {
symbol: string;
coin: string;
dex: string | null;
maxLeverage: number;
lotStep: number;
szDecimals: number;
}
export interface Risk {
equity: number;
balance: number;
floor: number;
floorKind: "max" | "daily";
maxFloor: number;
dailyFloor: number;
roomUsd: number;
roomPct: number;
breached: boolean;
openPositions: number;
}
export class PropDAOError extends Error {
constructor(public status: number, message: string) {
super(`${status}: ${message}`);
}
}
export class PropDAO {
private key: string;
private base: string;
private timeoutMs: number;
constructor(apiKey?: string, baseUrl = "https://app.propdao.finance/api/v1", timeoutMs = 15_000) {
const k = apiKey ?? (globalThis as any).process?.env?.PROPDAO_API_KEY;
if (!k) throw new Error("apiKey required (or set PROPDAO_API_KEY)");
this.key = k;
this.base = baseUrl.replace(/\/$/, "");
this.timeoutMs = timeoutMs;
}
private async req<T = any>(method: string, path: string, body?: unknown, retried = false): Promise<T> {
const res = await fetch(this.base + path, {
method,
signal: AbortSignal.timeout(this.timeoutMs),
headers: { Authorization: `Bearer ${this.key}`, "Content-Type": "application/json" },
body: body == null ? undefined : JSON.stringify(body),
}).catch((e) => { throw new PropDAOError(0, `network error: ${e?.message ?? e}`); });
// 429 on a read: wait out Retry-After (capped) once, then surface it.
if (res.status === 429 && method === "GET" && !retried) {
const wait = Math.min(5, Number(res.headers.get("retry-after")) || 1);
await new Promise((r) => setTimeout(r, wait * 1000));
return this.req(method, path, body, true);
}
const data: any = await res.json().catch(() => ({}));
if (!res.ok) throw new PropDAOError(res.status, data?.error ?? res.statusText);
return data as T;
}
// -- read ---------------------------------------------------------------
health(): Promise<{ status: "ok" | "degraded"; services: { db: "ok" | "error" }; time: string }> {
return this.req("GET", "/health");
}
me(): Promise<{ userId: string; loginKind: string; apiKeyId: string }> {
return this.req("GET", "/me");
}
/** Every tradable symbol with maxLeverage, lotStep, and the fee schedule. */
markets(): Promise<{ fees: { taker: number; maker: number }; data: Market[]; total: number }> {
return this.req("GET", "/markets");
}
async challenges(): Promise<any[]> {
return (await this.req("GET", "/challenges")).data;
}
async getAccounts(): Promise<any[]> {
return (await this.req("GET", "/accounts")).accounts ?? [];
}
getAccount(accountId: string): Promise<any> {
return this.req("GET", `/accounts/${accountId}`);
}
async getOpenPositions(accountId: string): Promise<any[]> {
return (await this.req("GET", `/accounts/${accountId}/positions?limit=500`)).data;
}
async getOpenOrders(accountId: string): Promise<any[]> {
return (await this.req("GET", `/accounts/${accountId}/orders?limit=500`)).data;
}
async getTrades(accountId: string, limit = 100, offset = 0): Promise<any[]> {
return (await this.req("GET", `/accounts/${accountId}/trades?limit=${limit}&offset=${offset}`)).data;
}
/** Dollars you can lose before breach. Size off roomUsd. */
getRisk(accountId: string): Promise<Risk> {
return this.req("GET", `/accounts/${accountId}/risk`);
}
// -- trade --------------------------------------------------------------
async placeOrder(accountId: string, symbol: string, side: Side, qty: number, opts: OrderOpts = {}) {
const intentId = opts.intentId ?? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`);
for (let attempt = 0; ; attempt++) {
try {
return await this.req("POST", `/accounts/${accountId}/orders`, { symbol, side, qty, orderType: "market", ...opts, intentId });
} catch (e) {
// 409 = same intentId still executing (a retried request raced itself); wait for its result.
if (!(e instanceof PropDAOError) || e.status !== 409 || attempt >= 4) throw e;
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
}
marketBuy(accountId: string, symbol: string, qty: number, opts?: OrderOpts) {
return this.placeOrder(accountId, symbol, "BUY", qty, opts);
}
marketSell(accountId: string, symbol: string, qty: number, opts?: OrderOpts) {
return this.placeOrder(accountId, symbol, "SELL", qty, opts);
}
limitBuy(accountId: string, symbol: string, qty: number, price: number, opts?: OrderOpts) {
return this.placeOrder(accountId, symbol, "BUY", qty, { ...opts, orderType: "limit", limitPrice: price });
}
limitSell(accountId: string, symbol: string, qty: number, price: number, opts?: OrderOpts) {
return this.placeOrder(accountId, symbol, "SELL", qty, { ...opts, orderType: "limit", limitPrice: price });
}
/** Work qty into the market over `minutes`. */
twap(accountId: string, symbol: string, side: Side, qty: number, minutes: number, opts: OrderOpts = {}) {
return this.placeOrder(accountId, symbol, side, qty, { ...opts, orderType: "twap", twapMs: Math.round(minutes * 60_000) });
}
/** Set or clear stop-loss / take-profit on an open position (null clears). */
setRisk(accountId: string, positionId: string, risk: { sl?: number | null; tp?: number | null }) {
return this.req("PATCH", `/accounts/${accountId}/positions/${positionId}`, risk);
}
/** Cancel every resting order and TWAP. Cancelling one rung of a scale ladder
* cancels its siblings, so a 404 on a later rung is expected and skipped. */
async cancelAllOrders(accountId: string) {
const out = [];
for (const o of await this.getOpenOrders(accountId)) {
try {
out.push(o.orderType === "twap" ? await this.cancelTwap(accountId, o.id) : await this.cancelOrder(accountId, o.id));
} catch (e) {
if (!(e instanceof PropDAOError) || e.status !== 404) throw e;
}
}
return out;
}
cancelOrder(accountId: string, orderId: string) {
return this.req("DELETE", `/accounts/${accountId}/orders/${orderId}`);
}
cancelTwap(accountId: string, twapId: string) {
return this.req("DELETE", `/accounts/${accountId}/twaps/${twapId}`);
}
closePosition(accountId: string, positionId: string, percent = 1) {
return this.req("POST", `/accounts/${accountId}/positions/${positionId}/close`, { percent });
}
async closeAll(accountId: string) {
const out = [];
for (const p of await this.getOpenPositions(accountId)) out.push(await this.closePosition(accountId, p.id));
return out;
}
}Quick start 5 MIN
import { PropDAO } from "./propdao"; // or "@propdao/sdk"
const pd = new PropDAO(); // reads PROPDAO_API_KEY
const [{ account_id }] = await pd.getAccounts();
const risk = await pd.getRisk(account_id); // ALWAYS before sizing
console.log(`room to breach: $${risk.roomUsd.toFixed(2)} (${risk.floorKind} floor)`);
const r = await pd.marketBuy(account_id, "BTCUSDC", 0.01, { leverage: 2, sl: 60000 });
console.log(r.status);API reference REFERENCE
Constructor
const pd = new PropDAO( apiKey?: string, // default: process.env.PROPDAO_API_KEY baseUrl = "https://app.propdao.finance/api/v1", timeoutMs = 15_000, );
Read
| Method | Auth | Returns |
|---|---|---|
pd.health() | No | {status, time} |
pd.markets() | No | {fees, data: Market[], total} |
pd.challenges() | No | Account sizes, prices and rules |
pd.me() | Yes | {userId, loginKind, apiKeyId} |
pd.getAccounts() | Yes | Accounts you own |
pd.getAccount(id) | Yes | Full live state incl. the whole trade history — heavy. Prefer getRisk / getOpenPositions in loops |
pd.getRisk(id) | Yes | Risk — equity, floor, roomUsd, breached |
pd.getOpenPositions(id) | Yes | Open positions at live marks |
pd.getOpenOrders(id) | Yes | Resting orders and live TWAPs |
pd.getTrades(id, limit = 100, offset = 0) | Yes | Closed trades, newest first |
Trade
| Method | Description |
|---|---|
pd.placeOrder(id, symbol, side, qty, opts?) | Any order type via opts.orderType |
pd.marketBuy(id, symbol, qty, opts?) | Market long |
pd.marketSell(id, symbol, qty, opts?) | Market short. Against an open long this opens a hedge leg — pass { reduceOnly: true } or use closePosition |
pd.limitBuy(id, symbol, qty, price, opts?) | Resting limit buy |
pd.limitSell(id, symbol, qty, price, opts?) | Resting limit sell |
pd.twap(id, symbol, side, qty, minutes, opts?) | Work size in over time. Min $100 notional |
pd.setRisk(id, positionId, { sl?, tp? }) | Set SL/TP by price. null clears |
pd.closePosition(id, positionId, percent = 1) | Close all or part at mark |
pd.closeAll(id) | Flatten every position |
pd.cancelOrder(id, orderId) | Cancel a resting order |
pd.cancelTwap(id, twapId) | Stop a running TWAP |
pd.cancelAllOrders(id) | Cancel every resting order and TWAP. Ladder siblings already gone (404) are skipped |
OrderOpts
interface OrderOpts {
orderType?: "market" | "limit" | "stop_market" | "stop_limit" | "take_market" | "take_limit" | "scale" | "twap";
leverage?: number; // default 1, clamped to the symbol cap
limitPrice?: number; // limit / stop_limit / take_limit
triggerPrice?: number; // stop_* / take_*
sl?: number; tp?: number; // stop-loss / take-profit PRICE; market & limit only, else use setRisk()
tif?: "gtc" | "ioc" | "alo"; // ioc: market only; alo: post-only, rejected if it would cross
reduceOnly?: boolean;
intentId?: string; // auto-generated if omitted
twapMs?: number; twapMin?: number; twapMax?: number; twapTrigger?: number;
twapRandomize?: boolean; twapMaxSlippagePct?: number;
scaleStart?: number; scaleEnd?: number; scaleCount?: number; scaleDist?: string;
}Recipes EXAMPLES
Risk-sized entry
const SYMBOL = "ETHUSDC", STOP_PCT = 0.02, RISK_FRACTION = 0.25;
const risk = await pd.getRisk(account_id);
const mkt = (await pd.markets()).data.find((m) => m.symbol === SYMBOL)!;
const px = (await pd.getOpenPositions(account_id)).find((p) => p.symbol === SYMBOL)?.mark ?? 2600;
const budget = risk.roomUsd * RISK_FRACTION; // dollars we accept losing
let qty = budget / (px * STOP_PCT);
qty = Math.round(qty / mkt.lotStep) * mkt.lotStep;
await pd.marketBuy(account_id, SYMBOL, qty, { leverage: Math.min(2, mkt.maxLeverage), sl: px * (1 - STOP_PCT) });Grid bot
The engine never nets, so a filled sell above opens a short leg beside your long. Use closePosition to actually reduce.
const SYMBOL = "BTCUSDC", MID = 81000, STEP = 500, QTY = 0.001;
for (let i = 1; i <= 5; i++) {
await pd.limitBuy(account_id, SYMBOL, QTY, MID - STEP * i, { leverage: 2 });
await pd.limitSell(account_id, SYMBOL, QTY, MID + STEP * i, { leverage: 2 });
}
console.log((await pd.getOpenOrders(account_id)).length, "orders resting");Risk guard
const MIN_ROOM_PCT = 1;
setInterval(async () => {
const r = await pd.getRisk(account_id);
console.log(`equity ${r.equity.toFixed(2)} floor ${r.floor.toFixed(2)} room ${r.roomPct.toFixed(2)}%`);
if (r.openPositions && r.roomPct < MIN_ROOM_PCT) {
console.log("room too thin - flattening");
await pd.cancelAllOrders(account_id);
await pd.closeAll(account_id);
}
}, 3000);Stop-loss + take-profit
await pd.marketBuy(account_id, "ETHUSDC", 0.1, { leverage: 2, sl: 2500, tp: 2900 });
const [pos] = await pd.getOpenPositions(account_id);
await pd.setRisk(account_id, pos.id, { sl: 2550 }); // move the stop up
await pd.setRisk(account_id, pos.id, { tp: null }); // clear the take-profitTWAP
const state = await pd.twap(account_id, "BTCUSDC", "BUY", 0.5, 30, { leverage: 2, twapMax: 82000 });
const twapId = state.twapOrders.at(-1).id;
// ...later
await pd.cancelTwap(account_id, twapId);Portfolio monitor
const r = await pd.getRisk(account_id);
console.log(`equity $${r.equity.toFixed(2)} floor $${r.floor.toFixed(2)} room $${r.roomUsd.toFixed(2)} (${r.roomPct.toFixed(2)}%)`);
console.table((await pd.getOpenPositions(account_id)).map((p) => ({
symbol: p.symbol, side: p.side, qty: p.qty, entry: p.entry, mark: p.mark, uPnL: p.unrealizedPnl,
})));Error handling REFERENCE
Every failure throws PropDAOError with .status and .message. Status 0 is a network error with no response — the intentId makes a retry safe. Reads that hit 429 are retried once after Retry-After; writes are not.
import { PropDAO, PropDAOError } from "./propdao";
try {
await pd.marketBuy(account_id, "BTCUSDC", 0.01, { leverage: 2 });
} catch (e) {
if (!(e instanceof PropDAOError)) throw e;
if (e.status === 429) await new Promise((r) => setTimeout(r, 2000));
else if (e.status === 403 && e.message.includes("breached")) process.exit(1);
else if (e.status === 0) { /* network blip - retry with the same intentId */ }
else if (e.status === 400 && e.message.startsWith("Hold positions")) await new Promise((r) => setTimeout(r, 1000)); // 1 s min hold / 0.5 s spacing
else console.error("API error", e.status, e.message);
}| Status | Meaning | Action |
|---|---|---|
| 400 | Bad input, or Hold positions 1s · max 1 execution every 0.5s | Read message; for the timing error wait and resend the same intentId |
| 401 | Invalid or revoked key | Check PROPDAO_API_KEY |
| 403 | Not your account / breached | Stop trading that account |
| 404 | Order or position not found | Refresh state |
| 409 | Same intentId still executing | SDK waits and retries for you |
| 429 | Rate limited | Back off; 300 reads / 60 orders per min |
| 0 | Network error, no response | Retry — the intentId makes it safe |
| 5xx | Engine error | Retry reads; check state before retrying an order |