Skip to main content

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:
ApproachBest forLatencyComplexity
Webhooks (transfer events)Real-time balance deltas via push notifications~1-3sMedium
Portfolio API (polling)On-demand balance snapshotsPer-requestLow
WebSocket streams (balance subscription)Persistent real-time balance feedsSub-secondHigher
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.
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.
Full runnable script: src/create-webhooks.ts

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:
FieldDescription
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:
FieldDescription
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

All scaling scripts are in the wallet-balance-tracker repo:

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);

  1. Initialize: Poll all wallets on startup (bun run poll-balances)
  2. Track: Webhooks push transfer events in real-time (bun run server)
  3. Reconcile: Every 5 min, re-poll a random subset of wallets
  4. Store: Use Redis or a database instead of in-memory maps
  5. 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

ActionMethodURL
Create webhookPOSThttps://api.mobula.io/api/1/webhook
List webhooksGEThttps://api.mobula.io/api/1/webhook?apiKey=...
Update webhookPATCHhttps://api.mobula.io/api/1/webhook
Delete webhookDELETEhttps://api.mobula.io/api/1/webhook/:id
Get token balancesGEThttps://api.mobula.io/api/2/wallet/token-balances
Get portfolioGEThttps://api.mobula.io/api/1/wallet/portfolio

Transfer Event Fields for Filtering

FieldTypeDescription
fromstringToken sender address
tostringToken receiver address
transactionFromstringTransaction initiator
transactionTostringTransaction target
contractstringSPL token mint address
amountstringRaw token amount
amountUSDstringUSD value
blockNumbernumberBlock number

Filter Operators

OperatorExample
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

ScriptCommandDescription
Demobun run demoValidate API connection, fetch sample balances
Pollbun run poll-balancesFetch current balances for all wallets
Createbun run create-webhooksCreate transfer webhooks (auto-partitioned)
Updatebun run src/update-webhooks.tsAdd/remove wallets on existing webhooks
Serverbun run serverStart webhook receiver server
Streambun 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

Mobula SDK

TypeScript SDK

Support

Telegram support