Solana Tracker LogoSolana Tracker
Swap
Developers
⌘K
Affiliate
All Resources
Solana Wallet Transfer Alerts: Live Stream with REST Backfill
Data APITypeScriptOctober 31, 202612 min readSolana Tracker

Solana Wallet Transfer Alerts: Live Stream with REST Backfill

Build Solana wallet transfer alerts in TypeScript: stream a wallet's transfers live, catch up over REST after reconnects, dedupe and post to a webhook.

  • transfers,
  • wallets,
  • datastream,
  • alerts,
  • typescript
Solana Data API›Enterprise›

Solana wallet transfer alerts tell you when a wallet you care about sends or receives funds: a treasury paying out, a hot wallet getting drained, a KOL moving tokens to an exchange, a customer deposit landing. A reliable alert needs two sources: a live stream so the alert fires within moments, and a history API so nothing is missed while you were disconnected. Solana Tracker provides both for the same transfers: the Datastream room transfer:wallet:{wallet} and the REST endpoint GET /account/{address}/transfers.

This guide builds a TypeScript alert service that streams a wallet's transfers, backfills recent ones on startup, catches up after every reconnect, dedupes across both sources, and posts each alert to an optional webhook. It uses @solana-tracker/data-api 0.6.0. The live room needs a Premium, Business or Enterprise plan of the Solana Data API; the REST history works on every plan.

How the alert pipeline works

StepSourceWhy
1. Connect and join transfer:wallet:{wallet}:enrichedDatastreamNew transfers arrive as they are seen, with USD values and identities
2. Backfill the latest BACKFILL transfersGET /account/{wallet}/transfersAlerts that fired while the service was down
3. Dedupe by signatureBothThe backfill and the stream overlap
4. After each reconnect, fetch everything since the newest transfer seenREST, start + sortOrder=ascFill the gap the socket missed
5. Filter by direction and USD value, then notifyBothOnly the transfers you care about

Alert per transaction, dedupe by signature. A live message is one transaction with all of its transfers for the wallet. A REST page never splits a transaction. So both sources can be turned into one alert per signature, and the signature becomes the dedupe key across both. That is more robust than matching individual transfers, whose shapes differ slightly between the two sources.

Live and REST transfers side by side

The stream and the history describe the same transfers with slightly different fields. The script normalizes both into one Alert type.

MeaningLive room (EnrichedStreamTransfer)REST (AccountTransfer)
Mintmint, plus token.minttoken.mint
Directiondirection (wallet rooms only)direction
Amountamount, uiAmount (null if decimals unknown)amount, uiAmount
USD valuevalue.usd at the token's current pricevalue.usd at the price at the time, with historicPrices=true
Identityfrom.identity, to.identity, null when unknownfrom.identity, to.identity, omitted when unknown
Per-transfer idid (signature:instruction:inner)none; use the signature
ConfirmationProcessed, provisionalIndexed history

Two behaviours differ and need a decision:

  • Swaps. REST leaves out transactions with DEX activity by default (dex=exclude). The live room never sends swap legs (they are in the trade rooms) but does send what remains of a swap, such as platform fees and tips, with swapTx: true. The script skips swapTx messages by default so both sources agree. Set INCLUDE_SWAP_REMAINDERS=true to alert on them too.
  • Failed transactions. The live room never sends them, and REST leaves them out by default (status=success).

Build the alert service in TypeScript

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:

# Data API key from https://www.solanatracker.io/account/data-api (Premium or higher for the live room)
SOLANA_TRACKER_API_KEY=
WALLET=GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3
# all, in or out
DIRECTION=all
# Alert when any matching transfer in a transaction is worth at least this much
MIN_USD=100
# Transfers loaded over REST on startup, 1-100
BACKFILL=20
# Optional: POST each alert as JSON ({ text, alert }) to this URL
WEBHOOK_URL=
# Also alert on what remains of a swap (fees, tips, payouts)
INCLUDE_SWAP_REMAINDERS=false

index.ts:

import {
  Client,
  Datastream,
  DataApiError,
  type AccountTransfer,
  type AccountTransfersParams,
  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 wallet = process.env.WALLET?.trim() || 'GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3';
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(wallet)) exit(`WALLET is not a valid address: ${wallet}`);
const DIRECTIONS = ['all', 'in', 'out'] as const;
const direction = (process.env.DIRECTION?.trim() || 'all') as (typeof DIRECTIONS)[number];
if (!DIRECTIONS.includes(direction)) exit('DIRECTION must be all, in or out');
const minUsd = num('MIN_USD', 100, 0, 1e12);
const backfill = num('BACKFILL', 20, 1, 100);
const webhookUrl = process.env.WEBHOOK_URL?.trim() || undefined;
const includeSwapRemainders = process.env.INCLUDE_SWAP_REMAINDERS?.trim() === 'true';

/** One alert per transaction, whichever source saw it first. */
type Leg = { direction: 'in' | 'out'; uiAmount: string | null; symbol: string; usd: number | null; counterparty: string };
type Alert = { signature: string; timestamp: number | null; legs: Leg[]; source: 'live' | 'rest' };

