Skip to main content
Alpha feature: Token Filters access is gated. Ask the Mobula team to enable it for your account. Until then, use Pulse; it works the same way for the same view/filter workflow.
Pulse remains the production-ready path while Token Filters is in alpha. This guide is for teams that already have Token Filters enabled or are preparing a migration.

Migration Overview

Token Filters introduces several breaking changes that improve the developer experience:
AspectPulse V2Token Filters
InitializationWebSocket messageWebSocket full payload, or optional REST init for initial data
Field namingMixed (filters: snake_case, output: camelCase)Consistent camelCase everywhere
UpdatesFull object on every changePartial updates (only changed fields)
Sync messagesPeriodic sync messagesReal-time view updates, no sync
Delete formatPipe-separated chainId|addressObject { chainId, address }
Mode parameterassetMode: true/falsemode: "token" or "market"
REST endpoint/api/2/pulse/api/2/token/filters
WebSocket typepulse-v2token-filters

Step 1: Update Connection Flow

Pulse V2 (Old Way)

// V2: Single WebSocket message for init + subscribe
const ws = new WebSocket('wss://api.mobula.io');

ws.on('open', () => {
  ws.send(JSON.stringify({
    type: 'pulse-v2',
    authorization: 'YOUR_API_KEY',
    payload: {
      assetMode: true,
      model: 'default',
      chainId: ['solana:solana'],
      poolTypes: ['pumpfun']
    }
  }));
});

// Wait for 'init' message with data
ws.on('message', (data) => {
  const msg = JSON.parse(data);
  if (msg.type === 'init') {
    // Initialize state with msg.payload
  }
});

Token Filters (New Way)

// Token Filters: WebSocket-only is recommended for live views.
// Use REST first only when you want initial data before opening WebSocket.

// Optional Step 1: Initialize via REST API
const initResponse = await fetch('https://api.mobula.io/api/2/token/filters', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'YOUR_API_KEY'
  },
  body: JSON.stringify({
    mode: 'token',  // Changed from assetMode: true
    views: {
      'new-tokens': {
        sortBy: 'createdAt',
        sortOrder: 'desc',
        limit: 50,
        filters: {
          chainId: { in: ['solana:solana'] },
          source: { in: ['pumpfun'] }
        }
      }
    }
  })
});

const { views } = await initResponse.json();

// Initialize state with REST response (optional - for initial data)
const state = new Map();
for (const [viewName, viewData] of Object.entries(views)) {
  state.set(viewName, new Map(
    viewData.data.map(item => [`${item.chainId}|${item.address}`, item])
  ));
}

// Step 2: Connect to WebSocket with FULL payload (not subscriptionId)
const ws = new WebSocket('wss://api.mobula.io');

ws.on('open', () => {
  // IMPORTANT: Send the complete payload, not just subscriptionId
  ws.send(JSON.stringify({
    type: 'token-filters',  // Changed from pulse-v2
    authorization: 'YOUR_API_KEY',
    payload: {
      mode: 'token',
      delta: true,
      maxUpdatesPerMinute: 120,
      views: {
        'new-tokens': {
          sortBy: 'createdAt',
          sortOrder: 'desc',
          limit: 50,
          filters: {
            chainId: { in: ['solana:solana'] },
            source: { in: ['pumpfun'] }
          }
        }
      }
    }
  }));
});
Important: Always send the full mode and views payload via WebSocket. Do NOT send just a subscriptionId - the WebSocket subscription is stateless and requires the complete configuration.

Step 2: Update Mode Parameter

Pulse V2

{
  "assetMode": true,
  ...
}

Token Filters

{
  "mode": "token",
  ...
}
Pulse V2Token Filters
assetMode: truemode: "token"
assetMode: falsemode: "market"

Step 3: Update Filter Field Names

Token Filters uses consistent camelCase for all filter fields. Here’s the mapping:

Price & Market Data

Pulse V2Token Filters
market_capmarketCapUSD
latest_pricepriceUSD
latest_market_capmarketCapUSD
liquidityliquidityUSD
liquidity_maxliquidityMaxUSD

Volume Fields

