Skip to content

Integrate with STX

Every command on this page was run against https://demo.stxapp.io and its output checked before publishing. Where something does not yet work, it says so.

There is one integration environment you can reach today:

Base URL https://demo.stxapp.io
Money None. Play balances.
Shared Yes — other developers’ orders are in the books, which makes it a realistic test target

Other hostnames you may have seen in older docs or SDK READMEs — in-api-staging.stxapp.io, api-staging.on.sportsxapp.com, api.on.stxapp.ca — either do not resolve publicly or do not serve /api/v1. Use demo.stxapp.io.

  1. Log in to the exchange and open Account → API Keys
  2. Create API Key, give it a label, Generate
  3. Copy the Key ID and the Private Key PEM. The PEM is shown once and never stored by the server.
Key ID: bcea87eb0e67e126490ea541e3534f66
PEM: -----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIH...
-----END PRIVATE KEY-----

One key authenticates all three surfaces: REST, GraphQL and the private WebSocket channels. You do not need a session token or a login call.

The message is three values joined with no separator:

timestamp_ms + HTTP_METHOD_UPPERCASE + path
import base64, time, requests
from cryptography.hazmat.primitives.serialization import load_pem_private_key
HOST = "https://demo.stxapp.io"
KEY_ID = "your-key-id"
PRIVATE = load_pem_private_key(open("stx.pem", "rb").read(), password=None)
def headers(method, path):
"""path must include the query string, if there is one."""
ts = str(int(time.time() * 1000))
sig = PRIVATE.sign((ts + method.upper() + path).encode())
return {
"STX-ACCESS-KEY": KEY_ID,
"STX-ACCESS-TIMESTAMP": ts,
"STX-ACCESS-SIGNATURE": base64.b64encode(sig).decode(),
}
def get(path):
return requests.get(HOST + path, headers=headers("GET", path)).json()
import crypto from 'node:crypto'
import { readFileSync } from 'node:fs'
const HOST = 'https://demo.stxapp.io'
const KEY_ID = 'your-key-id'
const PRIVATE = crypto.createPrivateKey(readFileSync('stx.pem'))
export function headers(method, path) {
const ts = Date.now().toString()
const sig = crypto.sign(null, Buffer.from(ts + method.toUpperCase() + path), PRIVATE)
return {
'STX-ACCESS-KEY': KEY_ID,
'STX-ACCESS-TIMESTAMP': ts,
'STX-ACCESS-SIGNATURE': sig.toString('base64'),
}
}

Four rules. Each one is a real 401 we have seen:

Rule Gets you
The path includes the query string Signing /api/v1/markets then requesting ?status=open fails
Timestamp within 30 seconds of server time Generate per request; never reuse a signature
Method uppercase GET, not get
The body is not signed Only timestamp, method and path

Full scheme and a test vector: Request signing.

Do this first. You need user_id to subscribe to any private WebSocket channel, and /me is the only place it comes from.

me = get("/api/v1/me")["me"]
print(me["user_id"], me["account_id"], me["scope"])
{
"me": {
"key_id": "bcea87eb0e67e126490ea541e3534f66",
"user_id": "a501cce1-aadc-4db5-b8bc-bbdf1c15e86b",
"account_id": "4021a4f3-de26-4743-8db7-c8be7c9d8eaf",
"scope": "read_write",
"method": "api_key"
}
}

Market data is public — no key required, on REST’s GraphQL sibling and on the socket:

Terminal window
curl -s -X POST https://demo.stxapp.io/api/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ marketInfosWithCount(input:{status:[OPEN]}) { count marketInfos { marketId title trading } } }"}'
import requests
path = "/api/v1/orders"
body = {
"market_id": "2bc3d8d8-fca3-432d-8540-e01e11835fae",
"order_type": "limit",
"action": "buy",
"price": 42, # whole cents, 1-99
"quantity": 5,
}
r = requests.post(HOST + path, json=body, headers=headers("POST", path)).json()
order_id = r["order"]["id"]
path = f"/api/v1/orders/{order_id}"
requests.delete(HOST + path, headers=headers("DELETE", path)).json()

Create returns 200 (not 201) with {"order": {...}}. Cancel returns {"status": "cancelled", "order_id": "..."}.

No credentials. Connect, join, then send a watch with market ids — without it the channel is silent.

import json, websocket # pip install websocket-client
ws = websocket.create_connection("wss://demo.stxapp.io/socket/websocket?vsn=2.0.0")
ws.send(json.dumps(["1", "1", "market_updates", "phx_join", {}]))
ws.send(json.dumps(["2", "2", "market_updates", "watch", ["<market-id>", "<market-id>"]]))
while True:
print(ws.recv())

Frames are JSON arrays, not objects:

[join_ref, ref, topic, event, payload]

Keep the connection alive with ["3","3","phoenix","heartbeat",{}] every ~30 seconds.

7. Stream your own orders, trades and positions

Section titled “7. Stream your own orders, trades and positions”

Private channels need the key and your user_id from step 3.

import base64, json, time, websocket
from cryptography.hazmat.primitives.serialization import load_pem_private_key
PRIVATE = load_pem_private_key(open("stx.pem", "rb").read(), password=None)
KEY_ID, USER_ID = "your-key-id", "your-user-id-from-me"
ts = str(int(time.time() * 1000))
sig = base64.b64encode(PRIVATE.sign((ts + "GET" + "/socket/websocket").encode())).decode()
ws = websocket.create_connection(
"wss://demo.stxapp.io/socket/websocket?vsn=2.0.0",
header=[
f"X-STX-ACCESS-KEY: {KEY_ID}",
f"X-STX-ACCESS-TIMESTAMP: {ts}",
f"X-STX-ACCESS-SIGNATURE: {sig}",
],
)
for i, topic in enumerate([
f"active_orders:{USER_ID}",
f"active_trades:{USER_ID}",
f"active_positions:{USER_ID}",
f"portfolio:{USER_ID}",
]):
ws.send(json.dumps([str(i), str(i), topic, "phx_join", {}]))
while True:
print(ws.recv())

Each private channel replies {"status":"ok"} and then pushes a snapshot:

Channel Snapshot event
active_orders:{user_id} all_orders
active_trades:{user_id} all_trades
active_positions:{user_id} all_positions
portfolio:{user_id} summary
user_info:{user_id} user_updated

That differs from market_updates, which sends nothing until you watch.

8. Protect resting orders from a dropped connection

Section titled “8. Protect resting orders from a dropped connection”

Join active_orders with two extra params and the exchange cancels your flagged orders if your client stops heartbeating:

ws.send(json.dumps(["9", "9", f"active_orders:{USER_ID}", "phx_join",
{"cancel_on_disconnect": True, "ping_timeout": 5000}]))

Reply: {"ping_timeout": 5000, "cancel_on_disconnect": true}. Then send ["n","n",f"active_orders:{USER_ID}","ping",{}] inside every ping_timeout window.

ping_timeout is clamped to 5000–20000 ms and must be an integer. Only orders that set cancel_on_disconnect: true themselves are cancelled, and there is a 20-second grace period after the channel drops.

Try it interactively — kill the socket and watch an order leave the book.

Verified against demo.stxapp.io. Being fixed; listed so you do not lose an afternoon.

Issue Workaround
GET /api/v1/markets?status=open returns suspended markets; trading=true is ignored Find open markets via the GraphQL query in step 4
?status=OPEN returns 400 Lowercase the value
The market_updates channel doc elsewhere shows the topic as market_update The topic is market_updates, plural