Overview
Ready-to-run example : All code snippets in this guide are available as runnable scripts in the wallet-balance-tracker repo.git clone https://github.com/MobulaFi/wallet-balance-tracker.git
cd wallet-balance-tracker
bun install
MOBULA_API_KEY = your_key bun run demo
This guide covers how to track wallet balances (native SOL + SPL tokens) across thousands of wallets — typical for market-making operations where you have many wallets trading different tokens on Solana.
There are 3 approaches , each suited to different use cases:
Approach Best for Latency Complexity Webhooks (transfer events)Real-time balance deltas via push notifications ~1-3s Medium Portfolio API (polling)On-demand balance snapshots Per-request Low WebSocket streams (balance subscription)Persistent real-time balance feeds Sub-second Higher
Recommendation for MM wallets : Use webhooks to get notified of every transfer in/out of your wallets, then call the portfolio API to refresh the full balance when needed.
Approach 1: Webhooks (Recommended for MM)
Webhooks let you receive push notifications every time tokens move in or out of your wallets. You track transfer events filtered to your wallet addresses.
Step 1: Create a Transfer Webhook
const API_KEY = 'YOUR_API_KEY' ;
// List your MM wallet addresses
const wallets = [
'WaLLet1111111111111111111111111111111111111' ,
'WaLLet2222222222222222222222222222222222222' ,
'WaLLet3333333333333333333333333333333333333' ,
// ... up to hundreds of wallets per webhook
];
// Build filter: match transfers FROM or TO any of your wallets
const filters = {
or: [
... wallets . map ( w => ({ eq: [ 'transactionFrom' , w ] })),
... wallets . map ( w => ({ eq: [ 'transactionTo' , w ] })),
],
};
const response = await fetch ( 'https://api.mobula.io/api/1/webhook' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
name: 'mm-wallet-tracker-solana' ,
chainIds: [ 'solana:solana' ],
events: [ 'transfer' ],
apiKey: API_KEY ,
url: 'https://your-server.com/webhook/balance-updates' ,
filters ,
}),
});
const webhook = await response . json ();
// IMPORTANT: Save this immediately — it's only shown once!
console . log ( 'Webhook Secret:' , webhook . webhookSecret );
console . log ( 'Stream ID:' , webhook . id );
Filter limit : Max 1,000 leaf operations per webhook. With the or filter above, each wallet uses 2 operations (from + to), so you can track up to 500 wallets per webhook . For more wallets, create multiple webhooks — see Scaling to Thousands of Wallets .
Step 2: Receive Transfer Events on Your Server
Every time a transfer happens involving your wallets, Mobula POSTs a payload like this:
{
"streamId" : "d628fe5d-f550-4c8b-b1c2-c0a91d9d1cfb" ,
"chainId" : "solana:solana" ,
"data" : [
{
"type" : "transfer" ,
"transactionHash" : "5abc...xyz" ,
"blockNumber" : 285123456 ,
"transactionFrom" : "WaLLet1111111111111111111111111111111111111" ,
"transactionTo" : "SomeOtherAddress..." ,
"contract" : "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" ,
"from" : "WaLLet1111111111111111111111111111111111111" ,
"to" : "SomeOtherAddress..." ,
"amount" : "1000000" ,
"amountUSD" : "1.00" ,
"date" : "2026-03-20T10:00:00.000Z"
}
]
}
Key transfer fields:
Field Description from / toActual token sender/receiver transactionFrom / transactionToTransaction-level initiator/target contractSPL token mint address (token being transferred) amountRaw token amount (needs decimal adjustment) amountUSDUSD value of the transfer
Step 3: Build Your Webhook Server
Full runnable server with health check & balance query endpoints: src/server.ts WALLETS = addr1,addr2 WEBHOOK_SECRET = whsec_xxx PORT = 3000 bun run server
import { createHmac } from 'node:crypto' ;
const WEBHOOK_SECRET = process . env . WEBHOOK_SECRET ; // whsec_...
const WALLET_SET = new Set ( YOUR_WALLETS );
// Verify Mobula webhook signature
function verifySignature ( rawBody : string , signature : string ) : boolean {
const expected = createHmac ( 'sha256' , WEBHOOK_SECRET )
. update ( rawBody )
. digest ( 'hex' );
return signature === `sha256= ${ expected } ` ;
}
// Process incoming transfer events
function handleWebhook ( rawBody : string , headers : Headers ) {
// 1. Verify signature
const signature = headers . get ( 'x-signature' );
if ( ! verifySignature ( rawBody , signature )) {
throw new Error ( 'Invalid signature' );
}
// 2. Replay protection (5 min window)
const timestamp = Number ( headers . get ( 'x-timestamp' ));
if ( Math . abs ( Date . now () / 1000 - timestamp ) > 300 ) {
throw new Error ( 'Request too old' );
}
// 3. Process transfers
const payload = JSON . parse ( rawBody );
for ( const event of payload . data ) {
if ( event . type !== 'transfer' ) continue ;
if ( WALLET_SET . has ( event . from )) {
console . log ( `[OUT] ${ event . from } sent ${ event . amount } of ${ event . contract } ` );
}
if ( WALLET_SET . has ( event . to )) {
console . log ( `[IN] ${ event . to } received ${ event . amount } of ${ event . contract } ` );
}
}
}
Step 4: Also Track Swap Events (Optional)
If your MM wallets are actively trading, you can also track swap events to see trades:
const swapFilters = {
or: wallets . map ( w => ({ eq: [ 'swapSenderAddress' , w ] })),
};
await fetch ( 'https://api.mobula.io/api/1/webhook' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
name: 'mm-swap-tracker-solana' ,
chainIds: [ 'solana:solana' ],
events: [ 'swap' ],
apiKey: API_KEY ,
url: 'https://your-server.com/webhook/swap-updates' ,
filters: swapFilters ,
}),
});
Swap payload fields:
Field Description swapSenderAddressWallet that performed the swap poolAddressDEX pool used poolTypeDEX protocol (raydium, raydium-clmm, orca, etc.) addressToken0 / addressToken1Tokens in the pair amount0 / amount1Swap amounts amountUSDTotal swap value in USD rawPostBalance0 / rawPostBalance1Wallet balance AFTER the swap
Pro tip : The rawPostBalance0 and rawPostBalance1 fields in swap events give you the wallet’s balance of both tokens after the swap — no extra API call needed!
Approach 2: Portfolio API (Polling)
For on-demand balance checks or to initialize/reconcile your balance tracking, use the Portfolio API.
Full runnable script with batch polling & rate limiting: src/poll-balances.ts MOBULA_API_KEY = xxx WALLETS = addr1,addr2 bun run poll-balances
Single Wallet Balance
// Get all token balances for a single wallet
const response = await fetch (
'https://api.mobula.io/api/2/wallet/token-balances?' + new URLSearchParams ({
wallet: 'WaLLet1111111111111111111111111111111111111' ,
chainIds: 'solana:solana' ,
}),
{ headers: { Authorization: `Bearer ${ API_KEY } ` } }
);
const { data } = await response . json ();
// data = [
// {
// token: {
// address: "So11111111111111111111111111111111111111112",
// name: "SOL", symbol: "SOL", decimals: 9,
// },
// balance: 12.5, // Human-readable (decimal-adjusted)
// rawBalance: "12500000000" // Raw lamports
// },
// {
// token: {
// address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
// name: "USD Coin", symbol: "USDC", decimals: 6,
// },
// balance: 1500.25,
// rawBalance: "1500250000"
// }
// ]
Full Portfolio with USD Values
const response = await fetch (
'https://api.mobula.io/api/1/wallet/portfolio?' + new URLSearchParams ({
wallet: 'WaLLet1111111111111111111111111111111111111' ,
chains: 'solana:solana' ,
}),
{ headers: { Authorization: `Bearer ${ API_KEY } ` } }
);
const { data } = await response . json ();
// data.total_wallet_balance = 25000.50 (total USD)
// data.assets = [{ asset: { name, symbol }, price, token_balance, estimated_balance, ... }]
Batch Polling for Thousands of Wallets
async function pollAllWallets ( wallets : string [], apiKey : string ) {
const BATCH_SIZE = 10 ;
const DELAY_MS = 200 ;
const results = new Map ();
for ( let i = 0 ; i < wallets . length ; i += BATCH_SIZE ) {
const batch = wallets . slice ( i , i + BATCH_SIZE );
await Promise . all ( batch . map ( async ( wallet ) => {
const res = await fetch (
'https://api.mobula.io/api/2/wallet/token-balances?' + new URLSearchParams ({
wallet , chainIds: 'solana:solana' ,
}),
{ headers: { Authorization: `Bearer ${ apiKey } ` } }
);
const data = await res . json ();
results . set ( wallet , data . data );
}));
if ( i + BATCH_SIZE < wallets . length ) {
await new Promise ( r => setTimeout ( r , DELAY_MS ));
}
}
return results ;
}
Approach 3: WebSocket Balance Stream
For persistent, real-time balance feeds with the lowest latency.
Full runnable script with auto-reconnect: src/ws-stream.ts MOBULA_API_KEY = xxx WALLETS = addr1,addr2 TOKENS = native,EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v bun run ws-stream
Subscribe to Balance Updates
const ws = new WebSocket ( 'wss://streams.mobula.io' );
ws . addEventListener ( 'open' , () => {
ws . send ( JSON . stringify ({
type: 'balance' ,
payload: {
items: [
{ wallet: 'YourWallet...' , token: 'native' , blockchain: 'solana:solana' },
{ wallet: 'YourWallet...' , token: 'EPjFWdd5...' , blockchain: 'solana:solana' },
],
subscriptionTracking: true ,
},
authorization: API_KEY ,
}));
});
ws . addEventListener ( 'message' , ( event ) => {
const data = JSON . parse ( event . data );
// {
// wallet, token, chainId, balance, rawBalance,
// decimals, symbol, name,
// previousBalance, previousRawBalance // delta info
// }
console . log ( ` ${ data . wallet } ${ data . symbol } : ${ data . balance } ` );
});
WebSocket subscriptions require specifying each wallet + token + blockchain triple individually. For thousands of wallets each holding many tokens, the webhook approach is more practical.
Scaling to Thousands of Wallets
Architecture
┌─────────────────────────────────────────────────────────┐
│ Your MM System │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Wallet DB │ │ Balance DB │ │
│ │ (wallets + │ │ (wallet → │ │
│ │ tokens) │ │ token → │ │
│ └──────┬───────┘ │ balance) │ │
│ │ └──────▲───────┘ │
│ │ │ │
│ ┌──────▼───────┐ ┌──────┴───────┐ │
│ │ Webhook │ │ Webhook │ │
│ │ Manager │ │ Receiver │ │
│ │ (CRUD) │ │ (process │ │
│ └──────┬───────┘ │ events) │ │
│ │ └──────▲───────┘ │
└─────────┼───────────────────┼───────────────────────────┘
│ │
▼ │
Mobula Webhook API Mobula pushes
(create/update) transfer events
1. Partition Wallets Across Multiple Webhooks
Each webhook supports ~450 wallets (1,000 filter ops limit, 2 per wallet). The create-webhooks.ts script handles this automatically:
MOBULA_API_KEY = xxx \
WEBHOOK_URL=https://your-server.com/webhook/transfers \
WALLETS=addr1,addr2,...,addr2000 \
bun run create-webhooks
# Output:
# Webhook 0: 450 wallets | Stream ID: abc-123
# Webhook 1: 450 wallets | Stream ID: def-456
# Webhook 2: 450 wallets | Stream ID: ghi-789
# Webhook 3: 450 wallets | Stream ID: jkl-012
# Webhook 4: 200 wallets | Stream ID: mno-345
2. Add/Remove Wallets Dynamically
When you spin up new MM wallets or retire old ones, update the webhook filter with update-webhooks.ts :
# Merge new wallets into existing webhook
MOBULA_API_KEY = xxx STREAM_ID = abc-123 MODE = merge \
WALLETS=newAddr1,newAddr2 bun run src/update-webhooks.ts
# Replace all wallets on a webhook
MOBULA_API_KEY = xxx STREAM_ID = abc-123 MODE = replace \
WALLETS=addr1,addr2,addr3 bun run src/update-webhooks.ts
Under the hood, this calls PATCH /api/1/webhook:
await fetch ( 'https://api.mobula.io/api/1/webhook' , {
method: 'PATCH' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
streamId: 'YOUR_STREAM_ID' ,
apiKey: 'YOUR_API_KEY' ,
mode: 'merge' , // or 'replace'
filters: {
or: [
... newWallets . map ( w => ({ eq: [ 'from' , w ] })),
... newWallets . map ( w => ({ eq: [ 'to' , w ] })),
],
},
}),
});
3. Initialize Balances on Startup
Before relying on webhooks for deltas, fetch current balances for all wallets using the batch poller:
MOBULA_API_KEY = xxx WALLETS = addr1,addr2,...,addr2000 bun run poll-balances
4. Periodic Reconciliation
Webhooks can occasionally miss events (network issues, dead webhook recovery). Run periodic reconciliation by re-polling a random subset:
// Every 5 minutes, reconcile 50 random wallets
setInterval ( async () => {
const subset = getRandomSubset ( allWallets , 50 );
for ( const wallet of subset ) {
const apiBalances = await fetchWalletBalances ( wallet );
const localBalances = balanceStore . get ( wallet );
// Compare and update mismatches
}
}, 5 * 60 * 1000 );
Recommended Production Setup
Initialize : Poll all wallets on startup (bun run poll-balances)
Track : Webhooks push transfer events in real-time (bun run server)
Reconcile : Every 5 min, re-poll a random subset of wallets
Store : Use Redis or a database instead of in-memory maps
Monitor : Track webhook delivery failures via the list webhooks endpoint
See the complete working setup in the wallet-balance-tracker repo — clone it, set your env vars, and run.
Quick Reference
API Endpoints
Action Method URL Create webhook POSThttps://api.mobula.io/api/1/webhookList webhooks GEThttps://api.mobula.io/api/1/webhook?apiKey=...Update webhook PATCHhttps://api.mobula.io/api/1/webhookDelete webhook DELETEhttps://api.mobula.io/api/1/webhook/:idGet token balances GEThttps://api.mobula.io/api/2/wallet/token-balancesGet portfolio GEThttps://api.mobula.io/api/1/wallet/portfolio
Transfer Event Fields for Filtering
Field Type Description fromstring Token sender address tostring Token receiver address transactionFromstring Transaction initiator transactionTostring Transaction target contractstring SPL token mint address amountstring Raw token amount amountUSDstring USD value blockNumbernumber Block number
Filter Operators
Operator Example eq{ "eq": ["from", "WaLLet..."] }neq{ "neq": ["contract", "native"] }gt / gte{ "gte": ["amountUSD", "100"] }lt / lte{ "lt": ["amount", "1000000"] }in{ "in": ["contract", ["mint1", "mint2"]] }and{ "and": [filter1, filter2] }or{ "or": [filter1, filter2] }
Solana-Specific Notes
Native SOL address : So11111111111111111111111111111111111111112 (Wrapped SOL mint)
Chain ID : solana:solana
SOL decimals : 9 (1 SOL = 1,000,000,000 lamports)
USDC decimals : 6
Webhook event delivery is batched (up to 10 events per POST, 2-second debounce)
Failed deliveries are retried every 10 minutes for up to 7 days
Repo Scripts
Script Command Description Demo bun run demoValidate API connection, fetch sample balances Poll bun run poll-balancesFetch current balances for all wallets Create bun run create-webhooksCreate transfer webhooks (auto-partitioned) Update bun run src/update-webhooks.tsAdd/remove wallets on existing webhooks Server bun run serverStart webhook receiver server Stream bun run ws-streamReal-time WebSocket balance feed
GitHub Repo Ready-to-run code example
Webhook Getting Started Webhook basics
Filter Reference Full filter documentation
Transfer Data Model Transfer event fields