> ## 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` |

### Trending Score Fields

| Pulse V2              | Token Filters       |
| --------------------- | ------------------- |
| `trending_score_1min` | `trendingScore1min` |
| `trending_score_5min` | `trendingScore5min` |
| `trending_score_1h`   | `trendingScore1h`   |
| (etc.)                | (etc.)              |

### 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`   | `trendingScore1h` 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>
