Skip to main content
Mobula’s Pulse V2 WebSocket stream delivers real-time token data — new pairs, bonding curve progress, and migrations — directly to your application. This guide shows you how to integrate the Pulse WSS using the @mobula/sdk, with practical patterns taken from the open-source MTT (Mobula Trader Terminal) codebase.
Live Demo: See Pulse in action at mtt.ggSource Code: Full implementation at github.com/MobulaFi/MTT

Prerequisites

  • Node.js 18+ or Bun
  • A Mobula API key (Growth or Enterprise plan) — get one at admin.mobula.io
  • @mobula/sdk installed in your project
npm install @mobula/sdk
# or
bun add @mobula/sdk

Quick Start

Connect to Pulse V2 and start receiving real-time token data in under 10 lines:
import { MobulaClient } from '@mobula/sdk';

const client = new MobulaClient({
  restUrl: 'https://pulse-v2-api.mobula.io',
  apiKey: 'YOUR_API_KEY',
});

// Subscribe to the pulse-v2 stream
const subscriptionId = client.streams.subscribe(
  'pulse-v2',
  {
    assetMode: true,
    views: [
      { name: 'new', model: 'new', chainId: ['solana:solana'], limit: 30 },
      { name: 'bonding', model: 'bonding', chainId: ['solana:solana'], limit: 30 },
      { name: 'bonded', model: 'bonded', chainId: ['solana:solana'], limit: 30 },
    ],
  },
  (data) => {
    console.log('Received:', data);
  }
);
That’s it — you’re now receiving live token updates.

How the SDK StreamClient Works

The MobulaClient exposes a streams property — an instance of StreamClient that manages WebSocket connections. Key behaviors:
FeatureDetail
Auto-reconnectExponential backoff up to 30 seconds
HeartbeatPings every 30s to keep connections alive
DeduplicationSubscriptions are hashed to prevent duplicates
CompressionPer-message deflate supported
QueueMessages queued until the WebSocket is ready

Core Methods

// Subscribe — returns a subscription ID
const id = client.streams.subscribe('pulse-v2', payload, callback);

// Unsubscribe by ID
await client.streams.unsubscribe('pulse-v2', id);

// Close the WebSocket connection entirely
client.streams.close('pulse-v2');

Understanding the Payload

The subscription payload controls what data you receive. Here’s the full shape:
interface PulsePayload {
  assetMode?: boolean;       // true = token mode, false = pool mode
  compressed?: boolean;      // gzip-compressed responses
  views: Array<{
    name: string;            // Your label for this view
    model: 'new' | 'bonding' | 'bonded';  // Token lifecycle stage
    chainId?: string[];      // e.g. ['solana:solana', 'evm:56']
    poolTypes?: string[];    // Filter by pool type
    sortBy?: string;         // Sort field
    sortOrder?: 'asc' | 'desc';
    limit?: number;          // Max tokens per view (max 100, default 30)
    offset?: number;         // Pagination offset
    filters?: Record<string, unknown>;  // Advanced filters (see below)
    min_socials?: number;    // 1-3, require social links
  }>;
}

View Models

ModelDescription
newFreshly created tokens still in their bonding phase
bondingTokens nearing the end of their bonding curve (final stretch)
bondedTokens that have migrated to a DEX (graduated)

Message Types

The stream sends three message types:

init — Initial Data Snapshot

Sent immediately after subscribing. Contains the current state for all views:
{
  type: 'init',
  payload: {
    new: { data: PulseToken[] },
    bonding: { data: PulseToken[] },
    bonded: { data: PulseToken[] },
  }
}

update-token — Token Data Changed

Sent when a token’s metrics update (price, holders, market cap, etc.):
{
  type: 'update-token',
  payload: {
    viewName: 'new' | 'bonding' | 'bonded',
    token: PulseToken,
  }
}

new-token — New Token Appeared

Sent when a token enters a view for the first time:
{
  type: 'new-token',
  payload: {
    viewName: 'new' | 'bonding' | 'bonded',
    token: PulseToken,
  }
}

REST + WebSocket Pattern

MTT uses a REST-first, WebSocket-second pattern for the best user experience: load data instantly via REST, then switch to WebSocket for live updates. Here’s a simplified version of how MTT does it (apps/mtt/src/features/pulse/hooks/usePulseV2.ts):
import { MobulaClient } from '@mobula/sdk';

