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
| Step | Source | Why |
|---|---|---|
1. Connect and join transfer:wallet:{wallet}:enriched | Datastream | New transfers arrive as they are seen, with USD values and identities |
2. Backfill the latest BACKFILL transfers | GET /account/{wallet}/transfers | Alerts that fired while the service was down |
| 3. Dedupe by signature | Both | The backfill and the stream overlap |
| 4. After each reconnect, fetch everything since the newest transfer seen | REST, start + sortOrder=asc | Fill the gap the socket missed |
| 5. Filter by direction and USD value, then notify | Both | Only 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.
| Meaning | Live room (EnrichedStreamTransfer) | REST (AccountTransfer) |
|---|---|---|
| Mint | mint, plus token.mint | token.mint |
| Direction | direction (wallet rooms only) | direction |
| Amount | amount, uiAmount (null if decimals unknown) | amount, uiAmount |
| USD value | value.usd at the token's current price | value.usd at the price at the time, with historicPrices=true |
| Identity | from.identity, to.identity, null when unknown | from.identity, to.identity, omitted when unknown |
| Per-transfer id | id (signature:instruction:inner) | none; use the signature |
| Confirmation | Processed, provisional | Indexed 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, withswapTx: true. The script skipsswapTxmessages by default so both sources agree. SetINCLUDE_SWAP_REMAINDERS=trueto 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
BACKFILLtransfers in one request. - After a reconnect, it asks for everything since the newest transfer it has seen:
startset a minute before that timestamp (Unix seconds, inclusive) andsortOrder: 'asc', so alerts come out oldest first. It usesiterateAccountTransfers, 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
identitylabels 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.