Updated September 2026: MadeOnSol stream tokens have been permanent since August 27. The old 24-hour expiry and automatic in-band rotation model is retired. expires_at and next_refresh_at remain in the response for compatibility, but they are null.
A WebSocket client still needs a reconnect loop. The reason is no longer token age: networks fail, servers restart, subscriptions lapse, connection limits can be hit, and slow consumers can be disconnected. The robust pattern is therefore simpler than it used to be: obtain your stream token, connect, subscribe, track liveness, and reconnect with bounded backoff when the socket actually dies.
The current lifecycle
1. POST /api/v1/stream/token with your msk_ API key
↓
2. Receive the user's stable stream token
expires_at: null
next_refresh_at: null
↓
3. Connect to wss://madeonsol.com/ws/v1/stream
using the STREAM token, not the msk_ API key
↓
4. Send a subscribe frame
↓
5. Process events + heartbeats
↓
6. If the socket closes:
- 4001: fetch the token again, then reconnect
- 4002: connection limit; back off hard
- 4003: fatal auth/backend condition; stop and investigate
- 4008: slow consumer; reconnect and resume
- network/restart: reconnect with exponential backoff
There is no scheduled refresh timer in that loop.
Step 1: get the stream token
curl -X POST https://madeonsol.com/api/v1/stream/token \
-H "Authorization: Bearer msk_your_api_key"
A current response includes fields like:
{
"token": "7se3_SNGtSm9hgI8gxEGg7vfyTi4g6RI4E70i6zpohQ",
"expires_at": null,
"next_refresh_at": null,
"rotated": false,
"lifetime": "This token does not expire...",
"ws_url": "wss://madeonsol.com/ws/v1/stream"
}
ULTRA-class accounts also receive dex_ws_url for the separate DEX firehose. The same stream token authenticates both sockets.
Calling the token endpoint again normally returns the same token. That is deliberate: two processes belonging to the same account can restart independently without invalidating each other.
What actually invalidates a token?
A stream token stops being usable when:
- the subscription no longer entitles the account to streaming;
- the token row is removed as part of a downgrade; or
- you explicitly rotate the token.
It does not expire merely because 24 hours passed.
Step 2: authenticate the WebSocket correctly
The WebSocket handshake needs the stream token. Do not put the long-lived msk_ API key into the WebSocket handshake.
For server-side Node clients, prefer an Authorization header:
import WebSocket from "ws";
const ws = new WebSocket("wss://madeonsol.com/ws/v1/stream", {
headers: {
Authorization: `Bearer ${streamToken}`,
},
});
Clients that cannot set headers on the upgrade request can still use the query-string form:
const ws = new WebSocket(
`wss://madeonsol.com/ws/v1/stream?token=${encodeURIComponent(streamToken)}`
);
MadeOnSol accepts either form. Query-string token values are masked in MadeOnSol's nginx access logging, but a header is still the cleaner option for server-side clients.
Step 3: subscribe after the connection opens
Opening the socket does not subscribe you to every feed. Send a subscribe message after the connection is ready:
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
channels: ["kol:trades", "deployer:alerts"],
}));
});
Check the server's subscribed acknowledgement rather than assuming every requested channel was accepted. Channel access depends on the account tier, and some newer streams require scoped filters. The token endpoint advertises the current channel registry so clients do not need to maintain a stale hard-coded list.
Step 4: treat heartbeats as liveness, not business events
The main stream emits heartbeats on a 15-second cadence. Record when you last received any valid frame and use a reasonable timeout to detect a half-open socket.
let lastFrameAt = Date.now();
ws.on("message", (raw) => {
lastFrameAt = Date.now();
const msg = JSON.parse(raw.toString());
if (msg.type === "heartbeat") return;
if (msg.channel) {
handleEvent(msg);
}
});
const watchdog = setInterval(() => {
if (Date.now() - lastFrameAt > 45_000) {
ws.terminate();
}
}, 15_000);
The DEX firehose has its own heartbeat cadence, so do not copy a main-stream watchdog threshold blindly into every socket.
Step 5: reconnect with bounded backoff
A reconnect loop should obtain the current stream token on every connection attempt. Because token fetches normally return the same permanent value, this is cheap operationally and also handles the cases where the account was renewed or the token was explicitly rotated.
import WebSocket from "ws";
const API = "https://madeonsol.com";
const API_KEY = process.env.MADEONSOL_API_KEY!;
async function fetchStreamToken() {
const res = await fetch(`${API}/api/v1/stream/token`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error(`stream token: HTTP ${res.status}`);
}
return res.json() as Promise<{
token: string;
ws_url: string;
expires_at: null;
next_refresh_at: null;
}>;
}
async function connect() {
let backoffMs = 2_000;
for (;;) {
try {
const info = await fetchStreamToken();
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(info.ws_url, {
headers: { Authorization: `Bearer ${info.token}` },
});
let subscribed = false;
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
channels: ["kol:trades"],
}));
});
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "subscribed") {
subscribed = true;
backoffMs = 2_000; // reset only after the subscription is accepted
return;
}
if (msg.type === "heartbeat") return;
if (msg.channel) handleEvent(msg);
});
ws.on("close", (code, reason) => {
const text = reason.toString();
const err = Object.assign(
new Error(`WS closed ${code}: ${text}`),
{ code }
);
reject(err);
});
ws.on("error", reject);
});
} catch (err: any) {
const code = Number(err?.code);
if (code === 4003) {
throw err; // fatal: stop rather than spin
}
if (code === 4002) {
backoffMs = Math.max(backoffMs, 60_000);
} else {
backoffMs = Math.min(Math.round(backoffMs * 1.5), 60_000);
}
await new Promise((r) => setTimeout(r, backoffMs));
}
}
}
A production client should also add jitter so many workers do not reconnect in lockstep after a restart.
What close codes mean
The stream exposes close codes so clients can respond differently instead of hammering the server with the same failed reconnect:
| Code | Meaning for the client | Recommended action |
|---|
4001 | Stream token no longer authenticates | Call POST /stream/token again, then reconnect with bounded retries |
4002 |