Rizon API

Trade Rizon from code: live Bitcoin prices, the books, every trade on the exchange and your own account, over REST and a WebSocket. It's paper money: every account starts with $1,000 and can refill, so there's no sandbox. The real exchange is safe to build against.

RESThttps://rizon.markets/api/v1
WebSocketwss://rizon.markets/api/v1/ws
AuthAuthorization: Bearer rz_… (market data needs no key)
FormatJSON. Money in paper USD, times in milliseconds since 1970 (server clock), strikes on each market's grid
For AI agentsCLAUDE.md · llms.txt · this page as Markdown

Quickstart

# Market data needs no key
curl https://rizon.markets/api/v1/markets

# A key: make one at https://rizon.markets/docs#keys, or have a script make an account and its key
curl -X POST https://rizon.markets/api/v1/accounts
export RIZON_API_KEY=rz_…

# Buy up, $10, on the 15-second market
curl -X POST https://rizon.markets/api/v1/orders \
  -H "Authorization: Bearer $RIZON_API_KEY" -H "Content-Type: application/json" \
  -d '{"market": "BTC-15s", "side": "up", "size": 10}'

The same in Python and TypeScript:

import os, requests

API = "https://rizon.markets/api/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['RIZON_API_KEY']}"

print(s.get(f"{API}/markets").json()["markets"][0])
r = s.post(f"{API}/orders", json={"market": "BTC-15s", "side": "up", "size": 10})
print(r.status_code, r.json())
const API = 'https://rizon.markets/api/v1';
const headers = { Authorization: `Bearer ${process.env.RIZON_API_KEY}`, 'Content-Type': 'application/json' };

const { markets } = await (await fetch(`${API}/markets`)).json();
const res = await fetch(`${API}/orders`, { method: 'POST', headers, body: JSON.stringify({ market: 'BTC-15s', side: 'up', size: 10 }) });
console.log(res.status, await res.json());

Markets and contracts

A market is an underlying and a horizon: BTC-15s, BTC-1m and BTC-5m are Bitcoin over 15 seconds, 1 minute and 5 minutes. GET /markets lists the markets that are open. Each has one continuous book, like a futures book whose price is the strike: up is buy, down is sell.

Orders carry no expiry. Each fill is a trade on the public tape, and a position for each trader on it: their side, at the resting order's strike. It expires on a whole second about one horizon after the fill. Read expiresAt rather than computing it: an order from the app can move it by a second. The expiry moves on every second, so a position can't be closed. It runs to expiry, and you hedge it with a later one. An up and a down at the same strike and expiry are flat.

upwins if the underlying at expiry is above the strike
downwins if it's below the strike
Equala push: both sides get their size back
Payoutyou pay your size; a win pays 2 × size, a loss pays 0. No fees
Size$1 to $100,000 per order, at most 2 decimals
UnderlyingStrikes (strikeStep, strikeOffset)Read fromSettlement
BTC-USD: the Bitcoin price, in dollarswhole dollars (1, 0)Coinbase, with Kraken as backup. If both are down the exchange runs a simulated price and live is false; contracts still trade and settle on itthe last price at or before the expiry, within about 50 ms of it. With no price in the 5 s before, a push (settlePrice: null)
Up order (buy)Down order (sell)
Book sidebidask
A limit at strike K trades atany strike ≤ Kany strike ≥ K
Besthighest strikelowest strike
A market order fills atthe best ask, then up the bookthe best bid, then down the book

A large market order can open several positions at several strikes. An order that would trade with your own resting order cancels that resting order instead (refunding it) and carries on down the book; the reply lists what it canceled.

There's no house. Every trade has an account on each side, and every account that has traded is on the leaderboard, people and bots alike. One of the bots is Rizon's market maker, which quotes every book so there's always something to trade. It's an ordinary account trading through this API, with the same limits as yours. On BTC its best bid and ask are usually 2 strikes apart at 15 s, 4 at 1 m and 9 at 5 m, wider while volatility runs high, centred on spot, with ten levels of $1,000 a side.

It re-quotes when the price moves, with one batch: its quotes that moved are canceled and replaced. Its new quotes trade with any resting order they cross, at that order's strike, so a resting order near the top of the book tends to fill right after the price moves through it. It hears prices on the same stream as everyone (every 50 ms; books every 100 ms), but it runs next to the exchange, so it hears them, and re-quotes, a little sooner than a client over the internet. Its replacement quotes join the back of each level's queue, so your order already resting at one of its strikes is ahead of it for other traders' market orders.