const client = new MobulaClient({
  restUrl: 'https://pulse-v2-api.mobula.io',
  apiKey: 'YOUR_API_KEY',
});

// 1. Load initial data via REST (fast first paint)
const initialData = await client.fetchPulseV2({
  assetMode: true,
  compressed: false,
  views: [
    { name: 'new', model: 'new', chainId: ['solana:solana'], limit: 50 },
    { name: 'bonding', model: 'bonding', chainId: ['solana:solana'], limit: 50 },
    { name: 'bonded', model: 'bonded', chainId: ['solana:solana'], limit: 50 },
  ],
});

// Render initialData immediately...

// 2. Then subscribe to WebSocket for live updates
const subscriptionId = client.streams.subscribe(
  'pulse-v2',
  {
    assetMode: true,
    views: [
      { name: 'new', model: 'new', chainId: ['solana:solana'], limit: 50 },
      { name: 'bonding', model: 'bonding', chainId: ['solana:solana'], limit: 50 },
      { name: 'bonded', model: 'bonded', chainId: ['solana:solana'], limit: 50 },
    ],
  },
  (msg) => {
    if (msg.type === 'init') {
      // Replace all view data
    }
    if (msg.type === 'update-token' || msg.type === 'new-token') {
      // Merge single token into the right view
    }
  }
);
The REST and WebSocket APIs accept the same payload shape, so you can reuse your view configuration for both.

How MTT Implements Pulse WSS

MTT is Mobula’s open-source trading terminal. Its Pulse implementation is a production reference you can study and adapt. Here are the key architectural pieces:

Architecture

┌──────────────────────────────────────────────────────┐
│                 PulseStreamProvider                   │
│  React Context — shares stream state with all children│
├──────────────────────────────────────────────────────┤
│                   usePulseV2 Hook                     │
│  REST initial load → WebSocket subscription           │
│  Message batching → Pause/Resume → Filter resubscribe │
├─────────────────────┬────────────────────────────────┤
│  usePulseDataStore  │     usePulseFilterStore         │
│  Zustand store:     │     Zustand + localStorage:     │
│  tokens per view    │     chain, protocol, metric     │
│  merge / sort / cap │     and audit filters           │
├─────────────────────┴────────────────────────────────┤
│                    UI Components                      │
│  TokenSection → TokenCard → FilterModal               │
└──────────────────────────────────────────────────────┘

Key Files in the MTT Codebase

FilePurpose
src/lib/sdkClient.tsSDK wrapper with server/client mode routing
src/features/pulse/hooks/usePulseV2.tsCore hook — REST load, WSS subscribe, message handling
src/features/pulse/store/usePulseDataStore.tsToken state management per view
src/features/pulse/store/usePulseModalFilterStore.tsFilter state with localStorage persistence
src/features/pulse/context/PulseStreamContext.tsxReact context provider for stream state
src/features/pulse/components/FilterModal.tsxFilter UI that triggers resubscription
src/config/endpoints.tsWSS and REST URL configuration

SDK Client Wrapper

MTT wraps the SDK to support both server-side (SSR) and client-side modes. Here’s the stream subscription pattern from sdkClient.ts:
import { MobulaClient } from '@mobula/sdk';
import type { PulsePayloadParams } from '@mobula/types';

type StreamSubscription = { unsubscribe: () => void };

function subscribePulseV2(
  params: PulsePayloadParams,
  callback: (data: unknown) => void
): StreamSubscription {
  const client = getMobulaClient(); // your singleton
  const subscriptionId = client.streams.subscribe('pulse-v2', params, callback);

  return {
    unsubscribe: () => {
      client.streams.unsubscribe('pulse-v2', subscriptionId);
    },
  };
}

Update Batching for 60fps

MTT batches incoming WebSocket messages using requestAnimationFrame to avoid render storms:
class UpdateBatcher<T> {
  private updates: T[] = [];
  private scheduled = false;

  constructor(private callback: (updates: T[]) => void) {}

  add(update: T) {
    this.updates.push(update);
    if (!this.scheduled) {
      this.scheduled = true;
      requestAnimationFrame(() => {
        this.callback(this.updates);
        this.updates = [];
        this.scheduled = false;
      });
    }
  }
}

