Upscale
Menu
On this page

Upscale Public API

Version d796ac9Last changed

Programmatic access to Upscale trading with a personal API key. Only the endpoints listed here accept an API key — everything else requires an interactive session.

Quick start

Opening and closing a position takes five calls. Everything below runs against https://api.upscale.trade with a single personal API key in the Authorization header — see Authentication for how to get one.

#CallWhat it gives you
1GET /accounts/with-risk-statusThe accounts the key may trade, each with its phase, status, balance and equity. Take accountId from the one to trade on.
2GET /v2/markets?accountId=…The tradable markets with the price, fees and leverage bounds an order on that account is checked against. Take id of the market.
3POST /orders with type: "market"Opens the position. amount is the quote reserve to spend, leverage the multiplier.
4GET /positions/{accountId}/activeThe open positions. Needed because the order response carries no position identifier.
5POST /orders with type: "take" and triggerPrice: "0"Closes that position at market. amount is the base size to close.

Three things to settle before writing any code:

  • Every number is an fp9 raw string — the value scaled by 10⁹. 100 USD is "100000000000", 10x leverage is "10000000000". Read and write them with BigInt / Decimal; a float silently rounds away the last digits.
  • amount changes unit with the order class. On the market order that opens a position it is a quote amount — margin, fee, spread and buffer reserved from the free balance. On the take order that closes it, it is the base asset size of the position, and nothing is reserved. Step 5 passes the position size straight through.
  • Closing is a take order, not a market order the other way. Positions are held per direction, so a short market order placed against an open long opens a second position instead of closing the first. A take with triggerPrice: "0" carries no trigger and fills at market while the call is still open; an amount larger than the position holds closes it in full. To flatten an account in one call use POST /positions/{accountId}/close-all.

Both scripts below are complete: set UPSCALE_API_KEY in the environment and run.

JavaScript (Node 18+)

Save as upscale.mjs and run with node upscale.mjs — no dependencies.

import { randomUUID } from 'node:crypto';
 
const BASE_URL = 'https://api.upscale.trade';
const API_KEY = process.env.UPSCALE_API_KEY;
 
// Human decimal -> fp9 raw string: 100 -> "100000000000", "0.5" -> "500000000".
const toFp9 = (value) => {
  const [whole, fraction = ''] = String(value).split('.');
  return BigInt(whole + fraction.padEnd(9, '0').slice(0, 9)).toString();
};
 
// fp9 raw string -> human decimal string. Stays on BigInt: Number loses the low digits above ~9M USD.
const fromFp9 = (raw) => {
  const negative = raw.startsWith('-');
  const digits = (negative ? raw.slice(1) : raw).padStart(10, '0');
  return `${negative ? '-' : ''}${digits.slice(0, -9)}.${digits.slice(-9)}`;
};
 