Pulse V2Token Filters
volume_1minvolume1minUSD
volume_5minvolume5minUSD
volume_15minvolume15minUSD
volume_1hvolume1hUSD
volume_4hvolume4hUSD
volume_6hvolume6hUSD
volume_12hvolume12hUSD
volume_24hvolume24hUSD
volume_buy_1hvolumeBuy1hUSD
volume_sell_1hvolumeSell1hUSD

Price Change Fields

Pulse V2Token Filters
price_change_1minpriceChange1minPercentage
price_change_5minpriceChange5minPercentage
price_change_1hpriceChange1hPercentage
price_change_4hpriceChange4hPercentage
price_change_6hpriceChange6hPercentage
price_change_12hpriceChange12hPercentage
price_change_24hpriceChange24hPercentage

Trade & Participant Fields

Pulse V2Token Filters
trades_1htrades1h
buys_1hbuys1h
sells_1hsells1h
buyers_1hbuyers1h
sellers_1hsellers1h
traders_1htraders1h
fees_paid_1hfeesPaid1hUSD

Holdings Fields

Pulse V2Token Filters
holders_countholdersCount
top_10_holdings_percentagetop10HoldingsPercentage
top_50_holdings_percentagetop50HoldingsPercentage
top_100_holdings_percentagetop100HoldingsPercentage
top_200_holdings_percentagetop200HoldingsPercentage
dev_holdings_percentagedevHoldingsPercentage
insiders_holdings_percentageinsidersHoldingsPercentage
bundlers_holdings_percentagebundlersHoldingsPercentage
snipers_holdings_percentagesnipersHoldingsPercentage
pro_traders_holdings_percentageproTradersHoldingsPercentage
fresh_traders_holdings_percentagefreshTradersHoldingsPercentage
smart_traders_holdings_percentagesmartTradersHoldingsPercentage

Trader Category Fields

Pulse V2Token Filters
insiders_countinsidersCount
bundlers_countbundlersCount
snipers_countsnipersCount
fresh_traders_countfreshTradersCount
pro_traders_countproTradersCount
smart_traders_countsmartTradersCount
fresh_traders_buysfreshTradersBuys
pro_traders_buysproTradersBuys
smart_traders_buyssmartTradersBuys

Organic Fields

Pulse V2Token Filters
organic_trades_1horganicTrades1h
organic_volume_1horganicVolume1hUSD
organic_buys_1horganicBuys1h
organic_sells_1horganicSells1h
organic_buyers_1horganicBuyers1h
organic_sellers_1horganicSellers1h
organic_traders_1horganicTraders1h
organic_volume_buy_1horganicVolumeBuy1hUSD
organic_volume_sell_1horganicVolumeSell1hUSD
Pulse V2Token Filters
trending_score_1mintrendingScore1min
trending_score_5mintrendingScore5min
trending_score_1htrendingScore1h
(etc.)(etc.)

Bonding & Timestamp Fields

Pulse V2Token Filters
bonding_percentagebondingPercentage
created_atcreatedAt
latest_trade_datelatestTradeDate
bonded_atbondedAt
migrated_atmigratedAt

Metadata Fields

Pulse V2Token Filters
token_symbolsymbol
token_namename
dexscreener_listeddexscreenerListed
dexscreener_headerdexscreenerHeader
dexscreener_ad_paiddexscreenerAdPaid
dexscreener_boosteddexscreenerBoosted
twitter_reuses_counttwitterReusesCount
twitter_rename_counttwitterRenameCount
deployer_migrations_countdeployerMigrationsCount
deployer_tokens_countdeployerTokensCount
live_statusliveStatus
live_thumbnailliveThumbnail
livestream_titlelivestreamTitle
live_reply_countliveReplyCount

Special Filters

Pulse V2Token Filters
created_at_offsetcreatedAtOffset
min_socialsminSocials
includeKeywordsincludeKeywords (unchanged)
excludeKeywordsexcludeKeywords (unchanged)
addressToExcludeaddressToExclude (unchanged)

Step 4: Handle Partial Updates

Pulse V2 (Full Updates)

ws.on('message', (data) => {
  const msg = JSON.parse(data);
  
  if (msg.type === 'update-token') {
    // V2: Full token object received
    const token = msg.payload.token;
    const key = `${token.chainId}|${token.address}`;
    state.get(msg.payload.viewName).set(key, token);
  }
});

