A Solana wallet transfer history API answers one question: what did this wallet send and receive, from whom, and what was it worth at the time? Solana Tracker exposes it as GET /account/{address}/transfers (client.getAccountTransfers() in the SDK). One call returns native SOL, SPL and Token-2022 transfers in and out of a wallet, newest first, with token metadata, the wallet behind each token account, known identities on both sides and, if you ask, the USD value at the time of each transfer.
This guide covers every filter the endpoint takes, the response shape, paging rules that differ from other Data API endpoints, grouping by transaction, and a runnable TypeScript script that summarizes a wallet's flows over the last N days. It uses @solana-tracker/data-api 0.6.0, the first version with transfer methods.
How the Solana wallet transfer history API works
GET /account/{address}/transfers on https://data.solanatracker.io takes a wallet address and returns its transfers. SPL transfers are matched through the token accounts the wallet owns, so you pass the wallet, not its token accounts.
Three defaults shape every response:
- Plain transfers only. Transactions with DEX activity (swaps, liquidity adds and removes, pool fee and reward claims) are left out. Set
dex=includeto keep them ordex=onlyto get just those. - Successful transactions only.
statusdefaults tosuccess. Usestatus=failedorstatus=anyto see the rest. - Unix seconds.
start,endandtimestampare seconds on this endpoint. Milliseconds are rejected, not silently misread.
Each parameter has exactly one name. An unknown parameter is a 400 that names the right one (unknown parameter timeStart; use start), so a typo never returns an unfiltered page.
Filters
| Parameter | Values | Notes |
|---|---|---|
limit | 1-1000, default 100 | At most 100 with minValue or maxValue |
cursor | pagination.next | Keep every other parameter the same between pages |
sortOrder | desc (default), asc | Newest or oldest first |
start, end | Unix seconds, inclusive | end must be at least start |
startSlot, endSlot | slot, inclusive | |
direction | all (default), in, out | Relative to the requested address |
type | sol, spl | spl covers SPL and Token-2022, wrapped SOL included. Both by default |
mint | token mint | The wrapped SOL mint returns native and wrapped SOL transfers |
status | success (default), failed, any | Transaction outcome |
dex | exclude (default), include, only | Acts on whole transactions |
from, to, counterparty | up to 5 comma-separated addresses | Wallet or token account; counterparty matches either side |
excludeFrom, excludeTo | up to 5 comma-separated addresses | Drop transfers from or to these addresses |
minAmount, maxAmount | token units (uiAmount), inclusive | |
minValue, maxValue | USD at the time, inclusive | Turns on historicPrices |
historicPrices | boolean, default false | Adds price.usd and value.usd |
grouped | boolean, default false | Adds transactions, see below |
excludeZero | boolean, default false | Drops zero-amount transfers |
In the SDK the address filters accept an array or a comma-separated string. The SDK checks every filter with the API's rules and throws a ValidationError before sending a request the API would refuse, for example six addresses in from or limit: 500 with minValue.
What counts as DEX activity. A transaction has DEX activity when one of its transfers goes to or comes from a pool: an account a DEX or launchpad program owns (a Pump.fun bonding curve, a Meteora DLMM pool) or a Raydium or Meteora vault authority. A transaction where the wallet sent one token and received another counts too. The check only looks at transfers that match direction and mint, so with those set, a transaction whose pool leg is filtered out counts as a plain transfer.
Response shape
Each transfer, from the API reference:
{
"signature": "2VxcHt8HKSt2T2EuUZLyWPsEKz4H4BPreWV28aZDEzoaHG4Fsu232oKKeQqpVqsSYaf3vv8KN7UoZ4MPJA8vw9EF",
"status": "success",
"type": "spl",
"direction": "in",
"from": { "address": "C2RjaMN2NeYAErS6KiBjPQhHCt95J8mCmmLiQqZXPisR", "owner": "BfG7WYmravgBhNdWpGoiFmF8L1duw2vdqdDHiZid2hoJ" },
"to": { "address": "D9CEogjHA6CpS12F8St9zpSco7pQKJB5uR1RqDsuzQZk", "owner": "GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3" },
"token": { "name": "USD Coin", "symbol": "USDC", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "image": "https://image.solanatracker.io/proxy?url=…", "decimals": 6 },
"amount": "25000000",
"uiAmount": "25",
"decimals": 6,
"price": { "usd": 1 },
"value": { "usd": 25 },
"timestamp": 1792483949,
"slot": 459159504
}
The page wraps the transfers as { "account", "transfers", "pagination": { "next", "hasMore", "count" } }.
| Field | Meaning |
|---|---|
from.address, to.address | The account debited or credited: the token account for SPL, the wallet for SOL |
from.owner, to.owner | The wallet that owned address at the time of the transfer |
from.identity, to.identity | Known wallet identity (KOL, exchange, pool, developer, SNS name, tags). Omitted for unknown wallets |
amount, uiAmount | Raw base units and exact decimal amount, both strings |
type | sol or spl. SOL uses the wrapped SOL mint So11111111111111111111111111111111111111112 |
price.usd, value.usd | Only with historicPrices=true or a value filter. null when no price was found |
timestamp | Block time, Unix seconds |
historicPrices takes the token's USD price from the last 1-minute candle at or before the transfer, at most an hour old, else null. USDC, USDT and USD1 are priced at 1. If the price lookup runs out of time, the response carries enrichmentStatus: "partial", the prices it did not find are null, and that page is not cached.
Keep uiAmount as a string when you store or sum balances. Converting to Number is fine for a display summary, which is what the script below does.
Transfers, trades, holdings or PnL
Transfers are one of four wallet views in the Solana Data API. Pick the one that matches the question:
| Question | Endpoint | SDK |
|---|---|---|
| What did the wallet send and receive? | GET /account/{address}/transfers | getAccountTransfers |
| What did it buy and sell? | GET /wallet/{owner}/trades | getWalletTrades |
| What does it hold right now? | GET /wallet/{owner} | getWallet, see the portfolio guide |
| Did it make money? | PnL V2 | getPnlV2WalletOverview, see the PnL guide |
Because transfers leave out DEX transactions by default, the transfer history and the trade history barely overlap. Use transfers for deposits, withdrawals, payouts, airdrops and wallet-to-wallet moves; use trades for swaps.
Build a wallet flow summary in TypeScript
The script loads a wallet's transfers for the last DAYS days with USD values at the time, follows the cursor for up to MAX_PAGES pages, and prints totals per token, the top counterparties and the latest transfers. 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:
# Data API key from https://www.solanatracker.io/account/data-api
SOLANA_TRACKER_API_KEY=
WALLET=GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3
# all, in or out
DIRECTION=all
# Optional token mint, e.g. EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v for USDC
MINT=
DAYS=30
MAX_PAGES=5
index.ts:
import {
Client,
DataApiError,
RateLimitError,
ValidationError,
type AccountTransfer,
type AccountTransfersParams,
} 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 days = num('DAYS', 30, 1, 3650);
const maxPages = num('MAX_PAGES', 5, 1, 100);
const params: AccountTransfersParams = {
start: Math.floor(Date.now() / 1000) - days * 86_400, // Unix seconds, not ms
direction,
mint: process.env.MINT?.trim() || undefined,
historicPrices: true, // adds price.usd and value.usd at the time of each transfer
excludeZero: true,
limit: 100,
};
const client = new Client({ apiKey });
// The SDK has no timeout and no retry: add both. Never retry a ValidationError.
async function call<T>(label: string, fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
let timer: NodeJS.Timeout | undefined;
try {
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out`)), 20_000);
});
return await Promise.race([fn(), timeout]);
} catch (error) {
const retryable =
!(error instanceof ValidationError) &&
(error instanceof RateLimitError ||
!(error instanceof DataApiError) ||
error.status === undefined ||
error.status >= 500); // 502: history unavailable, 503: prices unavailable
if (!retryable || attempt >= attempts) throw error;
const retryAfterMs = error instanceof RateLimitError && error.retryAfter ? error.retryAfter * 1000 : 0;
await new Promise((resolve) => setTimeout(resolve, Math.max(retryAfterMs, 500 * 2 ** attempt)));
} finally {
clearTimeout(timer);
}
}
}
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 amt = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 4 });
/** The other side of a transfer, from the requested wallet's point of view. */
const counterparty = (t: AccountTransfer) => (t.direction === 'in' ? t.from : t.to);
type TokenRow = { symbol: string; in: number; out: number; usdIn: number; usdOut: number; unpriced: number };
type PartyRow = { label: string; count: number; usd: number };
const tokens = new Map<string, TokenRow>();
const parties = new Map<string, PartyRow>();
const recent: AccountTransfer[] = [];
let total = 0;
let emptyPages = 0;
let pages = 0;
try {
let cursor: string | undefined;
do {
const page = await call('getAccountTransfers', () =>
client.getAccountTransfers(wallet, cursor ? { ...params, cursor } : params),
);
pages++;
if (page.transfers.length === 0) emptyPages++; // filtered pages can be empty while hasMore is true
for (const t of page.transfers) {
total++;
if (recent.length < 10) recent.push(t);
const row = tokens.get(t.token.mint) ?? { symbol: t.token.symbol ?? short(t.token.mint), in: 0, out: 0, usdIn: 0, usdOut: 0, unpriced: 0 };
const amount = Number(t.uiAmount); // exact decimal string; Number is fine for a summary
const value = t.value?.usd;
if (t.direction === 'in') row.in += amount;
else row.out += amount;
if (value == null) row.unpriced++;
else if (t.direction === 'in') row.usdIn += value;
else row.usdOut += value;
tokens.set(t.token.mint, row);
const other = counterparty(t);
if (other) {
const party = parties.get(other.owner) ?? { label: other.identity?.name ?? short(other.owner), count: 0, usd: 0 };
party.count++;
party.usd += value ?? 0;
parties.set(other.owner, party);
}
}
cursor = page.pagination.hasMore ? (page.pagination.next ?? undefined) : undefined;
} while (cursor && pages < maxPages);
console.log(`${short(wallet)}: ${total} transfers in the last ${days} days (${pages} pages, ${emptyPages} empty)${cursor ? ', more available' : ''}\n`);
console.log('Token In Out USD in USD out');
for (const r of [...tokens.values()].sort((a, b) => b.usdIn + b.usdOut - (a.usdIn + a.usdOut)).slice(0, 15)) {
console.log(
`${r.symbol.slice(0, 12).padEnd(13)} ${amt(r.in).padEnd(15)} ${amt(r.out).padEnd(15)} ${usd(r.usdIn).padEnd(13)} ${usd(r.usdOut)}` +
(r.unpriced ? ` (${r.unpriced} unpriced)` : ''),
);
}
console.log('\nTop counterparties');
for (const p of [...parties.values()].sort((a, b) => b.usd - a.usd).slice(0, 5)) {
console.log(`${p.label.slice(0, 24).padEnd(25)} ${String(p.count).padStart(4)} ${p.count === 1 ? 'transfer ' : 'transfers'} ${usd(p.usd)}`);
}
console.log('\nLatest');
for (const t of recent) {
const when = t.timestamp ? new Date(t.timestamp * 1000).toISOString().slice(0, 16).replace('T', ' ') : '?';
const other = counterparty(t);
console.log(
`${when} ${t.direction.padEnd(3)} ${t.uiAmount} ${t.token.symbol ?? short(t.token.mint)} ` +
`${t.value?.usd != null ? usd(t.value.usd) : 'n/a'} ${t.direction === 'in' ? 'from' : 'to'} ` +
`${other?.identity?.name ?? short(other?.owner)} ${short(t.signature)}`,
);
}
} catch (error) {
const status = error instanceof DataApiError && error.status ? `HTTP ${error.status}: ` : '';
exit(`Transfer history failed: ${status}${error instanceof Error ? error.message : String(error)}`);
}
Output looks like this (values illustrative):
Gach…xJq3: 214 transfers in the last 30 days (3 pages, 1 empty), more available
Token In Out USD in USD out
USDC 12,450 9,800 $12,450 $9,800
SOL 41.2 38.75 $7,912.4 $7,301.9
BONK 1,250,000 0 $31.2 $0 (2 unpriced)
Top counterparties
Some Exchange 18 transfers $14,210
BfG7…2hoJ 6 transfers $2,100
client.iterateAccountTransfers(wallet, params) does the same paging in a for await loop, follows cursors through empty filtered pages and stops on a repeated cursor. It has no retry, so the script pages by hand to wrap each call.
Paging rules
Follow pagination.next as cursor until hasMore is false, with every other parameter unchanged. An empty cursor= returns the first page. Two rules differ from the other Data API endpoints:
- A page never splits a transaction. It can hold a few transfers more or fewer than
limit. - Filtered pages can be empty. Filtered requests read a bounded number of transfers per call, so a page can hold fewer than
limitrows, or none, whilehasMoreis stilltrue. Keep paging. WithminValueormaxValue, each page prices the nextlimittransfers and returns those that match.
Stop on hasMore, never on an empty transfers array. The script above counts empty pages so you can see this happen.
Group transfers by transaction
grouped: true adds transactions: the page's transfers grouped by signature, each with type (swap when dex is true, otherwise transfer), direction (in, out, both or swap), legs, totals.in, totals.out and totals.tokens. When the wallet sent one token and received another, swap names both sides. With prices on, the totals carry USD too: totals.value (gross in plus out), totals.net (in minus out), and usd on each side.
import { Client } from '@solana-tracker/data-api';
const client = new Client({ apiKey: process.env.SOLANA_TRACKER_API_KEY! });
const wallet = 'GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3';
// One entry per transaction, swaps included.
const grouped = await client.getAccountTransfers(wallet, { grouped: true, dex: 'include', historicPrices: true, limit: 50 });
for (const tx of grouped.transactions) {
const swap = tx.swap ? `${tx.swap.out.uiAmount} ${tx.swap.out.token.symbol} -> ${tx.swap.in.uiAmount} ${tx.swap.in.token.symbol}` : '';
console.log(tx.signature, tx.type, tx.direction, tx.legs, tx.totals.net?.usd ?? null, swap);
}
With { grouped: true }, the SDK types the response as GroupedAccountTransfersResponse, so transactions needs no cast. On grouped pages pagination.count counts transactions and pagination.transferCount counts transfers, and limit usually counts transactions; the API reference lists the one exception.
Without the SDK
Any HTTP client works. Send the key in the x-api-key header:
const wallet = 'GachaNgyXTU3zFogQ8Z5jR2BLXs8215X2AtEH18VxJq3';
const url = new URL(`https://data.solanatracker.io/account/${wallet}/transfers`);
url.search = new URLSearchParams({ direction: 'out', type: 'sol', minAmount: '1', limit: '50' }).toString();
const res = await fetch(url, { headers: { 'x-api-key': process.env.SOLANA_TRACKER_API_KEY ?? '' } });
const body = await res.json();
if (!res.ok) throw new Error(body.error); // 400s name the bad parameter
for (const t of body.transfers) console.log(t.timestamp, t.uiAmount, 'SOL to', t.to?.owner, t.signature);
Errors and request budget
| Status | Cause | What to do |
|---|---|---|
| 400 | Invalid address, filter or cursor, or an unknown parameter | Fix the request; the message names the parameter. Do not retry |
| 401 | Missing or invalid key | Check SOLANA_TRACKER_API_KEY |
| 429 | Rate limit or monthly allowance reached | Back off and honour RateLimitError.retryAfter |
| 502 | Transfer history temporarily unavailable | Retry |
| 503 | Historic prices unavailable, with minValue or maxValue | Retry |
Each page is one REST request against your plan's monthly request allowance, and the Free plan is limited to 3 requests per second, so a deep backfill on Free needs a delay between pages. Narrow the query instead of paging through everything: a start window, a mint or a counterparty cuts the number of pages far more than client-side filtering. Plan allowances are on the pricing page.
From history to live
REST history is the source of truth for what already happened. For transfers as they happen, join a transfer room on Datastream: stream a token's transfers for a mint, or build wallet transfer alerts that combine this endpoint with the live wallet room.
FAQ
Does the transfers endpoint include swaps?
Not by default. Transactions with DEX activity are left out. Set dex=include to keep them or dex=only to get only those; grouped=true then marks each one as type: "swap".
Are timestamps in seconds or milliseconds?
Seconds, for start, end and timestamp. Milliseconds are rejected with a 400.
How do I get only SOL transfers?
Pass type=sol for native SOL only, or mint=So11111111111111111111111111111111111111112 for native and wrapped SOL together.
Why is a page empty when hasMore is true?
Filtered requests read a bounded number of transfers per call. Keep following pagination.next until hasMore is false; iterateAccountTransfers does this for you.
Is the USD value current or historical?
Historical. With historicPrices=true or a value filter, price.usd comes from the last 1-minute candle at or before the transfer, at most an hour old. Without a price in that window it is null.
Which SDK version has transfer methods?
@solana-tracker/data-api 0.6.0 or later. It adds getAccountTransfers, iterateAccountTransfers and the subscribe.transfers rooms.