const call = async (method, path, body) => {
  const response = await fetch(`${BASE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      // One key per logical request; a retry of that request must send the same key again.
      ...(body ? { 'Content-Type': 'application/json', 'x-idempotency-key': randomUUID() } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const payload = response.status === 204 ? null : await response.json();
  if (!response.ok) {
    // `error` is the stable machine-readable code — branch on it, never on `message`.
    throw new Error(`${method} ${path} -> ${response.status} ${payload?.error}: ${payload?.message}`);
  }
  return payload;
};
 
// 1. Accounts. Authenticated with an API key the list holds only accounts that have api_trading enabled.
const accounts = await call('GET', '/accounts/with-risk-status');
const account = accounts.find((item) => item.status === 'active' && item.apiTrading && item.type === 'demo');
if (!account) {
  throw new Error('No active demo account with API trading enabled');
}
console.log(`account ${account.accountId}, equity ${fromFp9(account.riskStatus.currentEquity)}`);
 
// 2. Markets, priced by the shard that hosts this account — the state the order will be filled against.
const markets = await call('GET', `/v2/markets?accountId=${account.accountId}`);
const market = markets.find((item) => item.config.baseAsset === 'BTC');
if (!market) {
  throw new Error('BTC is not available on this account');
}
console.log(`${market.config.ticker} at ${fromFp9(market.state.indexPrice)}`);
 
// 3. Open a long: 100 USD of reserve at 10x, filled at market.
const order = await call('POST', '/orders', {
  accountId: account.accountId,
  marketId: market.id,
  type: 'market',
  direction: 'long',
  amount: toFp9(100),
  leverage: toFp9(10),
  expectedAmount: '0', // no slippage check; pass the base size you expect to enforce one
});
console.log(`order ${order.id} ${order.status}`);
 
// 4. Read the position back — the order response does not carry its identifier.
const positions = await call('GET', `/positions/${account.accountId}/active`);
const position = positions.find((item) => item.market === market.id && item.direction === 'long');
if (!position) {
  throw new Error('Position not found — the order may have been deferred or rejected at execution');
}
console.log(`position ${position.idx}, size ${fromFp9(position.size)} ${market.config.baseAsset}`);
 
// 5. Close it at market.
await call('POST', '/orders', {
  accountId: account.accountId,
  marketId: market.id,
  type: 'take',
  direction: position.direction, // a close order runs in the same direction as its position
  positionId: position.idx,
  amount: position.size, // base units
  triggerPrice: '0', // no trigger: fill now
});
console.log('closed');

Python (3.10+)

Needs requests (pip install requests).

import os
import uuid
from decimal import Decimal
 
import requests
 
BASE_URL = 'https://api.upscale.trade'
API_KEY = os.environ['UPSCALE_API_KEY']
FP9 = Decimal(10) ** 9
 
 
def to_fp9(value) -> str:
    """Human decimal -> fp9 raw string: 100 -> "100000000000"."""
    return str(int(Decimal(str(value)) * FP9))
 
 
def from_fp9(raw: str) -> Decimal:
    """fp9 raw string -> Decimal. Never float — it drops the low digits."""
    return Decimal(raw) / FP9
 
 
def call(method: str, path: str, body: dict | None = None):
    headers = {'Authorization': f'Bearer {API_KEY}'}
    if body is not None:
        # One key per logical request; a retry of that request must send the same key again.
        headers['x-idempotency-key'] = str(uuid.uuid4())
    response = requests.request(method, f'{BASE_URL}{path}', json=body, headers=headers, timeout=30)
    payload = None if response.status_code == 204 else response.json()
    if not response.ok:
        # `error` is the stable machine-readable code — branch on it, never on `message`.
        raise RuntimeError(
            f'{method} {path} -> {response.status_code} {payload.get("error")}: {payload.get("message")}'
        )
    return payload
 
 
# 1. Accounts. Authenticated with an API key the list holds only accounts that have api_trading enabled.
accounts = call('GET', '/accounts/with-risk-status')
account = next((item for item in accounts if item['status'] == 'active' and item['apiTrading'] and item['type'] == 'demo'), None)
if account is None:
    raise SystemExit('No active demo account with API trading enabled')
print(f"account {account['accountId']}, equity {from_fp9(account['riskStatus']['currentEquity'])}")
 
# 2. Markets, priced by the shard that hosts this account — the state the order will be filled against.
markets = call('GET', f"/v2/markets?accountId={account['accountId']}")
market = next((item for item in markets if item['config']['baseAsset'] == 'BTC'), None)
if market is None:
    raise SystemExit('BTC is not available on this account')
print(f"{market['config']['ticker']} at {from_fp9(market['state']['indexPrice'])}")
 
# 3. Open a long: 100 USD of reserve at 10x, filled at market.
order = call('POST', '/orders', {
    'accountId': account['accountId'],
    'marketId': market['id'],
    'type': 'market',
    'direction': 'long',
    'amount': to_fp9(100),
    'leverage': to_fp9(10),
    'expectedAmount': '0',  # no slippage check; pass the base size you expect to enforce one
})
print(f"order {order['id']} {order['status']}")
 
# 4. Read the position back — the order response does not carry its identifier.
positions = call('GET', f"/positions/{account['accountId']}/active")
position = next((item for item in positions if item['market'] == market['id'] and item['direction'] == 'long'), None)
if position is None:
    raise SystemExit('Position not found — the order may have been deferred or rejected at execution')
print(f"position {position['idx']}, size {from_fp9(position['size'])} {market['config']['baseAsset']}")
 
# 5. Close it at market.
call('POST', '/orders', {
    'accountId': account['accountId'],
    'marketId': market['id'],
    'type': 'take',
    'direction': position['direction'],  # a close order runs in the same direction as its position
    'positionId': position['idx'],
    'amount': position['size'],  # base units
    'triggerPrice': '0',  # no trigger: fill now
})
print('closed')

Where to go next

  • Deferred entries (limit, stop_market, stop_limit) and protective orders (stop, trailing_stop) take the same POST /orders route — the operation lists what each type requires.
  • A stop-loss and a take-profit can ride along with the opening order as stopTriggerPrice and takeTriggerPrice.
  • GET /accounts/{accountId}/risk-status is the endpoint to poll for drawdown headroom while a position is open.

Authentication

Create a key in the Upscale app (or with POST /user/api-keys from an authenticated session) — the raw key is shown once, at creation time. Pass it as a bearer token:

Authorization: Bearer usk_<your key>

A key inherits the permissions of the user it belongs to and stays valid until it is deleted, refreshed or expires.

Trading over the API is enabled per account: an account with api_trading turned off answers 403 (api_trading_not_enabled) to every request made with a key, and account lists leave it out entirely.

Conventions

  • Amounts, prices, leverage and multipliers travel as fp9 raw integer strings — the value scaled by 10⁹. "1000000000" is 1 USD, "10000000000" is 10x leverage, "50000000" is 5%.
  • fp9 fixes the scale, not the unit. A quote amount is USD; a base size is units of the traded asset. amount on an order is quote on increase orders and base on close orders — every field says which of the two it carries.
  • Identifiers are uuid v4. Markets are addressed by identifier when trading and by base asset ticker (BTC) on the per-market read endpoints.
  • Lists page through limit and offset and return the total count next to the page.
  • Positions and orders are scoped to the phase the account is in right now: what belongs to a finished phase is no longer returned.

Rate limits

Limits are counted per key, in fixed windows, separately for read (GET) and write (everything else) endpoints. Defaults are 10 requests per second and 60 per minute for reads, 10 per second and 60 per minute for writes.

Exceeding a limit returns 429 (api_key_rate_limit_exceeded) with a Retry-After header and a body carrying the bucket (read / write), the window that tripped, its limit and retryAt.

The key's own limits are the only ones a key is subject to. The per-endpoint limits the Upscale app runs into are counted per interactive session and are not applied to API-key requests, so no endpoint here carries a tighter limit of its own.

Errors

A failure answers with { statusCode, message, error }, where error is a stable machine-readable code. Branch on that code, not on the message text.

  • 400 — the body, query or path failed schema validation (validation_failed), or a trading precondition was rejected (insufficient_balance, position_not_available, and the per-operation codes below).
  • 401 — the key is missing, malformed, revoked or expired (session_expired).
  • 403 — the key was used on an endpoint that does not accept keys (api_key_not_allowed), the account belongs to someone else (account_access_denied), API trading is off (api_trading_not_enabled), the account is no longer trading (challenge_closed), has not reached a tradable phase (account_phase_not_allowed) or is locked by the managed capital limit (funded_limit_trading_locked), the order is no longer active (order_not_active), or the market is paused (market_paused), close-only (market_close_only) or outside the category the account may trade (market_category_not_allowed).
  • 404 — no such account (account_not_found), order (order_not_found), position (position_not_found) or market (market_not_found).
  • 409 — a request with the same x-idempotency-key is still being processed (idempotency_key_in_flight), the account is not currently loaded by the trading engine (account_not_loaded), or the market price has not been updated recently enough to trade on (market_price_stale).
  • 429 — the rate limit of the key tripped (api_key_rate_limit_exceeded).

Order-level rejections come back as 400 with a code naming the field combination at fault; each trading operation lists the ones it can produce.

Operations

Last updated