Token Filters (Partial Updates)

ws.on('message', (data) => {
  const msg = JSON.parse(data);
  
  if (msg.type === 'update-token') {
    // Token Filters: Only changed fields received
    const partial = msg.payload.token;
    const key = `${partial.chainId}|${partial.address}`;
    const viewState = state.get(msg.payload.viewName);
    
    if (viewState.has(key)) {
      // Merge partial update with existing state
      const existing = viewState.get(key);
      viewState.set(key, { ...existing, ...partial });
    }
  }
});
Important: Token Filters partial updates only include fields that changed. You MUST merge them with your existing state, not replace the entire object.

Step 5: Handle Remove Messages

Pulse V2 (Pipe-separated String)

if (msg.type === 'remove-token') {
  // V2: tokenKey is "chainId|address"
  const tokenKey = msg.payload.tokenKey;  // "solana:solana|Address123..."
  state.get(msg.payload.viewName).delete(tokenKey);
}

Token Filters (Structured Object)

if (msg.type === 'remove-token') {
  // Token Filters: token is { chainId, address }
  const { chainId, address } = msg.payload.token;
  const key = `${chainId}|${address}`;
  state.get(msg.payload.viewName).delete(key);
}

Step 6: Remove Sync Message Handling

Pulse V2

ws.on('message', (data) => {
  const msg = JSON.parse(data);
  
  switch (msg.type) {
    case 'init':
      // Handle init
      break;
    case 'sync':
      // V2: Handle periodic sync
      for (const [viewName, viewData] of Object.entries(msg.payload)) {
        refreshViewState(viewName, viewData.data);
      }
      break;
    case 'update-token':
      // Handle update
      break;
  }
});

Token Filters

ws.on('message', (data) => {
  const msg = JSON.parse(data);
  
  switch (msg.type) {
    // No 'init' via WebSocket - handled by REST API
    // No 'sync' messages - views updated in real-time
    
    case 'new-token':
      handleNewToken(msg.payload);
      break;
    case 'update-token':
      handleUpdateToken(msg.payload);  // Partial update
      break;
    case 'remove-token':
      handleRemoveToken(msg.payload);  // { chainId, address }
      break;
  }
});

Step 7: Update Preset Models

Pulse V2

{
  "type": "pulse-v2",
  "payload": {
    "model": "default",
    "assetMode": true,
    "chainId": ["solana:solana"]
  }
}

Token Filters

{
  "mode": "token",
  "views": {
    "new": {
      "model": "new",
      "filters": {
        "chainId": { "in": ["solana:solana"] }
      }
    }
  }
}

Model Comparison

Pulse V2 Default ModelToken Filters Equivalent
new viewmodel: "new"
bonding viewmodel: "bonding"
bonded viewmodel: "bonded"

New Token Filters Models

ModelSort ByDescription
trendingfeesPaid15minUSD descFee-weighted trending tokens
explorertrendingScore1h descExplorer view
topGainerspriceChange6hPercentage descTop price gainers
surgesurgeScore descSmall-cap surge tokens

New Capabilities

Token Filters keeps the Pulse-style view/filter workflow, and adds a few alpha-only controls:
CapabilityWhereDescription
deltaWebSocket payloadDefaults to true; updates contain only changed fields plus identifiers
maxUpdatesPerMinuteWebSocket payloadPer-entity throttle for noisy views
selectorsView definitionPin explicit { chainId, address } tokens or markets
ohlcv / ohlcvTimeframeView definitionAdd historical candles on init and receive update-token-ohlcv messages
filterQuotesfiltersExclude known quote/base tokens in token mode
createdAtOffset, latestTradeDateOffset, bondedAtOffset, migratedAtOffset, athDateOffset, atlDateOffsetfiltersFilter by age in seconds relative to now

Complete Migration Example

Pulse V2 Code (Before)

import WebSocket from 'ws';

class PulseV2Client {
  private ws: WebSocket;
  private state = new Map();