// Usage in the pulse hook
const batcher = new UpdateBatcher<{ view: string; token: PulseToken }>((updates) => {
  updates.forEach(({ view, token }) => {
    dataStore.mergeToken(view, token);
  });
});

// In the WebSocket callback:
client.streams.subscribe('pulse-v2', payload, (msg) => {
  if (msg.type === 'update-token' || msg.type === 'new-token') {
    batcher.add({ view: msg.payload.viewName, token: msg.payload.token });
  }
});
Without batching, high-frequency token updates can cause hundreds of React re-renders per second. The UpdateBatcher coalesces them into a single render per animation frame.

Pause and Resume

The SDK supports pausing specific views without disconnecting:
// Pause views (stop receiving updates for these views)
client.streams.subscribe('pulse-v2-pause', {
  action: 'pause',
  views: ['new', 'bonding'],
});

// Resume views
client.streams.subscribe('pulse-v2-pause', {
  action: 'unpause',
  views: ['new', 'bonding'],
});
MTT uses this when the user scrolls away from a section or when applying filters (to prevent flickering during resubscription):
// From usePulseV2.ts — pause during filter changes
const applyFilters = () => {
  setIsPaused(true);
  // Unsubscribe old → resubscribe with new filters
  // Resume after a short delay
  setTimeout(() => setIsPaused(false), 500);
};

Filtering

Pass filters inside each view to narrow down the token stream. Filters use { gte, lte } range syntax:
client.streams.subscribe('pulse-v2', {
  assetMode: true,
  views: [
    {
      name: 'new',
      model: 'new',
      chainId: ['solana:solana'],
      limit: 50,
      filters: {
        market_cap: { gte: 10000, lte: 1000000 },
        holders_count: { gte: 50 },
        volume_24h: { gte: 5000 },
        dev_holdings_percentage: { lte: 10 },
        top10_holders_percent: { lte: 50 },
      },
    },
  ],
});

Available Filter Fields

FilterTypeDescription
market_cap{ gte?, lte? }Market capitalization in USD
volume_24h{ gte?, lte? }24-hour trading volume
liquidity{ gte?, lte? }Available liquidity
holders_count{ gte?, lte? }Number of token holders
top10_holders_percent{ gte?, lte? }Top 10 holders concentration
dev_holdings_percentage{ gte?, lte? }Developer holdings %
snipers_holdings_percentage{ gte?, lte? }Sniper wallet holdings %
insiders_holdings_percentage{ gte?, lte? }Insider holdings %
bundlers_holdings_percentage{ gte?, lte? }Bundler holdings %
bonding_percentage{ gte?, lte? }Bonding curve progress %
pro_traders_count{ gte?, lte? }Number of pro traders
trades_24h{ gte?, lte? }24h transaction count
buys_24h{ gte?, lte? }24h buy count
sells_24h{ gte?, lte? }24h sell count
fees_paid_24h{ gte?, lte? }Fees paid in 24h
created_at_offset{ gte?, lte? }Token age in seconds
deployer_migrations_count{ gte?, lte? }Deployer’s migration count
dexscreener_ad_paid{ equals: true }Has DEX Screener ad
twitter{ not: null }Has Twitter link
website{ not: null }Has website
telegram{ not: null }Has Telegram link
min_socialsnumberMin social links (1-3)
includeKeywordsstring[]Token name must contain one of these
excludeKeywordsstring[]Token name must not contain these
For the complete filter reference with examples, see Filter Details.

Multi-Chain Support

Subscribe to multiple chains in a single view:
client.streams.subscribe('pulse-v2', {
  assetMode: true,
  views: [
    {
      name: 'new-multichain',
      model: 'new',
      chainId: ['solana:solana', 'evm:56', 'evm:8453', 'evm:1'],
      limit: 50,
    },
  ],
});
Or use separate views per chain for independent control:
client.streams.subscribe('pulse-v2', {
  assetMode: true,
  views: [
    { name: 'new-sol', model: 'new', chainId: ['solana:solana'], limit: 30 },
    { name: 'new-bsc', model: 'new', chainId: ['evm:56'], limit: 30 },
    { name: 'new-base', model: 'new', chainId: ['evm:8453'], limit: 30 },
  ],
});

Custom WSS URLs

