> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mobula.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate from Pulse V2 to Token Filters

> Step-by-step guide to migrate from Pulse Stream V2 to Token Filters

<Warning>
  **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.
</Warning>

<Note>
  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.
</Note>

## Migration Overview

Token Filters introduces several breaking changes that improve the developer experience:

| Aspect | Pulse V2 | Token Filters |
| - | - | - |
| Initialization | WebSocket message | WebSocket full payload, or optional REST init for initial data |
| Field naming | Mixed (filters: snake\_case, output: camelCase) | Consistent camelCase everywhere |
| Updates | Full object on every change | Partial updates (only changed fields) |
| Sync messages | Periodic sync messages | Real-time view updates, no sync |
| Delete format | Pipe-separated `chainId\|address` | Object `{ chainId, address }` |
| Mode parameter | `assetMode: true/false` | `mode: "token"` or `"market"` |
| REST endpoint | `/api/2/pulse` | `/api/2/token/filters` |
| WebSocket type | `pulse-v2` | `token-filters` |

## Step 1: Update Connection Flow

### Pulse V2 (Old Way)

```typescript theme={null}
// 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)

```typescript theme={null}
// 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'] }
          }
        }
      }
    }
  }));
});
```

<Warning>
  **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.
</Warning>

## Step 2: Update Mode Parameter

### Pulse V2

```json theme={null}
{
  "assetMode": true,
  ...
}
```

### Token Filters

```json theme={null}
{
  "mode": "token",
  ...
}
```

| Pulse V2 | Token Filters |
| - | - |
| `assetMode: true` | `mode: "token"` |
| `assetMode: false` | `mode: "market"` |

## Step 3: Update Filter Field Names

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

### Price & Market Data

| Pulse V2 | Token Filters |
| - | - |
| `market_cap` | `marketCapUSD` |
| `latest_price` | `priceUSD` |
| `latest_market_cap` | `marketCapUSD` |
| `liquidity` | `liquidityUSD` |
| `liquidity_max` | `liquidityMaxUSD` |

### Volume Fields

| Pulse V2 | Token Filters |
| - | - |
| `volume_1min` | `volume1minUSD` |
| `volume_5min` | `volume5minUSD` |
| `volume_15min` | `volume15minUSD` |
| `volume_1h` | `volume1hUSD` |
| `volume_4h` | `volume4hUSD` |
| `volume_6h` | `volume6hUSD` |
| `volume_12h` | `volume12hUSD` |
| `volume_24h` | `volume24hUSD` |
| `volume_buy_1h` | `volumeBuy1hUSD` |
| `volume_sell_1h` | `volumeSell1hUSD` |

### Price Change Fields

| Pulse V2 | Token Filters |
| - | - |
| `price_change_1min` | `priceChange1minPercentage` |
| `price_change_5min` | `priceChange5minPercentage` |
| `price_change_1h` | `priceChange1hPercentage` |
| `price_change_4h` | `priceChange4hPercentage` |
| `price_change_6h` | `priceChange6hPercentage` |
| `price_change_12h` | `priceChange12hPercentage` |
| `price_change_24h` | `priceChange24hPercentage` |

### Trade & Participant Fields

| Pulse V2 | Token Filters |
| - | - |
| `trades_1h` | `trades1h` |
| `buys_1h` | `buys1h` |
| `sells_1h` | `sells1h` |
| `buyers_1h` | `buyers1h` |
| `sellers_1h` | `sellers1h` |
| `traders_1h` | `traders1h` |
| `fees_paid_1h` | `feesPaid1hUSD` |

### Holdings Fields

| Pulse V2 | Token Filters |
| - | - |
| `holders_count` | `holdersCount` |
| `top_10_holdings_percentage` | `top10HoldingsPercentage` |
| `top_50_holdings_percentage` | `top50HoldingsPercentage` |
| `top_100_holdings_percentage` | `top100HoldingsPercentage` |
| `top_200_holdings_percentage` | `top200HoldingsPercentage` |
| `dev_holdings_percentage` | `devHoldingsPercentage` |
| `insiders_holdings_percentage` | `insidersHoldingsPercentage` |
| `bundlers_holdings_percentage` | `bundlersHoldingsPercentage` |
| `snipers_holdings_percentage` | `snipersHoldingsPercentage` |
| `pro_traders_holdings_percentage` | `proTradersHoldingsPercentage` |
| `fresh_traders_holdings_percentage` | `freshTradersHoldingsPercentage` |
| `smart_traders_holdings_percentage` | `smartTradersHoldingsPercentage` |

### Trader Category Fields

| Pulse V2 | Token Filters |
| - | - |
| `insiders_count` | `insidersCount` |
| `bundlers_count` | `bundlersCount` |
| `snipers_count` | `snipersCount` |
| `fresh_traders_count` | `freshTradersCount` |
| `pro_traders_count` | `proTradersCount` |
| `smart_traders_count` | `smartTradersCount` |
| `fresh_traders_buys` | `freshTradersBuys` |
| `pro_traders_buys` | `proTradersBuys` |
| `smart_traders_buys` | `smartTradersBuys` |

### Organic Fields

| Pulse V2 | Token Filters |
| - | - |
| `organic_trades_1h` | `organicTrades1h` |
| `organic_volume_1h` | `organicVolume1hUSD` |
| `organic_buys_1h` | `organicBuys1h` |
| `organic_sells_1h` | `organicSells1h` |
| `organic_buyers_1h` | `organicBuyers1h` |
| `organic_sellers_1h` | `organicSellers1h` |
| `organic_traders_1h` | `organicTraders1h` |
| `organic_volume_buy_1h` | `organicVolumeBuy1hUSD` |
| `organic_volume_sell_1h` | `organicVolumeSell1hUSD` |

### Bonding & Timestamp Fields

| Pulse V2 | Token Filters |
| - | - |
| `bonding_percentage` | `bondingPercentage` |
| `created_at` | `createdAt` |
| `latest_trade_date` | `latestTradeDate` |
| `bonded_at` | `bondedAt` |
| `migrated_at` | `migratedAt` |

### Metadata Fields

| Pulse V2 | Token Filters |
| - | - |
| `token_symbol` | `symbol` |
| `token_name` | `name` |
| `dexscreener_listed` | `dexscreenerListed` |
| `dexscreener_header` | `dexscreenerHeader` |
| `dexscreener_ad_paid` | `dexscreenerAdPaid` |
| `dexscreener_boosted` | `dexscreenerBoosted` |
| `twitter_reuses_count` | `twitterReusesCount` |
| `twitter_rename_count` | `twitterRenameCount` |
| `deployer_migrations_count` | `deployerMigrationsCount` |
| `deployer_tokens_count` | `deployerTokensCount` |
| `live_status` | `liveStatus` |
| `live_thumbnail` | `liveThumbnail` |
| `livestream_title` | `livestreamTitle` |
| `live_reply_count` | `liveReplyCount` |

### Special Filters

| Pulse V2 | Token Filters |
| - | - |
| `created_at_offset` | `createdAtOffset` |
| `min_socials` | `minSocials` |
| `includeKeywords` | `includeKeywords` (unchanged) |
| `excludeKeywords` | `excludeKeywords` (unchanged) |
| `addressToExclude` | `addressToExclude` (unchanged) |

## Step 4: Handle Partial Updates

### Pulse V2 (Full Updates)

```typescript theme={null}
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)

