Skip to main content
WSS
The swaps WebSocket is the easiest way to get real-time DEX trades into a browser, dashboard, or low-volume server. For higher throughput, see gRPC. For live OHLCV bars, see OHLCV candles. Pump.fun mint / graduation events stream from this same socket — join the latest, graduating, and graduated discovery rooms.

When to use WebSocket

Endpoint and auth

Authenticate either via Bearer header (server) or query string (browser):

Subscribe

After the connection is up, send a subscribe message. Filters are optional — empty filters mean “every swap on every pool”.
You’ll receive:
Then swap messages flow as trades happen.

Supported filters

All filters are optional and combine as AND. Set to null or omit to disable.

Wire format gotchas

A swap frame looks like this (captured live from meteora_damm_v2 — the values are that pool’s, so read pool_price and price_impact_bps with the venue in mind):
A few things worth flagging:
  • swap_type is the string "buy" or "sell" (lowercase). Not is_buy. (is_buy is only a filter key, not a field on the event.) The REST /swaps* endpoints use is_buy (bool) instead — these two surfaces are intentionally different.
  • dex is the string name here. On the REST /swaps* endpoints dex is an integer ID. Same enum, different encoding.
  • timestamp is epoch seconds. On REST /swaps* it’s epoch milliseconds; on REST candles it’s ISO 8601.
  • There is no price field. The price the trade filled at is the trade’s own two legs: price = (sol_amount / token_amount) * 10 ** (quote_decimals - base_decimals), in SOL per whole token. That is the same number REST returns as price_per_token, and it is correct on every venue. pool_price is the pool’s reported reserve ratio — not a mid, and not a quote you can trade against; read Which price field should I use? below before using it.
  • Amounts are integers in base units. sol_amount is in lamports (1 SOL = 1e9). token_amount is in token base units; divide by 10 ** quote_decimals to get the human-readable amount.
  • pool_address is what we elsewhere call pair_address — same concept (the on-chain pool/LP account). The swaps stream keys it as pool_address; REST and the OHLCV WebSocket key the same account as pair_address.
  • maker_tags carries the wallet-intel classification of the trader at swap time (sniper, insider, whale, …). Useful for live alerting on smart-money buys.
  • No price_semantics field. REST responses carry a price_semantics label describing how the price fields are derived; streaming frames do not. The derivations are the same — pool_price is a raw vault ratio and not a tradeable price, price_impact_bps is corrected, price_per_token is the executed price — see Coverage.
  • Nullable fields. virtual_*_reserves are non-null only on pump.fun. fee, creator_fee, price_impact_bps, cashback come back null on protocols that don’t report them — fall back to the _bps siblings. price_impact_bps is null wherever no impact was computed, the same swaps REST and GraphQL return null for; see below.

Which price field should I use?

A swap carries three numbers that look like a price. They mean different things, and only one of them is right for every venue and every date.

Start with price_per_token

price_per_token is the price the trade actually executed at, computed from the trade’s own two legs and nothing else — it is exactly sol_amount / token_amount. Because it never touches pool reserves, nothing about how a venue reports its reserves can bias it, which is what makes it the one price field that is correct on all ten venues and across all of history. It arrives as lamports per atomic token unit. Scale it once to get SOL per whole token:
The streaming feeds don’t carry the field, but they carry both legs, so the same number is sol_amount / token_amount from the frame you already have.
base_decimals and quote_decimals are the reverse of the usual convention. base_decimals is the SOL side and is 9 on every venue. quote_decimals holds the token’s own decimals, despite the name — commonly 6, but 9 and other values are normal. Read quote_decimals as “the token’s decimals” and the line above is the whole conversion.
For a USD price, multiply by the SOL/USD rate.

pool_price is a reserve ratio, not a quote

pool_price is the ratio of the pool’s reported SOL reserve to its reported token reserve, already scaled to SOL per whole token, and passed through as the venue published it. We serve it uncorrected on purpose: it is the faithful record of what the venue reported, which is what makes it useful for reserve and liquidity work. That same property is why it isn’t a price you can trade against:
  • On constant-product and bonding-curve pools the ratio is the pool’s marginal price, and it lands within about a fee of the executed price.
  • On concentrated-liquidity pools the reported reserves are the pool’s whole inventory across every tick or bin — not the liquidity at the active price. The ratio is an inventory statistic, and it can sit far from anything you could fill at.
  • On PumpSwap pools that graduated from the pump.fun bonding curve after mid-July 2026, about 17.58 SOL of quote sits outside the account whose balance the swap event reports. The ratio therefore understates the price by (sol_reserve + 17.58) / sol_reserve — under a percent on a well-funded pool, and much larger as the pool drains.
Per-venue numbers are in Data coverage.

price_impact_bps is null where it wasn’t computed

