All Resources
Solana Liquidity Events API: LP History and Live Stream
Data APITypeScript13 min readSolana Tracker

Solana Liquidity Events API: LP History and Live Stream

Solana liquidity events API guide: backfill LP adds and removes over REST, stream them over WebSocket, and reconcile provisional events in TypeScript.

  • data-api,
  • liquidity,
  • datastream,
  • websocket,
  • typescript

The Solana liquidity events API in Solana Tracker's Data API returns liquidity additions and removals for a token, pool, or wallet: confirmed history over REST and live events over the Datastream WebSocket. Request history with events=liquidity (or events=all to interleave swaps), then subscribe to a liquidity:* room for new LP actions. Live events are provisional at processed commitment, so treat REST as the record and reconcile against it.

Below: both halves, the event shape, exact amounts, and the reconciliation loop, using @solana-tracker/data-api 0.5.0 and the Solana Data API.

When to use LP history and live LP events

A swaps-only tape hides half of what happens to a pool. If your product shows pool depth, warns about liquidity pulls, or attributes LP behavior to known wallets, you need LP actions as first-class rows rather than inferring them from reserve changes.

NeedUseWhy
Backfill the last N LP actions for a token or poolREST history, events: 'liquidity'Confirmed data, cursor pagination, 1-500 rows per page
One chronological feed of swaps and LP actionsREST history, events: 'all'Same trades array, discriminate on type
React to new adds and removes as they happenDatastream subscribe.liquidity.*Push delivery at processed commitment
Raw instruction-level data for every program you care aboutYellowstone gRPCYou parse the transactions yourself
Current reserves or pool state, not the actions that changed themToken and pool endpoints, or pool update roomsSnapshots instead of events

The Data API is the right default when you want parsed, labeled LP actions without maintaining decoders. Supported families include Raydium AMM v4, CPMM and CLMM, Orca Whirlpool, Pump AMM, and Meteora DAMM v1/v2 and DLMM; coverage depends on the instruction and available on-chain data.

Query the Solana liquidity events API over REST

The four token-scoped trade history routes accept an events parameter. In SDK 0.5.0 these map to four opt-in methods that take an options object:

SDK methodREST routeScope
getTokenTradeHistory(mint, options)GET /trades/{token}All pools of a token
getPoolTradeHistory(mint, pool, options)GET /trades/{token}/{pool}One pool
getUserTokenTradeHistory(mint, wallet, options)GET /trades/{token}/by-wallet/{wallet}One wallet, one token
getUserPoolTradeHistory(mint, pool, wallet, options)GET /trades/{token}/{pool}/{wallet}One wallet, one pool

The options are the same on all four:

OptionValuesNotes
events'trades' (default), 'liquidity', 'all'LP modes are opt-in; omitting it returns swaps only
limit1-500, default 250The SDK throws ValidationError outside this range
sortDirection'DESC' (default), 'ASC'Keep it fixed while paginating
cursorvalue of nextCursorOpaque string for liquidity and all; pass back unchanged
enrich'identity'Adds current wallet labels; unknown wallets get identity: null
showMeta, hideArbbooleanAffect swap rows only; LP rows are kept

Every mode returns its rows in an array named trades, even when it holds only LP actions. TypeScript narrows the row type from the events literal: 'liquidity' gives LiquidityEvent, 'all' gives a union you switch on with event.type.

Pagination has two traps. nextCursor for liquidity and mixed feeds is an opaque string that preserves several actions at the same timestamp: never replace it with the last row's time, and never reuse it after changing scope, events, or sortDirection. And check for null explicitly, because a truthiness check also discards a numeric 0.

Response shape: what a liquidity event contains

This is the LiquidityEvent type from the SDK (field comments added). The same object arrives inside REST trades and as each live callback argument.