The SDK lets you override WebSocket endpoints per stream type:
const client = new MobulaClient({
  restUrl: 'https://pulse-v2-api.mobula.io',
  apiKey: 'YOUR_API_KEY',
  wsUrlMap: {
    'pulse-v2': 'wss://pulse-v2-api.mobula.io',
    'pulse-v2-pause': 'wss://pulse-v2-api.mobula.io',
  },
});
MTT uses this to allow users to configure custom endpoints from the UI (stored in localStorage).

Unsubscribing and Cleanup

Always clean up subscriptions when your component unmounts or when you no longer need the data:
// Store the subscription ID
const subscriptionId = client.streams.subscribe('pulse-v2', payload, callback);

// Later — unsubscribe cleanly
await client.streams.unsubscribe('pulse-v2', subscriptionId);

// Or close the entire WebSocket connection for this stream type
client.streams.close('pulse-v2');
In React, handle this in a cleanup function:
useEffect(() => {
  const id = client.streams.subscribe('pulse-v2', payload, handleMessage);

  return () => {
    client.streams.unsubscribe('pulse-v2', id);
  };
}, [payload]);

Full React Example

Here’s a minimal but complete React hook for Pulse V2, inspired by MTT’s implementation:
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { MobulaClient } from '@mobula/sdk';

interface PulseToken {
  address?: string;
  chainId?: string;
  name?: string;
  symbol?: string;
  logo?: string;
  marketCap?: number;
  holders_count?: number;
  price_change_24h?: number;
  created_at?: string;
  [key: string]: unknown;
}

type ViewName = 'new' | 'bonding' | 'bonded';

export function usePulseStream(apiKey: string, chainIds: string[] = ['solana:solana']) {
  const clientRef = useRef<MobulaClient | null>(null);
  const subIdRef = useRef<string | null>(null);

  const [tokens, setTokens] = useState<Record<ViewName, PulseToken[]>>({
    new: [], bonding: [], bonded: [],
  });
  const [loading, setLoading] = useState(true);

  const getClient = useCallback(() => {
    if (!clientRef.current) {
      clientRef.current = new MobulaClient({
        restUrl: 'https://pulse-v2-api.mobula.io',
        apiKey,
      });
    }
    return clientRef.current;
  }, [apiKey]);

  const payload = useMemo(() => ({
    assetMode: true,
    compressed: false,
    views: [
      { name: 'new', model: 'new' as const, chainId: chainIds, limit: 50 },
      { name: 'bonding', model: 'bonding' as const, chainId: chainIds, limit: 50 },
      { name: 'bonded', model: 'bonded' as const, chainId: chainIds, limit: 50 },
    ],
  }), [chainIds]);

  useEffect(() => {
    const client = getClient();

    // 1. REST for fast initial load
    client.fetchPulseV2(payload).then((data) => {
      setTokens({
        new: Array.isArray(data.new?.data) ? data.new.data : [],
        bonding: Array.isArray(data.bonding?.data) ? data.bonding.data : [],
        bonded: Array.isArray(data.bonded?.data) ? data.bonded.data : [],
      });
      setLoading(false);
    });

    // 2. WebSocket for live updates
    subIdRef.current = client.streams.subscribe('pulse-v2', payload, (msg: any) => {
      if (msg.type === 'init') {
        setTokens({
          new: Array.isArray(msg.payload.new?.data) ? msg.payload.new.data : [],
          bonding: Array.isArray(msg.payload.bonding?.data) ? msg.payload.bonding.data : [],
          bonded: Array.isArray(msg.payload.bonded?.data) ? msg.payload.bonded.data : [],
        });
        setLoading(false);
      }

      if (msg.type === 'update-token' || msg.type === 'new-token') {
        const { viewName, token } = msg.payload;
        setTokens((prev) => {
          const view = prev[viewName as ViewName] || [];
          const idx = view.findIndex((t) => t.address === token.address);
          const updated = idx >= 0
            ? view.map((t, i) => (i === idx ? { ...t, ...token } : t))
            : [token, ...view].slice(0, 50);
          return { ...prev, [viewName]: updated };
        });
      }
    });

    return () => {
      if (subIdRef.current) {
        client.streams.unsubscribe('pulse-v2', subIdRef.current);
        subIdRef.current = null;
      }
    };
  }, [getClient, payload]);

  return { tokens, loading };
}

Next Steps