const short = (a: string | null | undefined) => (a ? `${a.slice(0, 4)}…${a.slice(-4)}` : '?');
const usd = (n: number | null) => (n === null ? 'n/a' : `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`);
const wanted = (d: 'in' | 'out') => direction === 'all' || d === direction;

const seen = new Set<string>(); // signatures already alerted or skipped
let newestTimestamp = 0; // Unix seconds of the newest transfer seen, for catch-up

async function notify(alert: Alert): Promise<void> {
  const lines = alert.legs.map(
    (l) => `${l.direction === 'in' ? 'IN ' : 'OUT'} ${l.uiAmount ?? '?'} ${l.symbol} ${usd(l.usd)} ${l.direction === 'in' ? 'from' : 'to'} ${l.counterparty}`,
  );
  const when = new Date((alert.timestamp ?? Date.now() / 1000) * 1000).toISOString().slice(0, 19).replace('T', ' ');
  const text = `${when} ${short(wallet)} ${lines.join('; ')} (${alert.source}) https://solscan.io/tx/${alert.signature}`;
  console.log(text);
  if (!webhookUrl) return;
  try {
    const res = await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ text, alert }),
      signal: AbortSignal.timeout(5_000),
    });
    if (!res.ok) console.error(`webhook returned HTTP ${res.status}`);
  } catch (error) {
    console.error('webhook failed:', error instanceof Error ? error.message : String(error));
  }
}

function consider(alert: Alert): void {
  if (seen.has(alert.signature)) return;
  seen.add(alert.signature);
  if (seen.size > 5_000) seen.delete(seen.values().next().value as string);
  if (alert.timestamp) newestTimestamp = Math.max(newestTimestamp, alert.timestamp);
  if (alert.legs.length === 0) return;
  // Alert when any matching leg is worth MIN_USD. Unpriced legs only alert at MIN_USD=0.
  if (alert.legs.some((l) => (l.usd ?? 0) >= minUsd)) void notify(alert);
}

// ── Live: transfer:wallet:{wallet}:enriched ────────────────────────────────
function fromLive(update: EnrichedTransferUpdate): Alert {
  const legs: Leg[] = [];
  for (const t of update.transfers) {
    if (!t.direction || !wanted(t.direction)) continue;
    const other = t.direction === 'in' ? t.from : t.to;
    legs.push({
      direction: t.direction,
      uiAmount: t.uiAmount,
      symbol: t.token.symbol ?? short(t.mint),
      usd: t.value.usd, // current price at stream time
      counterparty: other.identity?.name ?? short(other.owner ?? other.address),
    });
  }
  return { signature: update.signature, timestamp: update.timestamp, legs, source: 'live' };
}

// ── REST: GET /account/{wallet}/transfers ──────────────────────────────────
function fromRest(transfers: AccountTransfer[]): Alert[] {
  // A REST page never splits a transaction, so grouping one page by signature is complete.
  const bySignature = new Map<string, Alert>();
  for (const t of transfers) {
    if (!t.signature) continue;
    const alert = bySignature.get(t.signature) ?? { signature: t.signature, timestamp: t.timestamp, legs: [], source: 'rest' as const };
    const other = t.direction === 'in' ? t.from : t.to;
    alert.legs.push({
      direction: t.direction,
      uiAmount: t.uiAmount,
      symbol: t.token.symbol ?? short(t.token.mint),
      usd: t.value?.usd ?? null, // price at the time of the transfer
      counterparty: other?.identity?.name ?? short(other?.owner),
    });
    bySignature.set(t.signature, alert);
  }
  return [...bySignature.values()]; // in page order
}

const client = new Client({ apiKey });
let lastCatchUp = 0;

/** First connect: the latest transfers. After a reconnect: everything since the newest one seen. */
async function catchUp(): Promise<void> {
  if (Date.now() - lastCatchUp < 15_000) return; // a flapping connection must not hammer REST
  lastCatchUp = Date.now();
  const params: AccountTransfersParams = { direction, historicPrices: true, excludeZero: true };
  try {
    if (newestTimestamp === 0) {
      const page = await client.getAccountTransfers(wallet, { ...params, limit: backfill });
      for (const alert of fromRest(page.transfers).reverse()) consider(alert); // newest-first page, alert oldest first
      return;
    }
    // start is inclusive and in seconds; overlap a minute and let the signature set drop repeats.
    let pages = 0;
    for await (const page of client.iterateAccountTransfers(wallet, { ...params, start: newestTimestamp - 60, sortOrder: 'asc', limit: 100 })) {
      for (const alert of fromRest(page.transfers)) consider(alert); // asc pages are already oldest first
      if (++pages >= 5) break;
    }
  } catch (error) {
    const status = error instanceof DataApiError && error.status ? `HTTP ${error.status}: ` : '';
    console.error(`catch-up failed: ${status}${error instanceof Error ? error.message : String(error)}`);
  }
}

// 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})`));
// "connected" fires on the first connect and after every automatic reconnect.
ds.on('connected', () => void catchUp());

