A solana trades websocket delivers every buy and sell for a token, pool or wallet the moment it is indexed, instead of making you poll trade history. With Solana Tracker Datastream you join transaction:{mint} (or a pool, wallet, whale or KOL room) and receive trade objects with the signature, side, USD and SOL volume, token amount, price, wallet and DEX program. A reliable feed also backfills recent trades over REST, dedupes the overlap and refetches what it missed after a reconnect; this guide builds exactly that.
Datastream is included in the Premium, Business and Enterprise plans of the Solana Data API. The code uses the @solana-tracker/data-api SDK 0.5.0, which manages the sockets, heartbeats, reconnects and room rejoins.
Solana trades WebSocket rooms
Pick the narrowest room that answers your question. Every room below is a method on ds.subscribe.tx.
| SDK call | Room | What arrives | |||
|---|---|---|---|---|---|
token(mint, opts?) | transaction:{mint} | Every swap of the token across all pools | |||
pool(mint, pool, opts?) | transaction:{mint}:{pool} | Swaps of the token in one pool | |||
poolWallet(mint, pool, wallet, opts?) | transaction:{mint}:{pool}:{wallet} | One wallet's swaps in one pool | |||
wallet(address, opts?) | wallet:{address} | One wallet's swaps across all tokens (wallet-shaped payload) | |||
whale(minVolume?, opts?) | `transaction:whale:{1000\ | 2500\ | 5000\ | 10000}` | Trades at or above a USD tier, any token |
kol(minVolume?, opts?) | transaction:kol or transaction:kol:{tier} | Trades by wallets on the shared KOL roster |
Passing { enriched: true } appends :enriched to the room and adds wallet identity to each trade (see the wallet identity guide). Identity reflects current labels, and an incomplete lookup is marked identityStatus: "partial" without a later correction.
Whale and KOL volume tiers are cumulative: a $12,000 trade is published to the 1,000, 2,500, 5,000 and 10,000 rooms. Join one tier, not all four, or you will count the same trade several times. Whale rooms exclude trades under $1,000; the plain transaction:kol room has no volume floor.
The trade message shape
Room messages arrive as { type: "message", room, data }, and on token transaction rooms data is an array of trades. The SDK calls your listener once per trade. This is the TokenTransaction shape, trimmed from the SDK type and the docs example:
{
"tx": "5V4apVkgHf49J5acYPiuh5rB89EGsF8ocSysgnD9vFfZsTtDamhN3MFDKJSq7Dn7Z6Y5XhjghuVYuvvYWnSFpnvW",
"amount": 90,
"priceUsd": 6.002467911111111,
"volume": 540.22,
"solVolume": 2.956,
"type": "sell",
"wallet": "F7R61te4Ac8xZ4pfkJUdF6iaaHy9tnM8nVRUPv8H8EMi",
"time": 1760201288835,
"program": "raydium",
"token": {
"from": { "name": "OFFICIAL TRUMP", "symbol": "TRUMP", "decimals": 6, "amount": 90, "address": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN" },
"to": { "name": "USD Coin", "symbol": "USDC", "decimals": 6, "amount": 540.22, "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }
}
}
volume is USD, solVolume is SOL, amount is the token amount in UI units and time is milliseconds. token.from is what the wallet gave and token.to is what it received.
REST trade history uses a slightly different shape for the same trade. Normalize both into one type before you store or render anything:
| Meaning | Live room (TokenTransaction) | REST history (Trade) |
|---|---|---|
| SOL volume | solVolume | volumeSol |
| Token legs | token.from, token.to | not included |
| Pools | not included | pools (array of pool ids) |
| Signature, side, USD volume, amount, price, wallet, time, program | same names | same names |
WebSocket or REST trade history
| Datastream transaction rooms | getTokenTradeHistory and variants | |
|---|---|---|
| Delivery | Pushed as trades are indexed | Pages of up to 500 trades (default 250) |
| History | Only what happens while connected | Paginate backwards with nextCursor |
| Ordering | Arrival order, mostly chronological | sortDirection DESC (default) or ASC |
| Filters | Token, pool, pool and wallet, wallet, whale tier, KOL | Token, pool, wallet in token, wallet in pool; hideArb, enrich, events |
| Plan | Premium or higher | Any Data API plan |
| Best for | Live tapes, alerts, copy-trade triggers | Backfill, reconciliation, analytics |
You need both. REST fills the screen on startup and recovers trades after a disconnect; the socket keeps it current. The REST methods are getTokenTradeHistory(mint, params), getPoolTradeHistory(mint, pool, params), getUserTokenTradeHistory and getUserPoolTradeHistory. Keep events, sortDirection and the scope unchanged while you reuse a cursor. Swap cursors are timestamps, while mixed or liquidity feeds return opaque strings, so store nextCursor as given.
Dedupe and ordering
Duplicates are normal in a feed that combines a backfill, a live socket and reconnects. Plan for these cases:
- Backfill overlap. A trade that lands while the REST request is in flight can arrive from both sources.
- One signature, several trades. A routed swap can contain more than one trade under the same
tx. Deduping by signature alone silently drops real trades. - Reconnect replays. After a reconnect, the trades you refetch over REST overlap with what you already showed.
- SDK-level dedupe is partial. In SDK 0.5.0, array payloads on token, pool and wallet rooms (the normal shape) pass through untouched, as do
:enrichedrooms. Whale and KOL rooms are deduped per room by signature, so a second trade in the same transaction can be dropped there. Do not depend on either behavior; dedupe in your own code.
A practical key is signature, wallet, side and amount: ${tx}:${wallet}:${type}:${amount}. Keep the set bounded (a few thousand recent keys) so memory does not grow forever.
Arrival order is close to chronological but not guaranteed, especially across a reconnect. Sort the backfill by time, flush buffered live trades in time order, and flag a trade as late when its time is older than the newest one you have printed rather than reordering the whole tape.
Run the example
Create an empty directory and install the SDK:
npm init -y && npm pkg set type=module
npm install @solana-tracker/data-api@^0.5.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
Add a .env file. Get both keys from the Data API dashboard. ST_DATASTREAM_KEY takes the key or the full wss://datastream.solanatracker.io/ URL; it contains your key, so never log it. TOKEN_MINT defaults to the token used in the docs.
ST_DATASTREAM_KEY=
ST_API_KEY=
TOKEN_MINT=6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN
The script subscribes first and buffers, backfills the last 25 trades over REST, flushes the buffer deduped, and walks REST history back after every reconnect until it overlaps what it already printed.
import { Client, Datastream } from "@solana-tracker/data-api";
import type { EnrichedTokenTransaction, EnrichedTrade } from "@solana-tracker/data-api";
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
console.error(`Missing ${name}. Add it to .env`);
process.exit(1);
}
return value;
}
const dsKey = required("ST_DATASTREAM_KEY");
const apiKey = required("ST_API_KEY");
const mint = process.env.TOKEN_MINT?.trim() || "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN";
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(mint)) {
console.error(`TOKEN_MINT is not a valid mint address: ${mint}`);
process.exit(1);
}
type Row = { tx: string; wallet: string; side: "buy" | "sell"; amount: number; usd: number; sol: number; time: number };
const fromLive = (t: EnrichedTokenTransaction): Row =>
({ tx: t.tx, wallet: t.wallet, side: t.type, amount: t.amount, usd: t.volume, sol: t.solVolume, time: t.time });
const fromRest = (t: EnrichedTrade): Row =>
({ tx: t.tx, wallet: t.wallet, side: t.type, amount: t.amount, usd: t.volume, sol: t.volumeSol, time: t.time });
const seen = new Set<string>();
const keyOf = (r: Row) => `${r.tx}:${r.wallet}:${r.side}:${r.amount}`;
let newest = 0;
function emit(r: Row, source: string): void {
const key = keyOf(r);
if (seen.has(key)) return;
seen.add(key);
if (seen.size > 5_000) seen.delete(seen.values().next().value as string);
const late = r.time < newest ? " (late)" : "";
newest = Math.max(newest, r.time);
console.log(`${new Date(r.time).toISOString().slice(11, 19)} ${r.side.toUpperCase().padEnd(4)} $${r.usd.toFixed(2).padStart(10)}` +
` ${r.sol.toFixed(3)} SOL ${r.wallet.slice(0, 4)}…${r.wallet.slice(-4)} ${r.tx.slice(0, 8)} ${source}${late}`);
}
const client = new Client({ apiKey });
async function history(maxPages: number): Promise<Row[]> {
const rows: Row[] = [];
let cursor: number | string | undefined;
for (let page = 0; page < maxPages; page++) {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("trade history timed out")), 15_000);
});
const params = { limit: 25, sortDirection: "DESC" as const, ...(cursor !== undefined ? { cursor } : {}) };
const res = await Promise.race([client.getTokenTradeHistory(mint, params), timeout]).finally(() => clearTimeout(timer));
const pageRows = res.trades.map(fromRest);
rows.push(...pageRows);
if (pageRows.some((r) => seen.has(keyOf(r))) || !res.hasNextPage || res.nextCursor == null) break;
cursor = res.nextCursor;
}
return rows.sort((a, b) => a.time - b.time);
}
const ds = new Datastream({
wsUrl: dsKey.startsWith("wss://") ? dsKey : `wss://datastream.solanatracker.io/${dsKey}`,
autoReconnect: true,
reconnectDelay: 1_000,
reconnectDelayMax: 30_000,
randomizationFactor: 0.5,
});
ds.on("error", () => console.error("Datastream error (check ST_DATASTREAM_KEY and plan access)"));
ds.on("reconnecting", (attempt: number) => console.warn(`Reconnecting, attempt ${attempt + 1}`));
let connectedOnce = false;
ds.on("connected", () => {
if (connectedOnce) {
// The SDK rejoined the room; REST recovers what was published while we were offline.
history(5)
.then((rows) => rows.forEach((r) => emit(r, "gap")))
.catch((e: unknown) => console.warn(`Gap fill failed: ${e instanceof Error ? e.message : String(e)}`));
}
connectedOnce = true;
});
let buffer: Row[] | null = []; // live trades that arrive during the backfill
const sub = ds.subscribe.tx.token(mint, { enriched: false });
const listener = sub.on((t) => {
if (typeof t?.tx !== "string" || (t.type !== "buy" && t.type !== "sell")) return;
if (buffer) buffer.push(fromLive(t));
else emit(fromLive(t), "live");
});
console.log(`Joined ${sub.room}. Ctrl+C to stop.`);
try {
for (const r of await history(1)) emit(r, "rest");
} catch (e) {
console.warn(`Backfill failed, continuing live only: ${e instanceof Error ? e.message : String(e)}`);
}
const pending = (buffer ?? []).sort((a, b) => a.time - b.time);
buffer = null;
pending.forEach((r) => emit(r, "live"));
process.once("SIGINT", () => {
listener.unsubscribe();
ds.unsubscribe(sub.room);
ds.disconnect();
process.exit(0); // disconnect() alone does not stop auto-reconnect in SDK 0.5.0
});
The companion project adds pool scoping with POOL_ADDRESS, a MIN_USD filter, optional identity labels with ENRICHED=true, REST retries with backoff, periodic stats and a net-flow summary on exit.
Production pitfalls
Reconnects lose messages. The SDK reconnects with backoff and rejoins the room, but nothing replays the trades published while the socket was down. Treat each connected event after the first as a signal to fetch recent history and print only unseen trades.
An invalid key looks like a flaky network. The server can accept the handshake and then close immediately with no error payload, so the client reconnects in a loop. If you never see a trade after connecting to a busy token, check the key and your plan before debugging the network.
Volume is per trade, not per transaction. Grouping by signature merges the separate trades of a routed swap into one row and hides how much each wallet traded. Aggregate on your normalized trade rows instead.
Backfill and live must use the same filters. REST history accepts hideArb: true, which keeps only swap legs involving the requested token. The live room has no equivalent switch, so if you enable it on REST, your backfill and live tape count different things. Leave it off when you merge both sources, or apply the same rule to live rows yourself.
Tier rooms are cumulative. A whale trade appears in every tier room it qualifies for. Join one tier per purpose.
Enriched identity is not historical. Labels describe the wallet as it is now, not when it traded, and a partial identity is not corrected later. Enriched notifications can also arrive later or out of order compared with the plain room, so never block the tape on identity; render the trade and attach the label when present.
Exit cleanly. In SDK 0.5.0, disconnect() closes the sockets but auto-reconnect can reopen them. Exit the process after disconnecting, or create a bounded client with autoReconnect: false.
Liquidity is a separate stream. Adds and removals do not arrive on transaction rooms. Use the liquidity rooms and events: "liquidity" on REST history; the liquidity events guide covers them.
FAQ
Which plan do I need for live trades?
Datastream is available on Premium, Business and Enterprise plans. REST trade history works on any Data API plan, so a backfill-only tool does not need Datastream.
How do I follow one wallet instead of one token?
Use subscribe.tx.wallet(address) for that wallet across all tokens, or subscribe.tx.poolWallet(mint, pool, wallet) for one wallet in one pool. The wallet room uses a wallet-shaped payload with price, volume, from and to objects, so normalize it separately.
Why do I see the same signature twice?
Either the trade arrived from both the backfill and the socket, or the transaction contains several trades. Dedupe on signature plus wallet, side and amount, not on the signature alone.
Can I get only large trades?
For one token, filter on volume in your listener. Across all tokens, join subscribe.tx.whale(5000) or another tier; trades below $1,000 never reach whale rooms.
How far back can I backfill?
Page through getTokenTradeHistory with nextCursor and hasNextPage, up to 500 trades per page. Keep the scope, events and sortDirection the same across pages.
Can I turn trades into candles?
Yes for the current interval: sum volume and track the first, highest, lowest and last priceUsd. For history, load candles from the OHLCV endpoint instead of replaying trades. For live prices without trades, see the Solana price WebSocket guide.
References
- Build a Solana trade feed with Datastream (guide)
- Datastream protocol: join, leave, heartbeat, auth failures
- Token transactions room
- Pool transactions room
- Wallet transactions room
- Whale trades room
- KOL trades room
- Get token trades (REST)
- Get pool-specific trades (REST)
- @solana-tracker/data-api on npm
Companion project
The companion project streams trades for TOKEN_MINT or one pool, backfills and dedupes against REST, fills reconnect gaps with cursor pagination, and prints rolling stats.
cp .env.example .env && npm install && npm start