A Solana token transfers WebSocket pushes every transfer of a mint between any two wallets the moment it is seen, so you can watch deposits, distributions, treasury moves and large holders without polling. With Solana Tracker Datastream you join transfer:{mint} and receive one message per transaction, carrying its transfers and the balance changes they caused. Append :enriched and every transfer also carries token metadata, a USD value, the token's market cap and the identity of both wallets.
Datastream is included in the Premium, Business and Enterprise plans of the Solana Data API, with unlimited messages and no usage-based fees. The code uses @solana-tracker/data-api 0.6.0, the first SDK version with transfer rooms; it manages the socket, heartbeats, reconnects and room rejoins.
Transfer rooms
There are three transfer rooms. Pick the narrowest one that answers your question.
| SDK call | Room | What arrives |
|---|---|---|
subscribe.transfers.token(mint, opts?) | transfer:{mint} | Every transfer of a token, between any wallets. Not available for SOL |
subscribe.transfers.tokenWallet(mint, wallet, opts?) | transfer:{mint}:{wallet} | One token to or from one wallet, with direction. Works for SOL with the wrapped SOL mint |
subscribe.transfers.wallet(wallet, opts?) | transfer:wallet:{wallet} | Every transfer to or from a wallet, across all tokens, with direction |
{ enriched: true } joins the :enriched variant of the room. transfers.token() throws for SOL, which has no token room. Follow SOL per wallet with transfers.wallet() or transfers.tokenWallet() and the wrapped SOL mint So11111111111111111111111111111111111111112.
What a message contains
Each message is one transaction. A plain room sends this shape (trimmed from the docs example, a Token-2022 transfer):
{
"type": "message",
"room": "transfer:pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn",
"data": {
"signature": "3sGWwELDWpxMaTWkadG95XK3mYiY2TZw8X6VVG7eZopJ8hGvWt4s2A6KK7ZZMVN7Qujg4WtJcm8dXmwvfnyhZ4NS",
"slot": 462944056,
"timestamp": 1793290384,
"commitment": "processed",
"swapTx": false,
"transfers": [
{
"id": "3sGWwELDWpxMaTWkadG95XK3mYiY2TZw8X6VVG7eZopJ8hGvWt4s2A6KK7ZZMVN7Qujg4WtJcm8dXmwvfnyhZ4NS:3:-1",
"instruction": 3,
"inner": -1,
"type": "spl",
"program": "token-2022",
"mint": "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn",
"decimals": 6,
"amount": "18404223000000",
"uiAmount": "18404223",
"from": { "address": "CEbJzsx15hLQkHD6Tsp5vk58rcnMtvbgLReGLzjiy4uH", "owner": "5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9", "postBalance": "1151345685569531" },
"to": { "address": "F5zXibinJCcNKY8BJhYC6iX5sKCKkaaSFV3MJgbVz25M", "owner": "G56HkiSheuhfjnTPGjGC1nNddu7NBtTqedueYKCrEHW2", "postBalance": "18404223000000" },
"swapTx": false
}
],
"balances": [
{
"owner": "5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9",
"account": "CEbJzsx15hLQkHD6Tsp5vk58rcnMtvbgLReGLzjiy4uH",
"mint": "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn",
"program": "token-2022",
"decimals": 6,
"pre": "1169749908569531",
"post": "1151345685569531",
"change": "-18404223000000",
"cause": "transfer"
}
]
}
}
| Field | Meaning |
|---|---|
transfers[].id | signature:instruction:inner, unique per transfer. inner is -1 for an outer instruction |
program | system, spl-token or token-2022 |
amount, uiAmount | Raw integer and exact decimal strings. uiAmount is null when decimals is unknown |
fee | Token-2022 transfer fee, raw. amount is before the fee |
from, to | address (token account, or the wallet for SOL), owner (the wallet) and postBalance (raw balance after the transaction) |
direction | in or out relative to the room's wallet. Only in wallet and token+wallet rooms |
balances | The room's balance changes in that transaction, with pre, post, change and cause (transfer, swap or other). The fee payer's SOL entry has fee |
timestamp | Unix seconds |
In an :enriched room every transfer and balance change also has token (name, symbol, image, decimals), price.usd, value.usd and marketCap.usd, and each from, to and balance change has identity: the wallet's KOL profile, bot, platform, developer, pool or SNS labels, or null for a wallet with none. The enriched message can arrive up to 250 ms after the plain one; if a lookup took longer, it has enrichmentStatus: "partial" and null for what is missing. USDC, USDT and USD1 are priced at 1 and have no market cap.
What the transfer rooms leave out
Knowing what does not arrive matters as much as the shape:
- Swap legs stay in the trade rooms. A transfer that is a leg of a swap already streamed in the trade rooms is not sent again. What remains of a swap, such as platform fees, tips or payouts to other wallets, arrives with
swapTx: true. - Wraps, unwraps and moves between a wallet's own accounts are not transfers.
- Failed transactions are not streamed.
- Nothing is confirmed. Transfers are sent at processed commitment. A transaction can be dropped on a fork, and there is no rollback message.
Stream a token's transfers in TypeScript
The script joins transfer:{mint}:enriched, prints every transfer worth at least MIN_USD, and every SUMMARY_SECONDS prints the number of transactions, the USD moved and the wallets with the largest net inflow and outflow in that window. Install and run with Node.js 20.18 or newer:
npm init -y && npm pkg set type=module
npm install @solana-tracker/data-api@^0.6.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
.env. Datastream uses the same Data API key as REST, in the URL wss://datastream.solanatracker.io/{apiKey}:
# Data API key from https://www.solanatracker.io/account/data-api (Premium or higher)
SOLANA_TRACKER_API_KEY=
# Any SPL or Token-2022 mint except SOL. Defaults to USDC
TOKEN_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
MIN_USD=1000
SUMMARY_SECONDS=60
index.ts:
import { Datastream, type EnrichedStreamTransfer, type EnrichedTransferUpdate } from '@solana-tracker/data-api';
function exit(message: string): never {
console.error(message);
process.exit(1);
}
function num(name: string, fallback: number, min: number, max: number): number {
const raw = process.env[name]?.trim();
const value = raw ? Number(raw) : fallback;
if (!Number.isFinite(value) || value < min || value > max) exit(`${name} must be a number from ${min} to ${max}`);
return value;
}
const apiKey = process.env.SOLANA_TRACKER_API_KEY?.trim();
if (!apiKey) exit('Missing SOLANA_TRACKER_API_KEY (create one at https://www.solanatracker.io/account/data-api)');
const mint = process.env.TOKEN_MINT?.trim() || 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; // USDC
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(mint)) exit(`TOKEN_MINT is not a valid mint address: ${mint}`);
const minUsd = num('MIN_USD', 1_000, 0, 1e12);
const summarySeconds = num('SUMMARY_SECONDS', 60, 5, 3_600);
// The Datastream URL contains your key: never log it.
const ds = new Datastream({ wsUrl: `wss://datastream.solanatracker.io/${apiKey}` });
const redact = (text: string) => text.split(apiKey).join('<key>');
ds.on('error', (error: unknown) => console.error('datastream error:', redact(error instanceof Error ? error.message : String(error))));
ds.on('reconnecting', (attempt: number) => console.error(`reconnecting (attempt ${attempt + 1})`));
const short = (a: string | null | undefined) => (a ? `${a.slice(0, 4)}…${a.slice(-4)}` : '?');
const usd = (n: number) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
const label = (party: EnrichedStreamTransfer['from']) => party.identity?.name ?? short(party.owner ?? party.address);
// `id` is signature:instruction:inner and unique per transfer. Bounded so memory stays flat.
const seen = new Set<string>();
const emptyStats = () => ({ messages: 0, transfers: 0, swapRemainders: 0, partial: 0, usd: 0, unpriced: 0 });
let stats = emptyStats();
const netByOwner = new Map<string, { label: string; net: number }>();
function isNew(t: EnrichedStreamTransfer): boolean {
if (seen.has(t.id)) return false;
seen.add(t.id);
if (seen.size > 10_000) seen.delete(seen.values().next().value as string);
return true;
}
function onTransfer(t: EnrichedStreamTransfer): void {
stats.transfers++;
const amount = t.uiAmount === null ? null : Number(t.uiAmount); // null when decimals are unknown
const value = t.value.usd; // current price × amount; null when no price was found
if (value === null) stats.unpriced++;
else stats.usd += value;
if (amount !== null) {
for (const [party, sign] of [[t.from, -1], [t.to, 1]] as const) {
const owner = party.owner ?? party.address;
const row = netByOwner.get(owner) ?? { label: label(party), net: 0 };
row.net += sign * amount;
netByOwner.set(owner, row);
}
}
if (value !== null && value >= minUsd) {
const when = new Date((t.timestamp ?? Date.now() / 1000) * 1000).toISOString().slice(11, 19);
const fee = t.fee ? ` fee ${t.fee} raw` : ''; // Token-2022 transfer fee; amount is before the fee
console.log(
`${when} ${t.uiAmount} ${t.token.symbol ?? short(t.mint)} ${usd(value)} ${label(t.from)} -> ${label(t.to)}` +
`${t.swapTx ? ' [swap remainder]' : ''}${fee} ${short(t.signature)}`,
);
}
}
function onMessage(update: EnrichedTransferUpdate): void {
const fresh = update.transfers.filter(isNew); // drop repeats from reconnects or overlapping rooms
if (fresh.length === 0) return;
stats.messages++; // one message per transaction
if (update.swapTx) stats.swapRemainders++;
if (update.enrichmentStatus === 'partial') stats.partial++;
for (const t of fresh) onTransfer(t);
}
function printSummary(): void {
const ranked = [...netByOwner.values()].sort((a, b) => b.net - a.net);
const fmt = (r: { label: string; net: number }) => `${r.label} ${r.net > 0 ? '+' : ''}${r.net.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
console.log(
`\n-- last ${summarySeconds}s: ${stats.messages} txs, ${stats.transfers} transfers, ${usd(stats.usd)} moved` +
` (${stats.unpriced} unpriced, ${stats.partial} partial, ${stats.swapRemainders} swap remainders)`,
);
console.log(` top receivers: ${ranked.filter((r) => r.net > 0).slice(0, 3).map(fmt).join(', ') || 'none'}`);
console.log(` top senders: ${ranked.slice(-3).reverse().filter((r) => r.net < 0).map(fmt).join(', ') || 'none'}\n`);
netByOwner.clear();
stats = emptyStats();
}
await ds.connect();
const sub = ds.subscribe.transfers.token(mint, { enriched: true }).on(onMessage);
console.log(`Streaming transfers of ${mint}; printing transfers worth ${usd(minUsd)} or more`);
const timer = setInterval(printSummary, summarySeconds * 1000);
function shutdown(): void {
clearInterval(timer);
sub.unsubscribe();
ds.disconnect();
printSummary();
process.exit(0);
}
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
Output looks like this (values illustrative):
Streaming transfers of EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v; printing transfers worth $1,000 or more
14:02:11 2500 USDC $2,500 Ducky -> 8GV8…T3mG 3og3…uXJp
14:02:13 18000 USDC $18,000 Some Exchange -> 5tzF…uAi9 2Vxc…w9EF
-- last 60s: 1843 txs, 2051 transfers, $4,812,330.5 moved (0 unpriced, 3 partial, 211 swap remainders)
top receivers: 5tzF…uAi9 +18,000, 8GV8…T3mG +2,487.5, G56H…EHW2 +1,200
top senders: Some Exchange -18,000, Ducky -2,500, BfG7…2hoJ -950
A busy mint like USDC sends a lot of messages. Keep the handler synchronous and cheap, as here, and move slow work (database writes, webhooks) onto a queue. A connection that falls more than 1 MiB behind is closed by the server; the SDK reconnects and rejoins, but whatever was sent in between is gone.
Dedupe, reconnects and reconciliation
Dedupe by transfer id. Joining overlapping rooms, for example transfer:{mint} and transfer:{mint}:{wallet}, delivers the same transfer once per room. The SDK does not dedupe transfer rooms, so the script keeps a bounded set of recent ids. Do not dedupe by signature alone: one transaction can carry several transfers of the same token.
Plain or enriched, not both. If you need both shapes, join one room and derive the other. Joining both doubles your traffic and delivers every transfer twice.
Reconcile through REST. Live transfers are provisional. The REST endpoint GET /account/{address}/transfers is per wallet, not per mint, so reconcile the wallets you act on: call it with mint set to your token and a start window around the live transfer, and check the signature is there. The transfer history guide covers that endpoint and its paging rules.
The enriched USD value is current, not historical. In a live room, price.usd is the token's current price from its top pool, so value.usd is what the transfer is worth now. REST historicPrices uses the price at the time of the transfer. For a transfer you see live, the two are close; for one you backfill later, they can differ.
One token for one wallet
To follow a single wallet's balance of one token, including SOL, use tokenWallet. Each message lists the wallet's balance changes for that token, and the fee payer's SOL entry shows the transaction fee:
import { Datastream } from '@solana-tracker/data-api';
const wallet = 'GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3';
const ds = new Datastream({ wsUrl: `wss://datastream.solanatracker.io/${process.env.SOLANA_TRACKER_API_KEY}` });
await ds.connect();
ds.subscribe.transfers
.tokenWallet('So11111111111111111111111111111111111111112', wallet)
.on((update) => {
for (const t of update.transfers) console.log(t.direction, t.uiAmount, 'SOL', update.signature);
for (const b of update.balances) if (b.fee) console.log('fee paid', b.fee, 'lamports');
});
For alerts across every token a wallet holds, use the wallet room instead; the wallet transfer alerts guide builds one with a REST backfill.
FAQ
Can I stream SOL transfers for every wallet?
No. transfer:{mint} is not available for SOL, and the SDK throws if you try. Stream SOL per wallet with transfer:wallet:{wallet} or transfer:So11111111111111111111111111111111111111112:{wallet}.
Why are some transfers of a swap missing?
Swap legs are already in the trade rooms, so the transfer rooms do not repeat them. What remains of a swap, such as platform fees or tips, arrives with swapTx: true. Join a trade room for the swaps themselves.
Are the amounts exact?
Yes. amount is the raw integer and uiAmount the exact decimal, both as strings. For Token-2022 tokens with a transfer fee, amount is before the fee and fee is the fee in raw units.
What happens if a streamed transaction is dropped?
Nothing is sent: there is no rollback message. Transfers are provisional at processed commitment. Reconcile anything you act on with the REST transfer history.
Which plan do I need?
Datastream rooms, including transfer rooms, need a Premium, Business or Enterprise Data API plan. The REST transfer history works on every plan.
What does partial enrichment mean?
An :enriched message whose price, market cap or identity lookup took too long has enrichmentStatus: "partial", with null for the missing values. The plain room for the same transaction is not affected.