interface LiquidityEvent {
  tx: string;                 // transaction signature
  type: 'add_liquidity' | 'remove_liquidity';
  program: string;            // e.g. "pumpfun-amm"
  programId: string;
  instruction: string;        // e.g. "deposit"
  pool: string;
  wallet: string;             // owner or authority; can be a delegate or PDA
  time: number;               // Unix milliseconds
  slot: number;
  amountBasis: 'transfer' | 'principal';
  tokens: Array<{
    address: string;
    amount: string;           // exact decimal string
    amountRaw: string;        // integer string in base units
    decimals: number;
    feeAmountRaw?: string;
    transferredAmountRaw?: string;
  }>;
  identity?: PnlV2Identity | null; // only with enrich / :enriched
}

Things that matter when you render or store it:

  • Amounts are strings on purpose. amountRaw can exceed Number.MAX_SAFE_INTEGER. Sum with BigInt and format with the token's decimals; store as text or NUMERIC.
  • amountBasis tells you what was measured. transfer is gross token movement across the pool vault, before Token-2022 withholding. principal separates the position principal from fees or internal reallocations; Raydium CLMM removals can also carry feeAmountRaw and transferredAmountRaw per token.
  • One-sided actions have a single tokens entry. Zero amounts are omitted. A rebalance can appear as a separate removal and addition.
  • No price, volume, or PnL. LP rows are position actions in token units. They do not count toward swap volume, candles, or swap PnL. If you show a USD estimate, label it as derived and name the price source.

Stream live liquidity events over Datastream

Datastream exposes five liquidity scopes. Pick the narrowest one that answers your question, because overlapping rooms each deliver the same action.

SDK callRoomDelivers
subscribe.liquidity.token(mint)liquidity:{mint}Every LP action involving the token
subscribe.liquidity.tokenPool(mint, pool)liquidity:{mint}:{pool}The token's actions in one pool
subscribe.liquidity.tokenPoolWallet(mint, pool, wallet)liquidity:{mint}:{pool}:{wallet}One provider in one pool
subscribe.liquidity.pool(pool)liquidity:pool:{pool}All actions in a pool, across its tokens
subscribe.liquidity.wallet(wallet)liquidity:wallet:{wallet}One provider across pools and tokens

Pass { enriched: true } as the last argument to join the :enriched variant of any room. Enriched notifications include identity, can arrive later or out of order, and may carry identityStatus: 'partial' when the lookup was incomplete; no correction follows. Identity is the current label, not a snapshot from the event's time. For more on labels, see Solana wallet identity enrichment.

Liquidity rooms carry only LP actions, and existing transaction:* rooms stay swaps-only. A combined live feed needs both subscriptions; the Solana trades WebSocket guide covers the swap side. Datastream requires a Premium plan or higher; copy the key or full wss:// URL from the Data API dashboard.

REST vs WebSocket: commitment and reconciliation

The two halves do not have the same guarantees, and the difference decides how you build the feed.

PropertyREST historyDatastream liquidity rooms
CommitmentConfirmed/finalizedProcessed (provisional)
timeCanonical block timeObservation time; can differ from REST
ForksNot affectedAn event can vanish; no rollback message is sent
DuplicatesNone within one scopeEach overlapping room delivers its own copy
GapsCursor paginationAnything during a disconnect is missed
SDK dedupen/aNone for liquidity or :enriched rooms

Three rules follow:

  1. Never dedupe LP activity by signature. One transaction can contain several distinct additions or removals with the same signature, pool, and wallet. The SDK deliberately skips its signature dedupe for liquidity:* rooms for this reason. If you need to match events, use a composite key and count occurrences rather than collapsing them.
  2. REST is the record; live rows are an overlay. Show live events immediately, mark them provisional, and replace them with confirmed rows when the REST window catches up.
  3. Reconcile after every reconnect. The SDK rejoins rooms automatically, but events emitted while you were offline are gone. Re-read the affected window from REST.

The code below implements this with a multiset: a key from tx, type, pool, wallet, and each token's address:amountRaw (no time, since live and REST timestamps differ), counted from the confirmed REST window and consumed once per live event. Live events still unmatched after a TTL are dropped from view.

Complete TypeScript example

Install and run:

npm init -y && npm pkg set type=module
npm install @solana-tracker/data-api@^0.5.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts

.env.example:

# Data API key (required)
ST_API_KEY=
# Datastream key or full wss:// URL (optional; without it only the backfill runs)
ST_DATASTREAM_KEY=
# Token to follow
TOKEN_MINT=6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN
# Pages of history to backfill (1-20)
HISTORY_PAGES=3

