Skip to main content

API reference

This page is for developers who write their own integration with OptAlgo once and then keep using it: a trading script, a strategy engine that sends signals, a dashboard, a spreadsheet export. It covers the rules that do not change from one endpoint to the next, the machine-readable schema, and two small clients you can paste into a project.

  • Every endpoint, with request and response examples, is in the OptAlgo LLM reference (section 7, Reference). It is written for AI agents and is just as precise for people; the plain-text copy is at docs.optalgo.com/optalgo-llm.md.
  • The interactive reference, rendered from the OpenAPI schema, is the API explorer.
  • Creating keys, scopes, autonomy and limits: API keys.

Base URL and versioning​

https://api.optalgo.com/v1

The version is in the path. Within /v1, OptAlgo only adds: new endpoints, new optional request fields, new response fields, new values in places documented as open lists. Write your client so that it ignores response fields it does not know. A change that removes something or changes what a field means ships as /v2, and /v1 keeps working next to it for at least 90 days. During that time /v1 responses carry a Deprecation header and a Sunset header with the date /v1 stops; log them if you want to be warned early.

Request bodies are strict in the other direction: unknown fields are refused with 422, never silently ignored, so a typo cannot change what an order does.

Authentication​

Send the key in a header on every request:

X-API-Key: oa_live_...

Never put the key in a URL, a browser page or a committed file. Keys need the Plus or Pro plan; an account has up to 5 active keys, each with scopes (read, bots, trade), an autonomy level and optional limits. A revoked key answers 401 at once.

Where your program reads the key​

Use one convention on every machine, so the same code runs on a laptop and on a server:

  1. the environment variables OPTALGO_API_KEY and OPTALGO_API_BASE, if set (servers, containers, CI);
  2. otherwise the file ~/.config/optalgo/credentials.env, readable only by its owner (chmod 600):
OPTALGO_API_KEY=oa_live_...
OPTALGO_API_BASE=https://api.optalgo.com/v1

The OptAlgo device login writes exactly this file, so the key never has to be copied by hand: the program (or an AI agent) starts a login, you approve it in the OptAlgo app, and the key goes from the API into the file. The commands are in Connect an agent without ever handling the key. In a shell, load the file only inside the command that needs it:

set -a; . ~/.config/optalgo/credentials.env; set +a; curl -sS "$OPTALGO_API_BASE/me" -H "X-API-Key: $OPTALGO_API_KEY"

Requests and responses​

  • JSON in, JSON out (Content-Type: application/json), except GET /v1/trades?format=csv (text/csv) and the agent login's format=env (text/plain).
  • Timestamps are epoch seconds (UTC) unless a field is ISO 8601 (as_of, expires_at, resets_at, the created_at of a connect attempt).
  • Money is in the quote currency of the account (USDT on most).
  • A signal or close answers 202 Accepted with a transaction_id: it is queued, not executed. GET /v1/signals/{transaction_id} returns the outcome; poll it until the status is final.

Errors​

Every error is JSON with a stable machine code in error, a sentence for people in detail, and sometimes extra fields next to them:

{"error": "limit_exceeded", "detail": "Leverage 10 is above this key's max_leverage (5).",
"limit": "max_leverage", "value": 10, "allowed": 5}

Backtest and market endpoints (/v1/backtests…, /v1/markets) are written for agents. GET /v1/markets and POST /v1/backtests/validate answer {"data": …, "next": [{"action", "method", "path", "why"}]}; the older backtest endpoints keep their fields at the top level and add next beside them, so the payload is data when present, else the body without next. Their errors are {"error", "message", "detail", "fix", "docs", "next"[, "field"]} plus the code's own fields: fix says what to change, docs links the code's entry, next the call to make next.

Branch on error, never on detail or message (their wording can change). Request-body validation errors keep FastAPI's shape, {"detail": [{"loc": [...], "msg": "..."}]} with status 422. The main codes:

HTTPerrorWhat your code should do
401invalid_api_keyStop. The key is missing, revoked, or its account was closed.
403plan_required, forbiddenStop and tell the user; retrying does not help.
403scope_requiredThe body names required_scope and a fix_url: the user opens it and turns the scope on for this same key in the app, then retry the same request with the same Idempotency-Key. No new key needed.
403limit_exceeded, key_pausedA limit the account owner set. Do not retry with the same values.
404not_foundWrong id, or not this user's.
409conflict, no_open_positionThe current state forbids it; read the state again.
409idempotency_in_progressThe same Idempotency-Key is still running; retry in a moment.
422invalid_request and othersFix the request as detail says.
429rate_limitedWait Retry-After seconds, then retry.
502listener_unavailableThe signal may still have been taken. Poll the status_url in the body before you send anything again.
502, 503listener_error, limit_check_unavailable, unavailableNothing was done; retry later.
500internal_errorReported to OptAlgo automatically; retry later.

The full table, with every extra field, is in section 6 of the LLM reference.

Rate limits​

Per key: 120 requests per minute overall and 30 per minute for signals and closes. Every response carries:

HeaderMeaning
X-RateLimit-LimitThe limit of the tightest bucket that applied to this call (120 on reads, 30 on a signal or close)
X-RateLimit-RemainingWhat is left of it in the current minute
Retry-AfterOn a 429 only: seconds to wait

The agent login has its own limits per IP address: 10 logins per minute, 10 claims per minute and 5 wrong claim codes per 10 minutes (429 rate_limited with retry_after); polling the token faster than interval answers 429 slow_down with a Retry-After header.

Idempotency​

POST /v1/bots, POST /v1/bots/{bot_id}/signal, POST /v1/bots/{bot_id}/close, POST /v1/bots/{bot_id}/start, POST /v1/bots/{bot_id}/stop and POST /v1/tickets accept an Idempotency-Key header: 1 to 128 characters of your choice, scoped to your API key.

  • The first call runs; its 2xx answer is kept for 24 hours. A repeat with the same key returns that answer with Idempotent-Replayed: true and does nothing again.
  • A repeat while the first call is still running gets 409 idempotency_in_progress.
  • Errors are not stored, so a failed call can be retried with the same key.
  • Use one fresh key (a UUID) per intended action, and reuse it only to retry that same action after a timeout or a dropped connection. That is what makes "send the signal again" safe.

Pagination and time windows​

Lists are newest first and paged with a cursor, never with page numbers:

  • GET /v1/trades?limit=50 answers {"trades": [...], "next_before": 1791300000.0}. Pass before=<next_before> for the next page; next_before is null on the last page. limit is 1 to 100.
  • since and until (epoch seconds, on opened_at) select a window, and combine with before.
  • GET /v1/trades?format=csv returns up to 5,000 rows in one response, with the same filters; it is the easier way to export history.
  • GET /v1/logs?limit=50&before=<epoch> pages the log feed the same way: pass the at of the oldest row you have.

OpenAPI schema​

The API describes itself in OpenAPI 3:

The schema covers every /v1 route, grouped by tag (Account, Exchanges, Bots, Multi-symbol, Signals, Results, Tickets, Connect, Agent login), with the ApiKeyAuth security scheme (header X-API-Key) on every operation except the agent login routes (/v1/agent/*), which are how a program gets a key. The live schema is cached for 5 minutes. Generate a typed client from it if you prefer (for example with openapi-generator or openapi-typescript); the minimal clients below need no generator.

A minimal client in Python​

About 80 lines with requests: the key from the environment or the credentials file, errors raised as exceptions with their code, Retry-After honoured, and an idempotency key on every write so a retry can never act twice.

"""Minimal OptAlgo API client. pip install requests"""
import os
import time
import uuid
from pathlib import Path

import requests


class OptAlgoError(Exception):
def __init__(self, status, code, detail, body):
super().__init__(f"HTTP {status} {code}: {detail}")
self.status, self.code, self.detail, self.body = status, code, detail, body


def load_credentials(path=Path.home() / ".config" / "optalgo" / "credentials.env"):
values = {}
if path.exists():
for line in path.read_text().splitlines():
name, sep, value = line.partition("=")
if sep and not name.startswith("#"):
values[name.strip()] = value.strip()
key = os.environ.get("OPTALGO_API_KEY") or values.get("OPTALGO_API_KEY")
base = os.environ.get("OPTALGO_API_BASE") or values.get("OPTALGO_API_BASE") or "https://api.optalgo.com/v1"
if not key:
raise RuntimeError("No OptAlgo API key: set OPTALGO_API_KEY or run the agent login")
return key, base.rstrip("/")


class OptAlgo:
def __init__(self, key=None, base=None, retries=3):
if key is None:
key, base = load_credentials()
self.base, self.retries = base or "https://api.optalgo.com/v1", retries
self.http = requests.Session()
self.http.headers.update({"X-API-Key": key, "User-Agent": "my-optalgo-client/1"})

def request(self, method, path, idempotency_key=None, **kwargs):
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
for attempt in range(self.retries + 1):
last = attempt == self.retries
try:
r = self.http.request(method, self.base + path, headers=headers, timeout=60, **kwargs)
except (requests.ConnectionError, requests.Timeout):
if last or (method != "GET" and not idempotency_key):
raise # a write without a key is never resent
time.sleep(2 ** attempt)
continue
if r.status_code < 400:
return r.json() if "json" in r.headers.get("Content-Type", "") else r.text
body = r.json() if "json" in r.headers.get("Content-Type", "") else {}
code = body.get("error") or ("invalid_request" if r.status_code == 422 else f"http_{r.status_code}")
if not last and (code == "rate_limited" or code == "idempotency_in_progress"):
time.sleep(float(r.headers.get("Retry-After", 2)))
continue
raise OptAlgoError(r.status_code, code, body.get("detail"), body)

# Writes take an idempotency key; pass the same one to retry the same action.
def summary(self):
return self.request("GET", "/summary")

def bots(self):
return self.request("GET", "/bots")

def signal(self, bot_id, payload, idempotency_key=None):
return self.request("POST", f"/bots/{bot_id}/signal", idempotency_key or str(uuid.uuid4()), json=payload)

def close(self, bot_id, ticker=None, close_ratio=None, idempotency_key=None):
body = {k: v for k, v in {"ticker": ticker, "close_ratio": close_ratio}.items() if v is not None}
return self.request("POST", f"/bots/{bot_id}/close", idempotency_key or str(uuid.uuid4()), json=body)

def outcome(self, transaction_id):
return self.request("GET", f"/signals/{transaction_id}")

def trades(self, **params): # every page, newest first
while True:
page = self.request("GET", "/trades", params=params)
yield from page["trades"]
if not page["next_before"]:
return
params["before"] = page["next_before"]

Use it:

api = OptAlgo()
print(api.summary()["pnl"]["total"])
sent = api.signal(bot_id, {"position_side": "LONG", "ticker": "SOLUSDT.P", "leverage": 3})
while (o := api.outcome(sent["transaction_id"]))["status"] in ("pending", "received", "dispatched", "retrying"):
time.sleep(3)
print(o["status"], o.get("warnings"))
for t in api.trades(status="closed", mode="live"):
print(t["symbol"], t["pnl"])

A minimal client in JavaScript​

The same shape with fetch (Node 18 or later). Run it on a server or your own machine, never in a browser page: a key in page source is a leaked key.

// Minimal OptAlgo API client. Node 18+, no dependencies.
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

export class OptAlgoError extends Error {
constructor(status, code, detail, body) {
super(`HTTP ${status} ${code}: ${detail}`);
Object.assign(this, { status, code, detail, body });
}
}

export function loadCredentials(path = join(homedir(), '.config', 'optalgo', 'credentials.env')) {
const values = {};
if (existsSync(path)) {
for (const line of readFileSync(path, 'utf8').split('\n')) {
const i = line.indexOf('=');
if (i > 0 && !line.startsWith('#')) values[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
}
const key = process.env.OPTALGO_API_KEY || values.OPTALGO_API_KEY;
const base = process.env.OPTALGO_API_BASE || values.OPTALGO_API_BASE || 'https://api.optalgo.com/v1';
if (!key) throw new Error('No OptAlgo API key: set OPTALGO_API_KEY or run the agent login');
return { key, base: base.replace(/\/$/, '') };
}

const sleep = (ms) => new Promise((done) => setTimeout(done, ms));

export class OptAlgo {
constructor({ key, base, retries = 3 } = {}) {
if (!key) ({ key, base } = loadCredentials());
Object.assign(this, { key, base: base || 'https://api.optalgo.com/v1', retries });
}

async request(method, path, { body, params, idempotencyKey } = {}) {
const url = this.base + path + (params ? `?${new URLSearchParams(params)}` : '');
const headers = { 'X-API-Key': this.key, Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
for (let attempt = 0; ; attempt++) {
const last = attempt >= this.retries;
let r;
try {
r = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(60_000) });
} catch (err) { // network error or timeout
if (last || (method !== 'GET' && !idempotencyKey)) throw err; // a write without a key is never resent
await sleep(2 ** attempt * 1000);
continue;
}
const isJson = (r.headers.get('content-type') || '').includes('json');
const data = isJson ? await r.json() : await r.text();
if (r.ok) return data;
const code = (isJson && data.error) || (r.status === 422 ? 'invalid_request' : `http_${r.status}`);
if (!last && (code === 'rate_limited' || code === 'idempotency_in_progress')) {
await sleep(Number(r.headers.get('retry-after') || 2) * 1000);
continue;
}
throw new OptAlgoError(r.status, code, isJson ? data.detail : data, data);
}
}

summary() { return this.request('GET', '/summary'); }
bots() { return this.request('GET', '/bots'); }
signal(botId, payload, idempotencyKey = randomUUID()) {
return this.request('POST', `/bots/${botId}/signal`, { body: payload, idempotencyKey });
}
close(botId, { ticker, closeRatio } = {}, idempotencyKey = randomUUID()) {
const body = {};
if (ticker) body.ticker = ticker;
if (closeRatio !== undefined) body.close_ratio = closeRatio;
return this.request('POST', `/bots/${botId}/close`, { body, idempotencyKey });
}
outcome(transactionId) { return this.request('GET', `/signals/${transactionId}`); }
async *trades(params = {}) { // every page, newest first
for (;;) {
const page = await this.request('GET', '/trades', { params });
yield* page.trades;
if (!page.next_before) return;
params = { ...params, before: page.next_before };
}
}
}

Use it:

const api = new OptAlgo();
console.log((await api.summary()).pnl.total);
const sent = await api.signal(botId, { position_side: 'LONG', ticker: 'SOLUSDT.P', leverage: 3 });
for await (const t of api.trades({ status: 'closed', mode: 'live' })) console.log(t.symbol, t.pnl);

Rules worth building in from the start​

  • A 202 is not a fill. Poll GET /v1/signals/{transaction_id} until the status is final, and compare GET /v1/trades before you report a position as open.
  • Never resend a signal because you did not see a result. Reuse the same Idempotency-Key if you must retry, and after a 502 listener_unavailable poll the status_url first.
  • Pace your polling on X-RateLimit-Remaining. Positions are cached 10 seconds on the server, the summary changes on trade events, equity only when a trade closes.
  • Read GET /v1/me at start-up: the key's scopes, autonomy and limits tell you what will be refused before you send it.