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:
| 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)
// 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
| 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 |
| 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)
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 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)
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:
Need Help?