Solana Tracker LogoSolana Tracker
Swap
Developers
⌘K
Affiliate
All Resources
Stream Solana Token Transfers for Any Mint over WebSocket
Data APITypeScriptOctober 31, 202611 min readSolana Tracker

Stream Solana Token Transfers for Any Mint over WebSocket

Stream every transfer of a Solana token over Datastream: transfer rooms, message shape, enriched USD values and identities, dedupe and reconciliation.

  • transfers,
  • websocket,
  • datastream,
  • streaming,
  • typescript
Solana Data API›Yellowstone gRPC›

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 callRoomWhat 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"
      }
    ]
  }
}
FieldMeaning
transfers[].idsignature:instruction:inner, unique per transfer. inner is -1 for an outer instruction
programsystem, spl-token or token-2022
amount, uiAmountRaw integer and exact decimal strings. uiAmount is null when decimals is unknown
feeToken-2022 transfer fee, raw. amount is before the fee
from, toaddress (token account, or the wallet for SOL), owner (the wallet) and postBalance (raw balance after the transaction)
directionin or out relative to the room's wallet. Only in wallet and token+wallet rooms
balancesThe 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
timestampUnix 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.

References

  • Datastream: Token transfers room
  • Datastream: Token + wallet transfers room
  • Datastream: Wallet transfers room
  • Track Solana wallet and token transfers guide
  • Datastream protocol
  • @solana-tracker/data-api on npm

Build on this example

Ask for changes, a different stack or the next feature. Answers use the Data API SDK and link the docs.

Related Guides

Solana Trades WebSocket: Live Swaps Without Gaps
Data API

Solana Trades WebSocket: Live Swaps Without Gaps

Read more
Solana Price WebSocket: Aggregated and Pool Rooms
Data API

Solana Price WebSocket: Aggregated and Pool Rooms

Read more
Solana Wallet Transfer Alerts: Live Stream with REST Backfill
Data API

Solana Wallet Transfer Alerts: Live Stream with REST Backfill

Read more

Products

  • Data API
  • Pump.fun API
  • Solana RPC
  • Dedicated Nodes
  • Yellowstone gRPC
  • Raptor Swap API
  • Enterprise

Trading

  • Swap
  • Latest Tokens
  • Trending
  • Top Gainers
  • Memescope
  • Whale Watch
  • KOL Tracker

Tools

  • Wallet Tracker
  • Rugcheck
  • PnL Leaderboard
  • KOLScan
  • Axiom Leaderboard
  • Photon Leaderboard
  • Bloom Leaderboard
  • FOMO Leaderboard
  • GMGN Leaderboard
  • Pump.fun App Leaderboard
  • Terminal Leaderboard
  • Platform Compare
  • My Positions
  • Teams

Resources

  • Developer Guides
  • Blog
  • Documentation
  • API Reference
  • Status
  • Affiliate Program — 25% recurring, uncapped
Solana TrackerSolana Tracker© 2026
Terms of ServicePrivacy PolicyContact