Updated 2026-06-06: stream tokens are now rotated server-side and pushed to live clients in-band as a token_refresh frame. Healthy connections are no longer hard-closed at the 24-hour mark — you don't need to schedule a proactive reconnect anymore. You still need a reconnect loop, but for the reasons that genuinely kill a socket: network drops, server restarts, and lapsed subscriptions.
MadeOnSol's WebSocket endpoints use short-lived bearer tokens, not long-lived API keys. This is a deliberate security choice — a compromised WS token is short-lived. Tokens expire after 24 hours, but as long as your subscription is active and your socket is connected, the server rotates the token in place and pushes you the new one before the old one expires — so a healthy stream never drops just because a token aged out.
What still breaks a stream: a network blip, a server restart, or a subscription that lapses. For those, clients must implement a reconnect loop (and handle the in-band token_refresh frame). A naive "connect once and listen forever" pattern will still fail silently on the first network hiccup.
This is the guide to not falling into that trap.
The lifecycle
1. Client: POST /api/v1/stream/token → { token, ws_url, expires_at, next_refresh_at }
2. Client: WebSocket(`${ws_url}?token=${token}`)
3. Client: send { type: "subscribe", channels: [...] }
4. Server streams events. While connected, if your token nears expiry the
server rotates it and pushes { type: "token_refresh", token } in-band —
the connection stays open. It ends only on one of:
- Server restart / network blip → connection drops with code 1011/1006
- Subscription lapsed / token invalid → server closes with code 4001
- Client disconnects → normal close
5. Client repeats from step 1 on any disconnect (and stores the latest
token_refresh value so the next reconnect uses a fresh token).
The critical step is still #5. The token generation endpoint and the WS endpoint are decoupled, so a dropped WS does not by itself give you a new token — you fetch one on reconnect. But while you're connected, you no longer have to: the server hands you rotated tokens in-band via token_refresh.
Getting a token
curl -X POST https://madeonsol.com/api/v1/stream/token \
-H "Authorization: Bearer msk_your_api_key"
Response:
{
"token": "7se3_SNGtSm9hgI8gxEGg7vfyTi4g6RI4E70i6zpohQ",
"expires_at": "2026-04-24T17:15:13Z",
"next_refresh_at": "2026-04-24T16:15:13Z",
"ws_url": "wss://madeonsol.com/ws/v1/stream",
"dex_ws_url": "wss://madeonsol.com/ws/v1/dex-stream",
"usage": "Connect with: wscat -c \"wss://madeonsol.com/ws/v1/stream?token=YOUR_TOKEN\""
}
Two things to notice:
ws_url does not include the token. You have to append it as a query parameter yourself — ${ws_url}?token=${token}. We've seen a handful of integrations fail because the developer passed new WebSocket(ws_url) expecting it would "just work."
next_refresh_at is 23 hours ahead, not 24. While you're connected the server uses this to rotate your token in-band (the token_refresh frame) before it expires, so you no longer have to pre-fetch. It's still a useful hint if you're holding a token without an open socket.
The dex_ws_url field is only included for ULTRA subscribers — the free tier cannot get a stream token at all (403), and PRO gets access only to /ws/v1/stream.
Connecting
Append the token to the URL, open the socket, and send a subscribe frame with the channels you want:
const ws = new WebSocket(`${ws_url}?token=${token}`);
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
channels: ["copytrade:signals", "kol:trades"]
}));
});
Valid channels (gated by tier):
kol:trades — PRO + ULTRA
kol:coordination — PRO + ULTRA
deployer:alerts — PRO + ULTRA
copytrade:signals — PRO + ULTRA (user-scoped — only your own rule firings)
wallet_tracker:events — ULTRA only, subscriber-scoped
The server responds immediately with a { type: "connected", tier } ack, then with { type: "subscribed", channels: [...] } after your subscribe frame. From that point on, every message is one of: a heartbeat ({ type: "heartbeat" } every 30s), a { type: "token_refresh", token } frame when the server rotates your token, or an event payload with a channel field.
Handle token_refresh. It's not an error and your connection stays open — just stash the new token so your next reconnect uses it instead of a stale one:
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "token_refresh") { currentToken = msg.token; return; }
if (msg.type === "heartbeat") { lastHeartbeat = Date.now(); return; }
if (msg.channel) onEvent(msg);
});
The previous token stays valid for 60 seconds after rotation, so an in-flight reconnect during the swap won't be rejected.
Close codes you should handle differently
| Code | Meaning | Action |
|---|
| 4001 | Invalid or expired token | Fetch new token, reconnect |
| 4002 | Connection limit exceeded for your tier | Wait; don't spam |
| 4003 | Auth DB error (transient) | Retry in 2–5s |
| 1013 | Server at global max connection count | Back off heavier (30–60s) |
| 1000 / 1005 | Normal close | Reconnect if you still want the stream |
| 1006 | Abrupt disconnect / network blip | Reconnect quickly |
| 1011 | Server restart | Reconnect after 2–5s |
The common mistake is treating all closes the same. A 4002 means you have too many concurrent connections — reconnecting immediately makes it worse. A 4001 means your token is dead and reconnecting without refetching will loop-fail forever.
Reference implementations
Node.js / TypeScript
import WebSocket from "ws";
class MadeOnSolStream {
private ws: WebSocket | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
private backoffMs = 2000;
private readonly channels: string[];
constructor(
private readonly apiKey: string,
channels: string[],
private readonly onEvent: (msg: any) => void
) {
this.channels = channels;
}
async start() {
try {
const res = await fetch("https://madeonsol.com/api/v1/stream/token", {
method: "POST",
headers: { Authorization: `Bearer ${this.apiKey}` },
});
if (!res.ok) throw new Error(`token fetch ${res.status}`);
const { token, ws_url } = await res.json();
this.ws = new WebSocket(`${ws_url}?token=${token}`);
this.ws.on("open", () => {
this.backoffMs = 2000; // reset on successful connect
this.ws!.send(JSON.stringify({ type: "subscribe", channels: this.channels }));
});
this.ws.on("message", (raw) => this.onEvent(JSON.parse(raw.toString())));
this.ws.on("close", (code) => this.handleClose(code));
this.ws.on("error", () => { /* close fires next, handle there */ });
} catch (err) {
this.handleClose(-1);
}
}
private handleClose(code: number) {
// Tier cap — back off hard
if (code === 4002 || code === 1013) this.backoffMs = 30_000;
// Token problems — also fine, we re-fetch on reconnect anyway
else this.backoffMs = Math.min(this.backoffMs * 1.5, 60_000);
this.reconnectTimer = setTimeout(() => this.start(), this.backoffMs);
}
stop() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close(1000, "client stop");
}
}
// Usage
const stream = new MadeOnSolStream(
process.env.MADEONSOL_API_KEY!,
["copytrade:signals", "kol:trades"],
(msg) => {
if (msg.channel === "copytrade:signals") console.log("signal:", msg.data);
}
);
stream.start();
Key properties:
- Token is fetched on every reconnect. Not stored, not cached — the
start() method always gets a fresh one.
- Exponential backoff capped at 60s. Prevents hammering on outages.
- Different backoff for 4002/1013 — tier/server saturation needs longer waits.
- Backoff reset on successful open — intermittent hiccups don't permanently slow you down.
Python
import asyncio
import json
import httpx
import websockets
class MadeOnSolStream:
def __init__(self, api_key: str, channels: list[str], on_event):
self.api_key = api_key
self.channels = channels
self.on_event = on_event
self.backoff = 2
async def _fetch_token(self):
async with httpx.AsyncClient() as client:
r = await client.post(
"https://madeonsol.com/api/v1/stream/token",
headers={"Authorization": f"Bearer {self.api_key}"},
)
r.raise_for_status()
return r.json()
async def run(self):
while True:
try:
info = await self._fetch_token()
url = f"{info['ws_url']}?token={info['token']}"
async with websockets.connect(url) as ws:
self.backoff = 2
await ws.send(json.dumps({
"type": "subscribe",
"channels": self.channels,
}))
async for raw in ws:
await self.on_event(json.loads(raw))
except Exception as e:
print(f"stream error: {e}; reconnecting in {self.backoff}s")
await asyncio.sleep(self.backoff)
self.backoff = min(self.backoff * 1.5, 60)
async def handle(msg):
if msg.get("channel") == "copytrade:signals":
print("signal:", msg["data"])
asyncio.run(MadeOnSolStream(
api_key="msk_...",
channels=["copytrade:signals", "kol:trades"],
on_event=handle,
).run())