price_impact_bps is nullable on every surface that carries it. REST (/swaps* and /swaps/{signature}), GraphQL (Int, previously Int!) and WebSocket all return null where no impact was computed. (The gRPC streams don’t carry the field.) None of them returns 0 for that case, so on REST and GraphQL a 0 is a real reading. The WebSocket exception is below. It’s computed on these venues: It’s null:
  • on meteora_dbc and meteora_pools;
  • on the six venues in the second row, for swaps from before that venue started computing it, and for swaps that couldn’t be priced;
  • on trades made by pump.fun’s own Mayhem program (agent_trade = 2), and on older zero-fee pumpfun trades by the same program from before agent_trade existed;
  • on dust trades, where the smaller leg (sol_amount or token_amount, in base units) is under 10,000. At that size one unit of integer rounding moves the executed price by more than 1 bp, so any number would be rounding noise.
In rare cases a stream frame carries 0 for a swap that REST and GraphQL serve as null: a swap on one of the six venues in the second row whose computed value landed on exactly 0. Treat both as unknown. On pumpswap the comparison is corrected for the SOL a pump.fun-migrated pool holds outside the account the swap reports, so it does not inherit the understatement described for pool_price. The same correction is applied on REST, GraphQL and WebSocket, so the surfaces agree for a given swap, apart from the rare 0 above and one more documented limit: REST and GraphQL clamp price_impact_bps to the Int16 range (−32,768 to 32,767), while the WebSocket feed carries the full value, so beyond that range they differ by design. It measures impact relative to the pool, not slippage against an external reference — for that, compare price_per_token to a reference you control.

For a price series, use candles

Candle prices are written when the bar closes and served back verbatim — stored bars are never re-derived at read time. So a change in how a venue’s price is derived reaches bars written from the day it ships forward; bars that closed earlier keep the value they were written with. One case is worth a number. On PumpSwap, daily closes written before the fix shipped carry the reserve-ratio understatement described above. Over the seven days 2026-08-31 to 2026-09-06 inclusive, across 153,174 PumpSwap daily bucket-closes, 39.5% (60,474) would take a different value once the migrated pool’s off-vault SOL is accounted for. Most of those moves are small; the largest single one in that window was 135×, on a pool drained to near-zero SOL, where the fixed shortfall dominates whatever is left. When you need a price you can act on, derive it from price_per_token over the swaps in the window rather than reading the bar close.

Pool events (LP add/remove + burns)

The same /ws/swaps socket also carries non-swap pool activity — LP deposits, LP withdrawals, token burns, and a server-classified rug signal. An unfiltered connection (or one filtered only by tokens/pools) receives these frames interleaved with swaps. This is the real-time liquidity-removal push that lets a bot react to a rug in the same second it lands, instead of polling.
lp_withdraw is the live rug / LP-removal signal. A large lp_withdraw is liquidity leaving the pool the instant it happens. rug_detected is the classified version — the server applies a drain threshold and a graduation-exclusion gate and tells you RUGGED vs LIQUIDITY_DRAINING so you don’t have to.

Filter-mode frame

In filter-mode (the default — the mode you get after sending a subscribe message), pool events arrive with a top-level type: "pool_event" and an event_type discriminator. All four pool-event kinds share type: "pool_event" — demux on event_type.

lp_withdraw / lp_deposit (LpEvent) fields

There is no buy/sell side and no price field on a pool event — it’s a liquidity action, not a trade.

token_burn (BurnRecord) — a different shape

A burn carries a different field set. There is no pool_address and no base/quote amount:

rug_detected (RugRecord) — the classified signal

rug_detected is a server-side classified rug / drain signal, derived from an lp_withdraw or lp_burn. It fires on any non-pumpfun venue when a real LP drain crosses the drain threshold outside the graduation/migration window — i.e. on a genuine AMM/CLMM pool, not the pump.fun bonding curve (which has no fungible LP to pull). Pump graduation-migrations are excluded: they move liquidity but aren’t rugs, so they never produce a rug_detected.

Filter scope for pool events

In filter-mode, only tokens and pools apply to pool events. The dexes, min_sol, max_sol, traders, is_buy, and wallet_tags filters are swap-only and have no effect on pool-event delivery. A default (empty) subscription delivers the full pool-event firehose immediately on connect — you don’t have to subscribe to start receiving them.

Room-mode delivers the same events with a different shape

If you use rooms (a join message) instead of filters, the same pool events arrive wrapped in a room message. Join a transaction room:
(or transaction:<mint>:<pool> to scope to one pool). Events then arrive as:
Dual-frame trap. The same pool event has two on-wire shapes depending on mode. In filter-mode it’s top-level (type: "pool_event", event_type: …). In room-mode the data object carries event_type but not a top-level type. Read event_type from msg in filter-mode and from msg.data in room-mode.
transaction:{mint} rooms interleave swaps, pool events (lp_deposit/lp_withdraw/token_burn), and rug_detected — demux on type / event_type. Room-mode is mode-exclusive with filter-mode on a given connection: the first subscribe locks the socket into filter-mode, and the first join locks it into room-mode. Pick one per connection.

