No examples found{
"const": "<string>"
}{
"const": "<string>"
}{
"const": "<string>",
"room": "<string>"
}{
"const": "<string>",
"room": "<string>"
}{
"const": "<string>",
"signature": "<string>",
"slot": 123,
"timestamp": 123,
"dex": "<string>",
"swap_type": "<string>",
"trader": "<string>",
"token_mint": "<string>",
"pool_address": "<string>",
"amount_in": 123,
"amount_out": 123,
"sol_amount": 123,
"token_amount": 123,
"real_sol_reserves": 123,
"real_token_reserves": 123,
"lp_fee_bps": 123,
"protocol_fee_bps": 123,
"creator_fee_bps": 123,
"base_decimals": 123,
"quote_decimals": 123,
"pool_price": 123,
"transfer_fee_in": 123,
"transfer_fee_out": 123,
"cashback_bps": 123,
"maker_tags": {
"item": "<string>"
}
}{}No examples found{
"type": "<string>",
"room": "<string>"
}{
"type": "<string>",
"room": "<string>"
}{
"const": "<string>",
"message": "<string>"
}{
"type": "<string>",
"message": "<string>"
}{
"const": "<string>"
}{
"const": "<string>",
"message": "<string>",
"error": "<string>",
"cap": 123
}WebSocket: swaps
Real-time swap stream over WebSocket. Subscribe by pool, get pushes as trades happen.
latest, graduating, and graduated discovery rooms.
When to use WebSocket
| Need | Use |
|---|---|
| Browser app, dashboard | WebSocket |
| Low-to-mid throughput server | WebSocket |
| Highest throughput, schema-typed clients | gRPC |
| Live OHLCV bars | WebSocket: OHLCV |
| Pump.fun mint / migration events | This socket — join the latest / graduating / graduated rooms |
| One-shot historical data | REST |
Endpoint and auth
wss://ws.dexploit.dev/ws/swaps
Authorization: Bearer ohlcv_live_sk_<your_key>
# or, for browsers where setting headers is awkward:
wss://ws.dexploit.dev/ws/swaps?api_key=ohlcv_live_sk_<your_key>
Subscribe
After the connection is up, send asubscribe message. Filters are optional — empty filters mean “every swap on every pool”.
{
"type": "subscribe",
"filters": {
"pools": ["<pool_address>"]
}
}
{ "type": "subscribed", "message": "Subscribed: pools" }
swap messages flow as trades happen.
Supported filters
All filters are optional and combine as AND. Set tonull or omit to disable.
| Key | Type | Notes |
|---|---|---|
pools | string[] | Filter by pool address (this is the most common filter — see Pairs vs tokens). |
tokens | string[] | Filter by token mint. |
traders | string[] | Filter by trader wallet. |
dexes | string[] | Values: pumpfun, pumpswap, raydium_amm, raydium_clmm, raydium_cpmm, orca, meteora_damm_v2, meteora_dbc, meteora_dlmm, meteora_pools. |
min_sol | number (lamports) | Minimum SOL amount. 1 SOL = 1,000,000,000 lamports. |
max_sol | number (lamports) | Maximum SOL amount. |
is_buy | boolean | true for buys only, false for sells only. |
Wire format gotchas
Aswap 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):
{
"type": "swap",
"signature": "3rjMq3C7Zp4eDDRtKwEBmVFXErFt4twvUVryNerBgkrxqs53XhAgL3LqDvfiZjDLNHEbwkbkQMTwfSjD1BvSboES",
"slot": 420984167,
"timestamp": 1779279483,
"dex": "meteora_damm_v2",
"swap_type": "sell",
"trader": "rk4jdi7srE6VKtxPeCKnqpEe7uJdZLLdpv1qAPK8LRd",
"token_mint": "8ZiqYN6rEm6PapbhCm5wWWzkRjsYyixgqW3K2384D1C3",
"pool_address": "DT75gs9fygQxi2Aq2ST1jz9C6FTNcbBjpgSpdDpMDdLX",
"amount_in": 350832255298,
"amount_out": 729873626,
"sol_amount": 729873626,
"token_amount": 350832255298,
"virtual_sol_reserves": null,
"virtual_token_reserves": null,
"real_sol_reserves": 91985332910,
"real_token_reserves": 44561454812398,
"lp_fee_bps": 1,
"protocol_fee_bps": 0,
"fee": null,
"creator_fee": null,
"creator_fee_bps": 0,
"price_impact_bps": null,
"base_decimals": 9,
"quote_decimals": 6,
"pool_price": 2.0642354092175554e-6,
"transfer_fee_in": 0,
"transfer_fee_out": 0,
"cashback": null,
"cashback_bps": 0,
"maker_tags": []
}
swap_typeis the string"buy"or"sell"(lowercase). Notis_buy. (is_buyis only a filter key, not a field on the event.) The REST/swaps*endpoints useis_buy(bool) instead — these two surfaces are intentionally different.dexis the string name here. On the REST/swaps*endpointsdexis an integer ID. Same enum, different encoding.timestampis epoch seconds. On REST/swaps*it’s epoch milliseconds; on REST candles it’s ISO 8601.- There is no
pricefield. 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 asprice_per_token, and it is correct on every venue.pool_priceis 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_amountis in lamports (1 SOL = 1e9).token_amountis in token base units; divide by10 ** quote_decimalsto get the human-readable amount. pool_addressis what we elsewhere callpair_address— same concept (the on-chain pool/LP account). The swaps stream keys it aspool_address; REST and the OHLCV WebSocket key the same account aspair_address.maker_tagscarries the wallet-intel classification of the trader at swap time (sniper,insider,whale, …). Useful for live alerting on smart-money buys.- No
price_semanticsfield. REST responses carry aprice_semanticslabel describing how the price fields are derived; streaming frames do not. The derivations are the same —pool_priceis a raw vault ratio and not a tradeable price,price_impact_bpsis corrected,price_per_tokenis the executed price — see Coverage. - Nullable fields.
virtual_*_reservesare non-null only on pump.fun.fee,creator_fee,price_impact_bps,cashbackcome back null on protocols that don’t report them — fall back to the_bpssiblings.price_impact_bpsisnullwherever no impact was computed, the same swaps REST and GraphQL returnnullfor; 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:
const solPerToken = price_per_token * 10 ** (quote_decimals - base_decimals);
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.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.
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:
| Venues | How | A 0 means |
|---|---|---|
pumpfun, pumpswap | Against the pool’s own reserves at the time of the trade (virtual reserves on pumpfun, real reserves on pumpswap), truncated toward zero. On pumpfun it’s measured against the curve after the trade, so it reads zero or negative. | The impact was under 1 bp. |
orca, raydium_amm, raydium_cpmm, raydium_clmm, meteora_dlmm, meteora_damm_v2 | The executed price against the pool’s price just before the trade, with the pool’s fee included, rounded to the nearest bp. Positive means the price moved against the trader. | These venues don’t return 0: every pool charges at least 1 bp, and the fee is part of the value. |
null:
- on
meteora_dbcandmeteora_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-feepumpfuntrades by the same program from beforeagent_tradeexisted; - on dust trades, where the smaller leg (
sol_amountortoken_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.
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 fromprice_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 asubscribe 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.
{
"type": "pool_event",
"event_type": "lp_withdraw",
"timestamp_ms": 1717000000000,
"slot": 270123456,
"signature": "5Qx…",
"user": "9aB…",
"pool_address": "DT7…",
"token_mint": "8Zi…",
"base_amount": 123456789,
"quote_amount": 4200000000,
"lp_token_amount": 987654321,
"drained_pct": 0.97,
"pool_base_reserve_after": 0,
"pool_quote_reserve_after": 0,
"lp_supply_after": 30000,
"dex": 2
}
lp_withdraw / lp_deposit (LpEvent) fields
| Field | Type | Notes |
|---|---|---|
timestamp_ms | i64 | Epoch milliseconds. ⚠ The swap frame on this same socket uses timestamp in epoch seconds — a 1000× difference. Don’t mix them. |
slot | u64 | Solana slot. |
signature | string | Base58 transaction signature. |
user | string | Wallet that added/removed the LP. |
pool_address | string | On-chain pool / LP account (elsewhere called pair_address). |
token_mint | string | Token mint. |
base_amount | u64 | Memecoin side, atomic units. Divide by 10**decimals (~6 for pump tokens). ⚠ Decimals are not carried on the event. |
quote_amount | u64 | The SOL / WSOL side in lamports, despite no _sol suffix. ⚠ Divide by 1e9 for SOL (the lamports-named-non-_sol trap). |
lp_token_amount | u64 | LP tokens, atomic. ⚠ Meteora DAMM v2 (dex=7) saturates this to u64::MAX on high-liquidity pools — use base_amount + quote_amount for value, never lp_token_amount. |
drained_pct | f32 (0..1) | Fraction of pool liquidity removed by this event. Constant-product pools (PumpSwap, RaydiumAmm) use the LP-supply ratio; Meteora CL uses the SOL-reserve ratio. 0.0 when not computable. |
pool_base_reserve_after | u64 | Post-event pool reserve, base side, atomic. 0 when N/A. |
pool_quote_reserve_after | u64 | Post-event pool reserve, quote side, lamports. 0 when N/A. |
lp_supply_after | u64 | Post-event total LP supply. Constant-product only — 0 for Meteora. |
dex | int (Enum8) | 2=PumpSwap, 3=RaydiumAmm, 4=RaydiumClmm, 5=RaydiumCpmm, 6=Orca, 7=MeteoraDammV2, 9=MeteoraDlmm. |
token_burn (BurnRecord) — a different shape
A burn carries a different field set. There is no pool_address and no base/quote amount:
{
"type": "pool_event",
"event_type": "token_burn",
"timestamp_ms": 1717000001000,
"slot": 270123457,
"signature": "3rj…",
"user": "9aB…",
"token_mint": "8Zi…",
"amount_atomic": 999999999999
}
| Field | Type | Notes |
|---|---|---|
timestamp_ms | i64 | Epoch milliseconds (same as LpEvent). |
slot | u64 | Solana slot. |
signature | string | Base58 transaction signature. |
user | string | Wallet that burned. |
token_mint | string | Token mint. |
amount_atomic | u64 | Burned amount in atomic units. |
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.
{
"type": "pool_event",
"event_type": "rug_detected",
"timestamp_ms": 1717000002000,
"slot": 270123458,
"signature": "5Qx…",
"pool_address": "DT7…",
"token_mint": "8Zi…",
"dex": 2,
"lp_sol_pulled": 4200000000,
"drained_pct": 0.97,
"classification": "RUGGED",
"reason": "lp_withdraw"
}
| Field | Type | Notes |
|---|---|---|
timestamp_ms | i64 | Epoch milliseconds. |
slot | u64 | Solana slot. |
signature | string | Base58 transaction signature of the triggering withdraw/burn. |
pool_address | string | On-chain pool / LP account. |
token_mint | string | Token mint. |
dex | int (Enum8) | The venue the drain happened on — any non-pumpfun AMM/CLMM (e.g. 2=PumpSwap, 3=RaydiumAmm, 4=RaydiumClmm, 5=RaydiumCpmm, 6=Orca, 7=MeteoraDammV2, 9=MeteoraDlmm). |
lp_sol_pulled | u64 | SOL liquidity removed, in lamports. Divide by 1e9 for SOL. |
drained_pct | f32 (0..1) | Fraction of pool liquidity removed. |
classification | string | "RUGGED" when >95% of liquidity was removed; "LIQUIDITY_DRAINING" when 50–95%. |
reason | string | "lp_withdraw" or "lp_burn" — which action triggered the classification. |
Filter scope for pool events
In filter-mode, onlytokens 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 (ajoin message) instead of filters, the same pool events arrive wrapped in a room message. Join a transaction room:
{ "type": "join", "room": "transaction:<mint>" }
transaction:<mint>:<pool> to scope to one pool). Events then arrive as:
{
"type": "message",
"room": "transaction:<mint>",
"data": { "event_type": "lp_withdraw", "timestamp_ms": 1717000000000, "...": "..." }
}
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).
| Room | Join string | What it carries |
|---|---|---|
| Latest mints | latest | New-mint discovery — one data per mints.observed event as a token is first seen. |
| Graduating | graduating | Tokens crossing the bonding-curve completion threshold (the mints.completing lifecycle event). |
| Graduated | graduated | Tokens that migrated to a tradeable AMM pool (the mints.graduated lifecycle event). |
| Rugs (global) | rugs | Every rug_detected across all venues — the full RugRecord (classification, drained_pct, lp_sol_pulled in lamports, dex, plus reason). See below. |
| Token safety | safety:{mint} | The mint’s full actor/safety snapshot (holder/sniper/insider/bundler counts, top-holder %, dev-held %, …), re-pushed on-change (~3–5 s coalesced). |
| Price bars | price:{addr}:{tf} | Live OHLCV bar updates for addr (a mint OR pool) at timeframe tf. tf ∈ 1s 30s 1m 5m 15m 1h 4h 1d. A mint-keyed room interleaves the mint’s pairs (each frame carries pair_address). |
| Wallet trades | wallet:{wallet} | Every swap where wallet is the trader (a live per-wallet trade tape). |
| Transaction (token) | transaction:{mint} | Interleaved swap + lp_deposit/lp_withdraw/token_burn + rug_detected for one mint (all pools). |
| Transaction (pool) | transaction:{mint}:{pool} | Same as above, scoped to a single pool. |
| Wallet PnL | pnl:{wallet} | Per-position PnL frames for wallet — one frame per holding, re-pushed as the wallet trades or spot moves. Pro+. |
| Position PnL | pnl:{wallet}:{token} | A single (wallet, token) position, glass-box (with realized_breakdown). Pro+. A token literally named summary is not addressable. |
| Wallet PnL summary | pnl:{wallet}:summary | The wallet-level rollup (realized_sol, unrealized_sol, cost_basis_sol, open_positions, closed_positions, positions[]). Pro+. |
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
{ "type": "join", "room": "rugs" }
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.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:
{
"type": "message",
"room": "rugs",
"data": {
"event_type": "rug_detected",
"token_mint": "8Zi…",
"pool_address": "DT7…",
"dex": 7,
"classification": "RUGGED",
"drained_pct": 0.97,
"lp_sol_pulled": 4200000000,
"reason": "lp_withdraw"
}
}
A safety:{mint} frame
{
"type": "message",
"room": "safety:8Zi…",
"data": { "mint": "8Zi…", "holder_count": 412, "top10_pct": 0.38, "dev_held_pct": 0.0, "sniper_count": 9, "insider_count": 3, "bundler_count": 0, "...": "..." }
}
A pnl:{wallet}:summary frame
{
"type": "message",
"room": "pnl:9aB…:summary",
"data": {
"wallet": "9aB…",
"pnl_mode": "adjusted",
"realized_sol": 12.34,
"unrealized_sol": -1.2,
"cost_basis_sol": 40.0,
"open_positions": 5,
"closed_positions": 18,
"positions": [ { "mint": "8Zi…", "realized_sol": 3.1, "unrealized_sol": 0.4, "...": "..." } ]
}
}
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).| DEX | dex | LP / rug events |
|---|---|---|
| PumpSwap | 2 | ✅ Live |
| RaydiumAmm v4 | 3 | ✅ Live |
| RaydiumClmm | 4 | ✅ Live |
| RaydiumCpmm | 5 | ✅ Live |
| Orca | 6 | ✅ Live |
| MeteoraDammV2 | 7 | ✅ Live |
| MeteoraDlmm | 9 | ✅ Live |
| MeteoraDbc | 8 | ❌ Not emitted |
| MeteoraPools | 10 | ❌ Not emitted |
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) — recentrug_detectedevents across all venues (7-day window, paged; requires an API key).drained_pctis a 0..1 fraction andlp_sol_pulledis already in SOL — butreasonis WS-only (use therugsroom for it). - REST:
GET /tokens/{mint}/rugs— rug history for a single mint. - MCP: the
list_pool_eventstool wraps the same pool-events endpoint.
/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
import WebSocket from 'ws'; // browser: use the global WebSocket
const URL = 'wss://ws.dexploit.dev/ws/swaps';
const API_KEY = 'ohlcv_live_sk_<your_key>';
const POOL = '<pool_address>';
let backoff = 1000;
function connect() {
const ws = new WebSocket(URL, {
headers: { Authorization: `Bearer ${API_KEY}` }
});
ws.on('open', () => {
backoff = 1000; // reset on successful connect
ws.send(JSON.stringify({
type: 'subscribe',
filters: { pools: [POOL] }
}));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
switch (msg.type) {
case 'subscribed':
console.log('subscribed:', msg.message);
break;
case 'swap': {
const sol = msg.sol_amount / 1e9;
const tok = msg.token_amount / Math.pow(10, msg.quote_decimals ?? 6);
const price = sol / tok;
console.log(
`${msg.swap_type.toUpperCase()} ${tok.toFixed(2)} @ ${price.toExponential(3)} SOL ` +
`(${msg.dex}, sig ${msg.signature.slice(0, 8)}…)`
);
break;
}
case 'error':
console.error('stream error:', msg.message);
break;
}
});
ws.on('close', () => {
console.warn(`disconnected, retrying in ${backoff}ms`);
setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, 60_000);
});
ws.on('error', (err) => {
console.error('ws error:', err);
// 'close' fires next; reconnect happens there.
});
}
connect();
Server messages
type | When |
|---|---|
subscribed | After your subscribe. |
unsubscribed | After your unsubscribe. |
swap | A trade matched your filters. |
pong | Reply to your ping. |
error | Subscribe rejected, auth failed, or server-side error. |
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.No examples found{
"const": "<string>"
}{
"const": "<string>"
}{
"const": "<string>",
"room": "<string>"
}{
"const": "<string>",
"room": "<string>"
}{
"const": "<string>",
"signature": "<string>",
"slot": 123,
"timestamp": 123,
"dex": "<string>",
"swap_type": "<string>",
"trader": "<string>",
"token_mint": "<string>",
"pool_address": "<string>",
"amount_in": 123,
"amount_out": 123,
"sol_amount": 123,
"token_amount": 123,
"real_sol_reserves": 123,
"real_token_reserves": 123,
"lp_fee_bps": 123,
"protocol_fee_bps": 123,
"creator_fee_bps": 123,
"base_decimals": 123,
"quote_decimals": 123,
"pool_price": 123,
"transfer_fee_in": 123,
"transfer_fee_out": 123,
"cashback_bps": 123,
"maker_tags": {
"item": "<string>"
}
}{}No examples found{
"type": "<string>",
"room": "<string>"
}{
"type": "<string>",
"room": "<string>"
}{
"const": "<string>",
"message": "<string>"
}{
"type": "<string>",
"message": "<string>"
}{
"const": "<string>"
}{
"const": "<string>",
"message": "<string>",
"error": "<string>",
"cap": 123
}?api_key=ohlcv_live_sk_… appended to the connection URL. Browser-friendly; the only option in environments where you can't set headers.
Replaces the active filter set. Empty filters means "every swap on every pool".
Optional keepalive. Server replies with pong. Recommended every 25–30 seconds.
Subscribe to a room (e.g. transaction:<mint> or transaction:<mint>:<pool>). First join locks room-mode.
One executed swap on a Solana DEX, normalized across protocols.
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-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).
Sent after a successful join.
Sent on connect AND after each successful subscribe. The connect-time frame includes a client-N ID for log correlation.
Reply to a client ping. Send a ping every ~25–30s to keep the connection from idling out.
Validation failure, auth issue, or server-side error. Connection usually stays open after a recoverable error.