index.ts:

import { Client, Datastream, DataApiError, RateLimitError } from '@solana-tracker/data-api';
import type { LiquidityEvent } from '@solana-tracker/data-api';

const apiKey = process.env.ST_API_KEY?.trim();
if (!apiKey) { console.error('Missing ST_API_KEY'); process.exit(1); }
const mint = process.env.TOKEN_MINT?.trim() || '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN';
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(mint)) { console.error('TOKEN_MINT is not a valid address'); process.exit(1); }
const pages = Number(process.env.HISTORY_PAGES ?? 3);
if (!Number.isInteger(pages) || pages < 1 || pages > 20) { console.error('HISTORY_PAGES must be 1-20'); process.exit(1); }

const client = new Client({ apiKey });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// The REST client has no timeout and no retries: add both.
async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
  for (let i = 1; ; i++) {
    let timer: NodeJS.Timeout | undefined;
    try {
      const timeout = new Promise<never>((_, rej) => { timer = setTimeout(() => rej(new Error('timeout')), 15_000); });
      return await Promise.race([fn(), timeout]);
    } catch (err) {
      const retryable = err instanceof RateLimitError || !(err instanceof DataApiError) || (err.status ?? 500) >= 500;
      if (i >= attempts || !retryable) throw err;
      const wait = err instanceof RateLimitError && err.retryAfter ? err.retryAfter * 1000 : 500 * 2 ** i;
      await sleep(wait);
    } finally {
      clearTimeout(timer);
    }
  }
}

type Cursor = string | number | undefined;
const page = (cursor: Cursor, limit = 100) =>
  withRetry(() => client.getTokenTradeHistory(mint, { events: 'liquidity', sortDirection: 'DESC', limit, cursor }));

// Composite key, counted not collapsed: several LP actions can share one signature.
const keyOf = (e: LiquidityEvent) =>
  [e.tx, e.type, e.pool, e.wallet, e.tokens.map((t) => `${t.address}:${t.amountRaw}`).sort().join(',')].join('|');
const fmt = (e: LiquidityEvent) =>
  `${e.type === 'add_liquidity' ? 'ADD   ' : 'REMOVE'} ${e.program} pool ${e.pool.slice(0, 4)} ` +
  e.tokens.map((t) => `${t.amount} ${t.address.slice(0, 4)}`).join(' + ');

async function main() {
  // 1. Backfill confirmed history.
  let cursor: Cursor;
  let total = 0;
  for (let p = 0; p < pages; p++) {
    const res = await page(cursor);
    for (const e of res.trades) console.log(new Date(e.time).toISOString(), fmt(e));
    total += res.trades.length;
    if (!res.hasNextPage || res.nextCursor == null) break;
    cursor = res.nextCursor;
  }
  console.log(`Backfill: ${total} liquidity events`);

  // 2. Stream, if a Datastream key is configured.
  const dsKey = process.env.ST_DATASTREAM_KEY?.trim();
  if (!dsKey) { console.log('Note: ST_DATASTREAM_KEY not set; skipping the live stream.'); return; }
  const ds = new Datastream({
    wsUrl: dsKey.startsWith('wss://') ? dsKey : `wss://datastream.solanatracker.io/${dsKey}`,
    autoReconnect: true, reconnectDelay: 1_000, reconnectDelayMax: 30_000, randomizationFactor: 0.5,
  });
  ds.on('error', () => console.error('Datastream connection error')); // never log the URL

  const pending: { key: string; seenAt: number; time: number }[] = [];
  const sub = ds.subscribe.liquidity.token(mint);
  const listener = sub.on((e) => {
    pending.push({ key: keyOf(e), seenAt: Date.now(), time: e.time });
    console.log('LIVE', fmt(e), '[provisional]');
  });

  async function reconcile(reason: string) {
    if (!pending.length) return;
    try {
      const oldest = Math.min(...pending.map((p) => p.time)) - 120_000;
      const counts = new Map<string, number>();
      let c: Cursor;
      for (let p = 0; p < 5; p++) {
        const res = await page(c, 200);
        for (const row of res.trades) counts.set(keyOf(row), (counts.get(keyOf(row)) ?? 0) + 1);
        const last = res.trades.at(-1);
        if (!last || last.time < oldest || !res.hasNextPage || res.nextCursor == null) break;
        c = res.nextCursor;
      }
      let confirmed = 0;
      for (let i = pending.length - 1; i >= 0; i--) {
        const item = pending[i]!;
        const n = counts.get(item.key) ?? 0;
        if (n > 0) { counts.set(item.key, n - 1); pending.splice(i, 1); confirmed++; }
        else if (Date.now() - item.seenAt > 5 * 60_000) pending.splice(i, 1); // likely dropped on a fork
      }
      console.log(`[reconcile:${reason}] confirmed ${confirmed}, provisional ${pending.length}`);
    } catch (err) {
      console.warn(`[reconcile:${reason}] skipped:`, err instanceof Error ? err.message : err);
    }
  }

  let connections = 0;
  ds.on('connected', () => { if (++connections > 1) void reconcile('reconnect'); });
  const timer = setInterval(() => void reconcile('interval'), 60_000);

  process.once('SIGINT', () => {
    clearInterval(timer);
    listener.unsubscribe();
    ds.unsubscribe(sub.room);
    ds.disconnect();
    process.exit(0);
  });
}