  async connect(apiKey: string) {
    this.ws = new WebSocket('wss://api.mobula.io');

    this.ws.on('open', () => {
      this.ws.send(JSON.stringify({
        type: 'pulse-v2',
        authorization: apiKey,
        payload: {
          assetMode: true,
          views: [
            {
              name: 'trending',
              chainId: ['solana:solana'],
              poolTypes: ['pumpfun'],
              sortBy: 'volume_1h',
              sortOrder: 'desc',
              limit: 50,
              filters: {
                volume_1h: { gte: 1000 },
                market_cap: { gte: 10000 },
                top_10_holdings_percentage: { lte: 30 }
              }
            }
          ]
        }
      }));
    });

    this.ws.on('message', (data) => {
      const msg = JSON.parse(data.toString());

      switch (msg.type) {
        case 'init':
          for (const [view, data] of Object.entries(msg.payload)) {
            this.state.set(view, new Map(
              data.data.map(t => [`${t.token.chainId}|${t.token.address}`, t])
            ));
          }
          break;

        case 'sync':
          // Handle periodic sync
          break;

        case 'update-token':
          const token = msg.payload.token;
          const key = `${token.token.chainId}|${token.token.address}`;
          this.state.get(msg.payload.viewName).set(key, token);
          break;

        case 'remove-token':
          this.state.get(msg.payload.viewName).delete(msg.payload.tokenKey);
          break;
      }
    });
  }
}

Token Filters Code (After)

import WebSocket from 'ws';

class TokenFiltersClient {
  private ws: WebSocket | null = null;
  private state = new Map<string, Map<string, unknown>>();
  private viewsConfig = {
    trending: {
      sortBy: 'volume1hUSD',
      sortOrder: 'desc' as const,
      limit: 50,
      filters: {
        chainId: { in: ['solana:solana'] },
        source: { in: ['pumpfun'] },
        volume1hUSD: { gte: 1000 },
        marketCapUSD: { gte: 10000 },
        top10HoldingsPercentage: { lte: 30 }
      }
    }
  };

  async connect(apiKey: string) {
    // Initialize state maps for each view
    for (const viewName of Object.keys(this.viewsConfig)) {
      this.state.set(viewName, new Map());
    }

    // Connect WebSocket directly with FULL payload
    this.ws = new WebSocket('wss://api.mobula.io');

    this.ws.on('open', () => {
      // IMPORTANT: Send complete payload, NOT just subscriptionId
      this.ws!.send(JSON.stringify({
        type: 'token-filters',  // Changed from pulse-v2
        authorization: apiKey,
        payload: {
          mode: 'token',  // Changed from assetMode: true
          delta: true,
          views: this.viewsConfig
        }
      }));
    });

    this.ws.on('message', (data) => {
      const msg = JSON.parse(data.toString());

      switch (msg.type) {
        // No 'init' or 'sync' cases needed

        case 'new-token':
          const newToken = msg.payload.token;
          const newKey = `${newToken.chainId}|${newToken.address}`;
          this.state.get(msg.payload.viewName)?.set(newKey, newToken);
          break;

        case 'update-token':
          // Partial update - merge with existing
          const partial = msg.payload.token;
          const updateKey = `${partial.chainId}|${partial.address}`;
          const viewState = this.state.get(msg.payload.viewName);
          if (viewState?.has(updateKey)) {
            const existing = viewState.get(updateKey);
            viewState.set(updateKey, { ...existing, ...partial });
          }
          break;

        case 'remove-token':
          // Structured object instead of pipe-separated string
          const { chainId, address } = msg.payload.token;
          const removeKey = `${chainId}|${address}`;
          this.state.get(msg.payload.viewName)?.delete(removeKey);
          break;
      }
    });

    // Keepalive
    setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ event: 'ping' }));
      }
    }, 30000);
  }
}

Checklist

Use this checklist to ensure complete migration:
  • Update REST endpoint from /api/2/pulse to /api/2/token/filters
  • Update connection flow to REST API init + WebSocket subscribe
  • Change type from pulse-v2 to token-filters
  • Change assetMode: true/false to mode: "token"/"market"
  • Update all filter field names to camelCase
  • Update all sortBy field names to camelCase
  • Implement partial update handling (merge, don’t replace)
  • Update remove message handling for structured objects
  • Remove sync message handling (no longer sent)
  • Remove init message handling (handled by REST)
  • Test with real data

Need Help?

Support

Telegram

Support

Slack

Need help?

Email