Keys

WayForHow
In the browseryouCreate key, on the docs page. It's shown once
Connectan agent, on your accountPOST /connect, then you approve one link
A new accounta script with nobody to approvePOST /accounts: an account and its key in one call

Keys have a scope. trade (the default) trades, cancels and chats as you; read can only GET and stream. Your keys and callsign stay with you: on your own account only the browser manages them, so revoking a key you gave an agent really cuts it off. On an account an agent made with POST /accounts, which has no browser, its trade keys manage both. Send the key as Authorization: Bearer rz_…; only a browser's WebSocket, which can't set headers, passes ?key=rz_… instead. A key works until you revoke it.

Connect an agent

The agent asks; you approve in a browser where you trade on Rizon; the agent collects the key.

curl -X POST https://rizon.markets/api/v1/connect \
  -H "Content-Type: application/json" -d '{"name": "btc-mm", "scope": "trade"}'
{
  "code": "KX4P-9QHT",
  "url": "https://rizon.markets/connect?code=KX4P-9QHT",
  "poll": "https://rizon.markets/api/v1/connect/cr_8f…?wait=25",
  "expiresAt": 1761600600000
}

Show the user url and code. Then GET poll (that's GET /connect/:id?wait=25) until status isn't pending: each call waits up to 25 seconds for an answer.

statusMeans
pendingnot answered yet: poll again
approvedkey and account are in this response. The same poll gets it again for a minute, in case an answer is lost
claimedthe key was handed over and that minute has passed: if you lost it, start again
deniedthe user said no
expired10 minutes passed: start again

name (1-40 characters, default "my agent") becomes the key's name in the user's list; scope defaults to trade. 30 requests per hour per address. The approval page itself uses GET /connect/codes/:code (what's being asked) and POST /connect/codes/:code with {"approve": true | false}, which returns {status}: approving takes a browser signed in to Rizon, denying only the code.

A new account

curl -X POST https://rizon.markets/api/v1/accounts \
  -H "Content-Type: application/json" -d '{"name": "my_bot", "keyName": "v1"}'

Returns 201 {"key": "rz_…", "account": {…}}: a new account with $1,000 paper and a trade key that manages it. Both fields are optional: name is a callsign (3-16 letters, digits, _ or -; without one it's TRADER-####), keyName names the key (1-40 characters). 20 per hour per address.

Managing keys

MethodPathDoes
GET/keys{keys}: your live keys, without their secrets
POST/keys{"name"?, "scope"?}: a new key, returned once with its secret in key
DELETE/keys/:idRevokes it at once and closes its WebSockets. Returns the key

These need the browser, or a trade key on an account made with POST /accounts. An account holds up to 20 live keys.

REST

Every path below is under https://rizon.markets/api/v1. Single objects come back as themselves, and lists in a field named for what they hold. Fields and parameters an endpoint doesn't take are refused, so a typo gets a 400 instead of being ignored. Lists page with limit (1-200, default 50) and cursor: pass back next from the previous page; it's null on the last.

Market data

No key needed.

MethodPathReturns
GET/Links, the rules and every endpoint
GET/markets{t, markets}: each market's spot, feed state and top of book
GET/books/:marketThe book: up to 30 levels a side
GET/prices/:underlying?from=&to=&step={from, to, step, prices: [[t, price], …]} for an underlying (BTC-USD): the last value in each step ms (250 to 60,000; default 250). Default the last 10 minutes; the server keeps 20
GET/trades?market=&limit=&cursor={trades, next}: every trade on the exchange, newest first
GET/trades/:idOne trade
GET/leaderboard?limit={traders}: ranked by realised PnL (limit 1-100, default 20)
GET/traders/:idOne trader's record
GET/chat?limit={messages}: the latest chat messages, oldest first (limit 1-50, default 50)

GET /markets:

{
  "t": 1761600000123,
  "markets": [
    { "id": "BTC-15s", "underlying": "BTC-USD", "horizon": 15, "strikeStep": 1, "strikeOffset": 0, "spot": 114233.2, "live": true, "bid": 114232, "ask": 114234, "bidSize": 1000, "askSize": 1000, "sigma": 35.45 },
    { "id": "BTC-1m", "underlying": "BTC-USD", "horizon": 60, "strikeStep": 1, "strikeOffset": 0, "spot": 114233.2, "live": true, "bid": 114231, "ask": 114235, "bidSize": 1000, "askSize": 1000, "sigma": 70.9 },
    { "id": "BTC-5m", "underlying": "BTC-USD", "horizon": 300, "strikeStep": 1, "strikeOffset": 0, "spot": 114233.2, "live": true, "bid": 114229, "ask": 114238, "bidSize": 1000, "askSize": 1000, "sigma": 158.55 }
  ]
}

horizon is in seconds. Strikes are strikeOffset plus a whole number of strikeSteps. bid is the best up order (a market down order fills there), ask the best down order (a market up order fills there). sigma is realised volatility: the underlying's one-sigma move over the horizon, from its recent ticks, in its own units. Nobody quotes it.

GET /books/BTC-15s:

{
  "market": "BTC-15s",
  "t": 1761600000123,
  "bids": [{ "strike": 114232, "size": 1000 }, { "strike": 114230, "size": 1000 }],
  "asks": [{ "strike": 114234, "size": 1000 }, { "strike": 114236, "size": 1000 }]
}

A trade: up and down are who is on each side, taker is the side whose order arrived and traded (the other was resting; when the market maker re-quotes through a resting order, it's the taker), and outcome is the side that won, or push, once it settles. A side is null only on trades from before 2026-10-28, when the exchange made its own market.

{
  "id": "Xq3vT9aLm2Pk",
  "market": "BTC-15s",
  "strike": 114234,
  "size": 100,
  "spot": 114233.2,
  "openedAt": 1761600000105,
  "expiresAt": 1761600016000,
  "up": { "id": "a1B2c3D4e5F6", "name": "TRADER-4821" },
  "down": { "id": "Mk7p2Qa9Lr4X", "name": "TRADER-7215" },
  "taker": "up",
  "settlePrice": 114240.02,
  "outcome": "up"
}

Your account

Any key, except to rename.

MethodPathReturns
GET/meYour account
GET/orders?status=open|all&market=&limit=&cursor={orders, next}: open ones by default, newest first. Canceled orders that never filled are kept for a day
GET/orders/:idOne of your orders
GET/positions?status=open|settled|all&market=&limit=&cursor={positions, next}: open ones by default, latest expiry first
GET/positions/:idOne of your positions: your side of the trade with the same id
GET/exposure{exposure}: your open positions summed by expiry and strike
PATCH/me{"name"}: a new callsign (3-16 letters, digits, _ or -, unique). Your account. Only the account's owner: the browser, or a trade key on an account made with POST /accounts

GET /me: your trader record, plus where your paper is. balance is free to trade, inOrders is held by your resting orders, inPositions is in open positions at cost. pnl is realised, and rank is null until your first position settles.

{
  "id": "a1B2c3D4e5F6",
  "name": "TRADER-4821",
  "avatar": null,
  "rank": 41,
  "pnl": 0.5,
  "wins": 6,
  "losses": 5,
  "pushes": 1,
  "streak": 2,
  "bestStreak": 3,
  "balance": 870.5,
  "inOrders": 100,
  "inPositions": 30,
  "equity": 1000.5
}

GET /exposure: one entry per underlying and second at which you hold open positions, soonest first, with your open size on each side at each strike. The underlying's value at that second settles them all, whichever of its markets they came from. At a settle price S, an up at strike k pays 2 × size if S > k, and a down pays 2 × size if S < k, so each entry gives your payout at every price. To keep exposure live, track your positions by id instead: hello (or GET /positions) has your open ones, and each position event updates one.

{
  "exposure": [
    { "underlying": "BTC-USD", "expiresAt": 1761600016000, "strikes": [{ "strike": 114232, "up": 0, "down": 50 }, { "strike": 114234, "up": 120, "down": 0 }] },
    { "underlying": "BTC-USD", "expiresAt": 1761600061000, "strikes": [{ "strike": 114235, "up": 0, "down": 40 }] }
  ]
}

Trading

Trade key.

MethodPathDoes
POST/ordersPlaces an order. 201 {order, positions, canceled}
POST/orders/batch{"cancel"?: [ids], "orders"?: […]}: cancels first (up to 100 ids; any not resting are skipped), then places up to 20 orders in turn, all in one step: nothing else trades in between. {canceled, results}: each result is what POST /orders would return, plus its status, such as {"status": 201, "order", "positions", "canceled"} or {"status": 409, "error": "post-only order would trade"}
DELETE/orders/:idCancels one. The order
DELETE/orders?all=true&market=&side=Cancels every resting order, or one market's and/or side's, including any you placed in the app. {canceled}. Without all=true it's refused, so leaving out an id never cancels everything
POST/me/refillBack to $1,000 once your balance is below $10. Your account

Place an order

Field
marketrequiredBTC-15s, BTC-1m or BTC-5m
siderequiredup or down
sizerequired1 to 100,000, at most 2 decimals. Taken from your balance when you place it
strikeoptionalMakes it a limit order at this strike, on the market's grid (whole dollars on BTC). Leave it out for a market order
tifoptionalLimit orders only. gtc (the default) rests until it fills or you cancel it; ioc takes what it can at the strike or better, then cancels the rest; post only ever rests: if it would trade on arrival (against anyone, your own orders included), it's refused before it touches the book, so it cancels nothing. It isn't kept on the order
clientIdoptionalYour own id for the order, unique per account while the order is kept (a canceled order that never filled, for a day; any other, for good): 1-64 of A-Z a-z 0-9 _ - . :
# market: fills now at the best strikes available, or fails with 409 no liquidity
-d '{"market": "BTC-15s", "side": "up", "size": 25}'

# limit: rests in the book at 114230 until it fills or you cancel it
-d '{"market": "BTC-1m", "side": "up", "size": 100, "strike": 114230}'

# a maker quote that never takes
-d '{"market": "BTC-15s", "side": "down", "size": 200, "strike": 114236, "tif": "post", "clientId": "ask-1"}'
{
  "order": {
    "id": "Qm7rL2xV9cNa",
    "clientId": null,
    "market": "BTC-15s",
    "side": "up",
    "strike": null,
    "size": 25,
    "filled": 25,
    "status": "filled",
    "createdAt": 1761600000105
  },
  "positions": [
    {
      "id": "Xq3vT9aLm2Pk",
      "orderId": "Qm7rL2xV9cNa",
      "market": "BTC-15s",
      "side": "up",
      "strike": 114234,
      "size": 25,
      "spot": 114233.2,
      "openedAt": 1761600000105,
      "expiresAt": 1761600016000,
      "settlePrice": null,
      "result": null,
      "pnl": null
    }
  ],
  "canceled": []
}

positions are what the order opened right away; canceled lists your own resting orders it met and canceled on the way. An order's strike is null for a market order, and while it's open, size − filled rests in the book. A market order that couldn't fill all of its size ends canceled with the rest refunded. One that filled nothing is refused with 409 no liquidity and isn't recorded, and an ioc order that filled nothing is refused with 409 no liquidity at that strike. A refusal also lists in canceled any of your resting orders it canceled. A resting order fills later at its own strike, and you hear about it on the WebSocket or by listing positions. If a request fails in transit, send it again with the same clientId: a 409 duplicate clientId carries the orderId it already belongs to.

To re-quote, send one batch: the ids of your current quotes in cancel and the new ones in orders.

Chat

Trade key.

MethodPathDoes
POST/chat{"text", "share"?, "feedback"?}: up to 240 characters to the global chat. share is one of your settled positions' ids; feedback: true sends it as product feedback (up to 1,000 characters). 201 with the message
PUT/chat/:id/reactions/:emojiPuts your reaction on one of the last 50 messages: 👍 🔥 😂 🚀 💀 👀 (URL-encoded in the path). The message as it now stands
DELETE/chat/:id/reactions/:emojiTakes it off. Both are safe to repeat

Messages sent through the API carry api: true, so people can tell a script from someone typing.

WebSocket

wss://rizon.markets/api/v1/ws. Without a key you get the public channels; with one (an Authorization header, or ?key=rz_… from a browser) you also get your own orders, positions and account, and can trade over the socket.

Pick channels with ?channels=, comma-separated: books is every book and all everything. Leave it out for all of them. With a key, your own order, position and account events come whatever the channels.

ChannelEvents
price:BTC-USDprice: BTC's on every change, up to 20 a second
book:<market>, like book:BTC-15sbook: one market's book (up to 30 levels a side) on every change, up to 10 a second each
tradestrade when a trade opens, and again when it settles
leaderboardleaderboard, whenever it changes
chatchat for each new message, and again when its reactions change

Every message from the server is {"type", "data"}. Events are named for what they carry; trade and chat are upserts, so keep the latest by id. On connect you get hello, then the current state of each subscribed price, book and leaderboard, so nothing waits for the next change.

Eventdata
hello{t, account, orders, positions, markets, channels}: account is null without a key; orders and positions are your open ones, in the order REST lists them
price{underlying, t, price, live}
bookA book
tradeA trade
leaderboardA list of traders
chatA chat message
order · position · accountOne of yours, whenever it changes (needs a key)

You send requests, named for what they do. Each may carry a reqId (a number, or a string of up to 128 characters), and gets one reply: {"type": "ack" | "error", "reqId", "status", "data"}, with the status and body its REST call would return.

RequestFieldsSame as
subscribe · unsubscribechannels: [...]Acked with {channels}: everything you now get
placethe order's fields (trade key)POST /orders
cancelorderId; or all: true, with market? and side? to narrow itDELETE /orders/:id, DELETE /orders?all=true
batchcancel?, orders?POST /orders/batch
pingAcked with {t}: the server time
{ "type": "place", "reqId": 1, "market": "BTC-15s", "side": "up", "size": 10 }
{ "type": "order", "data": { "id": "Qm7rL2xV9cNa", "status": "filled", … } }
{ "type": "position", "data": { "id": "Xq3vT9aLm2Pk", "strike": 114234, … } }
{ "type": "account", "data": { "balance": 990, … } }
{ "type": "ack", "reqId": 1, "status": 201, "data": { "order": { … }, "positions": [ … ], "canceled": [] } }

An order's order, position and account events arrive before its reply. For an order placed over REST they're sent before the response, but on another connection, so either can arrive first. Events aren't replayed after a disconnect: hello on reconnect has your open orders and positions, and GET /positions?status=settled has what settled meanwhile.

A Python bot, with the stream and REST together:

import asyncio, json, os, httpx, websockets  # websockets 14+

KEY = os.environ["RIZON_API_KEY"]
AUTH = {"Authorization": f"Bearer {KEY}"}
API = "https://rizon.markets/api/v1"
WS = "wss://rizon.markets/api/v1/ws?channels=price:BTC-USD,book:BTC-15s"

async def main():
    async with httpx.AsyncClient(base_url=API, headers=AUTH) as rest:
        delay = 1
        while True:
            try:
                async with websockets.connect(WS, additional_headers=AUTH) as ws:
                    delay = 1
                    async for raw in ws:
                        m = json.loads(raw)
                        kind, data = m["type"], m.get("data")
                        if kind == "hello":  # your open orders and positions: rebuild state here
                            print("open positions:", len(data["positions"]))
                        elif kind == "price" and not data["live"]:
                            continue  # a simulated price: sit out
                        elif kind == "position" and data["result"]:
                            print("settled:", data["result"], data["pnl"])
                        # decide, then act over REST, or send {"type": "place", ...} on the socket:
                        # r = await rest.post("/orders", json={"market": "BTC-15s", "side": "up", "size": 10})
            except (OSError, websockets.exceptions.WebSocketException) as e:  # refused, dropped or closed
                print(f"disconnected ({e}), retrying in {delay}s")
                await asyncio.sleep(delay)
                delay = min(delay * 2, 30)

asyncio.run(main())

In a browser:

const ws = new WebSocket(`wss://rizon.markets/api/v1/ws?channels=price:BTC-USD,book:BTC-15s&key=${key}`);
ws.onmessage = (e) => {
  const { type, data } = JSON.parse(e.data);
  if (type === 'price') console.log(new Date(data.t).toISOString(), data.price, data.live ? '' : '(simulated)');
  if (type === 'book') console.log(data.market, data.bids[0]?.strike, data.asks[0]?.strike);
};

Errors and limits

Errors are {"error": "…"} with a status, and the message says what to fix.

StatusMeans
400Malformed: a field or parameter is missing, out of range, or not one the endpoint takes
401No key, or a key that isn't valid (revoked?)
403The key's scope doesn't allow it (this key is read-only), or it's for the account's owner
404No such endpoint, market or object (yours only, for orders and positions)
405The path exists but not with this method: see the Allow header
409Valid, but not now. See below
410A connect code that expired
429Too fast: wait Retry-After seconds (also retryAfter in the body, and on WebSocket errors)
503no price yet (just after a restart), or too many connect requests at once

A trading 409's error is one of these, so a bot can branch on it:

errorAlso carries
insufficient balance
no liquiditycanceled, if it canceled any of your orders
no liquidity at that strikecanceled, likewise
post-only order would trade
duplicate clientIdorderId
order is already filled · order is already canceledorder

The other 409s are refill is available once your balance is below 10, name taken, a full key list, and a connect code that was already answered.

A restart (a deploy) drops connections for a few seconds, and a request in that window can fail with a 502 and no JSON. Resting orders and open positions survive it, and positions that expire meanwhile settle once it's back, on the real price at their expiry.

Limit
REST requests20 a second per key (or per address without one), in bursts of up to 40. Each WebSocket connect counts as one; a bad key counts against its address
Orders20 a second per account, batches and the WebSocket included. Cancels don't count
Batch20 orders
WebSockets10 open per key (or per address without one); 20 messages a second from each key or address, in bursts of up to 40
Chat1 message per 800 ms and 5 per 10 s; 20 reactions per 10 s
New accounts20 per hour per address
Connect requests30 per hour per address
Keys20 live per account

Objects

type Side = 'up' | 'down';

interface Market {                                    // an underlying and a horizon, one continuous book
  id: string; underlying: string; horizon: number;    // 'BTC-15s', 'BTC-USD', 15 (seconds)
  strikeStep: number; strikeOffset: number;           // strikes: strikeOffset + n × strikeStep
  spot: number | null; live: boolean;                 // live: false while the value is simulated or stale
  bid: number | null; ask: number | null; bidSize: number; askSize: number;
  sigma: number;                                      // realised 1-sigma move over the horizon, in the underlying's units
}
interface Book { market: string; t: number; bids: Level[]; asks: Level[] }   // best first
interface Level { strike: number; size: number }

interface Order {
  id: string; clientId: string | null; market: string; side: Side;
  strike: number | null;                              // null: a market order
  size: number; filled: number;                       // while open, size - filled rests
  status: 'open' | 'filled' | 'canceled'; createdAt: number;
}
interface Placed { order: Order; positions: Position[]; canceled: Order[] }   // POST /orders

interface Position {                                  // your side of one trade, with the trade's id
  id: string; orderId: string | null; market: string; side: Side; strike: number; size: number;
  spot: number;                                       // the underlying at the fill
  openedAt: number; expiresAt: number;                // expiresAt: a whole second. Read it, don't compute it
  settlePrice: number | null;
  result: 'win' | 'loss' | 'push' | null;             // null while open
  pnl: number | null;                                 // +size, -size or 0
}

interface Exposure { underlying: string; expiresAt: number; strikes: { strike: number; up: number; down: number }[] }

interface Trade {                                     // a fill on the public tape
  id: string; market: string; strike: number; size: number; spot: number;
  openedAt: number; expiresAt: number;
  up: Party | null; down: Party | null;               // who's on each side (null only on trades before 2026-10-28)
  taker: Side | null;                                 // the side whose order arrived (null only on old trades)
  settlePrice: number | null; outcome: Side | 'push' | null;   // null while open
}
interface Party { id: string; name: string }

interface Trader {
  id: string; name: string; avatar: string | null; rank: number | null;
  pnl: number; wins: number; losses: number; pushes: number; streak: number; bestStreak: number;
}
interface Account extends Trader { balance: number; inOrders: number; inPositions: number; equity: number }

interface ChatMessage {
  id: string; from: Party; text: string; createdAt: number;
  share?: Position; feedback?: true; api?: true;
  reactions?: { emoji: string; by: Party[] }[];
}

interface KeyInfo { id: string; name: string; scope: 'trade' | 'read'; hint: string; createdAt: number; lastUsedAt: number | null }

Avatar URLs are paths on https://rizon.markets.