await ds.connect();
const sub = ds.subscribe.transfers.wallet(wallet, { enriched: true }).on((update) => {
  // swapTx: what remains of a swap (platform fees, tips). REST leaves DEX transactions out by default.
  const alert = fromLive(update);
  consider(update.swapTx && !includeSwapRemainders ? { ...alert, legs: [] } : alert);
});
console.log(`Watching ${wallet} (${direction}), alerting at ${usd(minUsd)}${webhookUrl ? ' with webhook' : ''}`);

function shutdown(): void {
  sub.unsubscribe();
  ds.disconnect();
  process.exit(0);
}
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);

Output looks like this (values illustrative):

Watching GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3 (all), alerting at $100
2026-10-30 21:14:02 Gach…xJq3 IN  2500 USDC $2,500 from BfG7…2hoJ (rest) https://solscan.io/tx/2Vxc…
2026-10-31 09:41:37 Gach…xJq3 OUT 300 USDC $300 to Some Exchange (live) https://solscan.io/tx/3sGW…
reconnecting (attempt 1)
2026-10-31 09:43:05 Gach…xJq3 IN  1.5 SOL $283.4 from Ducky (rest) https://solscan.io/tx/5V4a…

The last line is a transfer that landed while the socket was down; the catch-up after the reconnect found it over REST.

Catch-up after a reconnect

The SDK emits connected on the first connect and after every automatic reconnect, and rejoins your rooms for you. It does not replay what happened while the socket was down. The script handles that in catchUp():

  • On the first connect, it loads the latest BACKFILL transfers in one request.
  • After a reconnect, it asks for everything since the newest transfer it has seen: start set a minute before that timestamp (Unix seconds, inclusive) and sortOrder: 'asc', so alerts come out oldest first. It uses iterateAccountTransfers, which keeps paging through empty filtered pages, and stops after 5 pages.
  • It runs at most once every 15 seconds, so a rejected key or a flapping network cannot turn into a REST request loop.

Overlap is deliberate. The signature set drops anything already alerted, so it is cheaper to fetch a minute twice than to miss a transfer at the boundary.

Production notes

Persist the dedupe state. The signature set and the newest timestamp live in memory, so a restart re-sends up to BACKFILL alerts. Store both (a small table keyed by wallet is enough) and seed them on startup.

Live alerts are provisional. Transfers stream at processed commitment, and a transaction dropped on a fork gets no rollback message. For alerts that trigger money movement, wait for the transfer to appear in the REST history before acting.

USD thresholds can miss unpriced tokens. A transfer with no price has value.usd: null, which the script treats as 0. A wallet receiving an unknown token never crosses MIN_USD. If that matters, alert on MIN_USD=0 or add a separate rule by mint.

Many wallets. Each wallet is its own room, and there is no limit on rooms per connection. Use one Datastream connection with one subscription per wallet, and key the dedupe state by wallet. For one token per wallet, transfers.tokenWallet(mint, wallet) keeps the traffic smaller.

Webhooks. The script posts { text, alert } with a 5-second timeout and logs failures without retrying. For delivery guarantees, write alerts to a queue and deliver from there. Never put the Datastream URL in a log or a webhook body: it contains your API key.

Request budget. The live room has no per-message cost on Premium and above. Each REST call (the backfill and each catch-up page) counts against your plan's monthly request allowance.

Related guides

  • Solana wallet transfer history API: every filter, paging rule and the grouped response of the REST endpoint.
  • Stream Solana token transfers over WebSocket: every transfer of one mint, between any wallets.
  • Solana wallet identity enrichment: what the identity labels on each side mean.

FAQ

How fast does an alert fire?

Live transfers are sent at processed commitment, before confirmation. An :enriched message can arrive up to 250 ms after the plain one.

Do I miss transfers while disconnected?

The live room does not replay them, but the REST history has them. The script fetches everything since the newest transfer it saw after every reconnect, so the gap is filled from history.

Can I alert on swaps too?

The transfer rooms do not carry swap legs. Join the wallet trade room (subscribe.tx.wallet()) for buys and sells, and set INCLUDE_SWAP_REMAINDERS=true if you also want fees and tips paid by the wallet's swaps.

Why dedupe by signature instead of by transfer?

Live transfers have a per-transfer id, REST transfers do not. Both sources keep every transfer of a transaction together, so the signature is the one key both share.

Does the USD value in a live alert match the REST history?

Not exactly. The live room values a transfer at the token's current price, REST historicPrices at the price at the time of the transfer. For a transfer you see live, they are close.

How many wallets can one connection watch?

Datastream has no limit on concurrent connections or rooms. Join one wallet room per wallet on a single connection.

References

  • Datastream: Wallet transfers room
  • Get Account Transfers API reference
  • Track Solana wallet and token transfers guide
  • Datastream protocol
  • Pricing and limits
  • @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 Wallet Transfer History API: SOL and SPL Transfers in TypeScript
Data API

Solana Wallet Transfer History API: SOL and SPL Transfers in TypeScript

Read more
Stream Solana Token Transfers for Any Mint over WebSocket
Data API

Stream Solana Token Transfers for Any Mint over WebSocket

Read more
Solana Wallet Identity API: Label Traders and Holders
Data API

Solana Wallet Identity API: Label Traders and Holders

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