```typescript theme={null}
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 });
    }
  }
});
```

<Warning>
  **Important**: Token Filters partial updates only include fields that changed. You MUST merge them with your existing state, not replace the entire object.
</Warning>

## Step 5: Handle Remove Messages

### Pulse V2 (Pipe-separated String)

```typescript theme={null}
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)

```typescript theme={null}
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

```typescript theme={null}
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

```typescript theme={null}
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

```json theme={null}
{
  "type": "pulse-v2",
  "payload": {
    "model": "default",
    "assetMode": true,
    "chainId": ["solana:solana"]
  }
}
```

### Token Filters

```json theme={null}
{
  "mode": "token",
  "views": {
    "new": {
      "model": "new",
      "filters": {
        "chainId": { "in": ["solana:solana"] }
      }
    }
  }
}
```

### Model Comparison

| Pulse V2 Default Model | Token Filters Equivalent |
| - | - |
| `new` view | `model: "new"` |
| `bonding` view | `model: "bonding"` |
| `bonded` view | `model: "bonded"` |

### New Token Filters Models

| Model | Sort By | Description |
| - | - | - |
| `trending` | `feesPaid15minUSD` desc | Fee-weighted trending tokens |
| `explorer` | internal trending rank desc | Explorer view |
| `topGainers` | `priceChange6hPercentage` desc | Top price gainers |
| `surge` | `surgeScore` desc | Small-cap surge tokens |

## New Capabilities

Token Filters keeps the Pulse-style view/filter workflow, and adds a few alpha-only controls:

| Capability | Where | Description |
| - | - | - |
| `delta` | WebSocket payload | Defaults to `true`; updates contain only changed fields plus identifiers |
| `maxUpdatesPerMinute` | WebSocket payload | Per-entity throttle for noisy views |
| `selectors` | View definition | Pin explicit `{ chainId, address }` tokens or markets |
| `ohlcv` / `ohlcvTimeframe` | View definition | Add historical candles on init and receive `update-token-ohlcv` messages |
| `filterQuotes` | `filters` | Exclude known quote/base tokens in token mode |
| `createdAtOffset`, `latestTradeDateOffset`, `bondedAtOffset`, `migratedAtOffset`, `athDateOffset`, `atlDateOffset` | `filters` | Filter by age in seconds relative to now |

## Complete Migration Example

### Pulse V2 Code (Before)

```typescript theme={null}
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)

```typescript theme={null}
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?

<CardGroup>
  <Card title="Support" icon="Telegram" href="https://t.me/mobuladevelopers?start=Mobula_API_Support_Key">
    Telegram
  </Card>

  <Card title="Support" icon="Slack" href="https://join.slack.com/t/mobulaapi/shared_invite/zt-29zrrpjnl-I0tyD73sy7zKy8q~KLL3Ug">
    Slack
  </Card>

  <Card title="Need help?" icon="envelope" href="mailto:contact@mobulalabs.org">
    Email
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.