Rooms reference

Every valid /ws/swaps room is listed below. Join with { "type": "join", "room": "<room>" }; the server replies { "type": "joined", "room": "<room>" } (and { "type": "left", "room": "<room>" } after a leave). An unknown or malformed room is rejected with an error frame (unknown_room / malformed_room). Joining a room that isn’t the discovery rooms — i.e. any pnl:* room — requires the Pro or Enterprise tier; lower tiers get a tier_required error. Per-tier join caps apply: Free 5, Developer 25, Pro 100, Enterprise unlimited. Unless noted, every room delivers the room-mode wrapper — { "type": "message", "room": "<room>", "data": { … } } — and you read the real payload from data (the dual-frame trap above).
pnl:* rooms push an instant snapshot on join. The other rooms only start delivering on the next matching event; the PnL rooms seed the joining connection with its current book immediately (then keep it live).

The global rugs room

A graduated boolean used to ship on this record. It was removed: it was derived as “the venue is PumpSwap”, which is false for most rugged pools (they are created directly on PumpSwap rather than graduating from the pump.fun bonding curve), and the classifier holds no complete graduation history to derive it from honestly.
The rugs room fans out the full RugRecord for every rug_detected — the same object the rug_detected field table describes, including reason (and the dex venue id). This is a superset of what the REST GET /rugs feed (under Rugs in the API reference) returns — REST omits reason (it lives only on the NATS wire), so use the room when you need it live:

A safety:{mint} frame

A pnl:{wallet}:summary frame

Per-DEX coverage matrix

LP and rug events are live for every AMM venue. The only two gaps are the bonding-curve venues, which have no fungible LP to add, remove, or drain (see Data coverage).
Absence is not safety on the two uncovered venues. meteora_dbc (8) is a bonding curve — there’s no fungible LP, so the curve is the liquidity and there’s nothing to lp_withdraw. meteora_pools (10) routes liquidity through dynamic vaults shared across many pools, so a per-pool LP add/remove isn’t derivable from a swap (see Data coverage). On those two, not seeing an LP-removal or rug_detected event is not proof a pool is safe.

Historical / replay companions

For backfill and gap-filling, the same events are queryable over REST and MCP:
  • REST: GET /pool-events?token=<mint>&limit=N&before=<ts_ms> — paginated LP/burn history (cursor is epoch ms).
  • REST: GET /rugs (under Rugs in the API reference) — recent rug_detected events across all venues (7-day window, paged; requires an API key). drained_pct is a 0..1 fraction and lp_sol_pulled is already in SOL — but reason is WS-only (use the rugs room for it).
  • REST: GET /tokens/{mint}/rugs — rug history for a single mint.
  • MCP: the list_pool_events tool wraps the same pool-events endpoint.
Edge: /ws/swaps is already routed at the OVH edge — both lp_withdraw and rug_detected ride this same socket. No new endpoint or connection is needed.

Complete TypeScript client

For production hardening (resubscribe state, gap-filling via REST, idle pings), see Reconnect & backpressure.

Server messages

Programmatic spec

If you’re generating clients or feeding the contract into an LLM/codegen pipeline, every WebSocket channel — /ws/swaps and /ws/ohlcv — is described as a single AsyncAPI 3.0 document. All message schemas and live-captured example payloads are included.
api_key
type:httpApiKey

?api_key=ohlcv_live_sk_… appended to the connection URL. Browser-friendly; the only option in environments where you can't set headers.

Set filters
type:object

Replaces the active filter set. Empty filters means "every swap on every pool".

Clear filters
type:object
Heartbeat
type:object

Optional keepalive. Server replies with pong. Recommended every 25–30 seconds.

Join room
type:object

Subscribe to a room (e.g. transaction:<mint> or transaction:<mint>:<pool>). First join locks room-mode.

Leave room
type:object
Swap event
type:object

One executed swap on a Solana DEX, normalized across protocols.

Pool event

Non-swap pool activity (filter-mode): LP deposit / withdraw, token burn, or the server-classified rug signal. All four share type: "pool_event" — demux on event_type. lp_withdraw is the real-time LP-removal signal; rug_detected is the classified version.

Room message
type:object

Room-mode delivery wrapper. The swap or pool event is nested under data; data carries event_type but NOT a top-level type (dual-frame trap).

Join ack
type:object

Sent after a successful join.

Leave ack
type:object
Connection / subscribe ack
type:object

Sent on connect AND after each successful subscribe. The connect-time frame includes a client-N ID for log correlation.

Unsubscribe ack
type:object
Pong
type:object

Reply to a client ping. Send a ping every ~25–30s to keep the connection from idling out.

Stream error
type:object

Validation failure, auth issue, or server-side error. Connection usually stays open after a recoverable error.