main().catch((err) => {
  const msg = err instanceof DataApiError ? `HTTP ${err.status ?? '?'}: ${err.message}` : String(err);
  console.error(`Error: ${msg}`);
  process.exitCode = 1;
});

Subscribing starts the connection, so no explicit connect() call is needed. The SDK answers heartbeats, reconnects with capped exponential backoff plus jitter, and rejoins liquidity:{mint} on its own; the connected handler only exists to trigger reconciliation.

Production pitfalls

  • Floating-point totals. Number("9000000001234567891") silently rounds. Use BigInt(amountRaw).
  • Signature dedupe copied from swap code. It merges distinct LP actions. Use the counted composite key above, or replace the whole REST window.
  • Overlapping rooms. Subscribing to both liquidity:{mint} and liquidity:pool:{pool} delivers shared actions twice. A two-token action also appears in both tokens' rooms and histories.
  • Treating removals as rugs. A removal can be routine position management or a rebalance half. Weigh it against pool depth and holder context; the Solana rug check API guide covers the risk side.
  • Credential leaks. The Datastream URL contains your key. Keep it server-side and never log it.
  • Assuming 0.4 behavior. These methods and rooms exist from SDK 0.5.0. If you are upgrading, read the Solana Tracker Data API SDK 0.5 migration notes.

FAQ

Do existing trade endpoints and rooms now include LP events?

No. events defaults to trades, the legacy getTokenTrades family keeps its swaps-only behavior, and transaction:* rooms stay swaps-only. LP data is strictly opt-in.

How far back does liquidity history go?

Page with the cursor until hasNextPage is false. The docs do not publish a fixed retention window for LP history, so test the depth you need for your tokens rather than assuming one.

Can I combine swaps and liquidity in one REST call?

Yes. Use events: 'all' and switch on event.type: buy and sell rows carry swap fields like priceUsd, while add_liquidity and remove_liquidity rows carry pool and tokens.

Why does a live event's timestamp differ from the same event in REST?

Live events are stamped when observed at processed commitment; REST uses canonical block time. Match on the composite key, not on time.

Is Datastream required?

No. REST works with any Data API key; Datastream needs Premium or higher. Without ST_DATASTREAM_KEY, the example runs the backfill only.

References

Companion project

The full example, with aligned tables, exact net-flow totals, pool scoping, optional identity enrichment, and configurable reconciliation, is at github.com/solanatracker/examples/tree/main/17-solana-liquidity-history-websocket.

cp .env.example .env && npm install && npm start

Runnable Node.js project — clone from GitHub, add keys to .env, then npm start. Use a private local environment for credentials. Check the companion package version before running.

Build on this example

Ask for changes, a different stack or the next feature. Answers use the Data API SDK and link the docs.