Docs
    Guide · Beta

    WebSockets

    Stream decoded blocks, DEX swaps, and token transfers over a single persistent connection. Path-based subscription, server-side SQE filters, and per-message billing.

    What you get

    • Solana DEX swaps solana@swaps. Raydium, Orca, Meteora, Pump.fun and more, normalized per trade.
    • Solana transfers solana@spl_transfer, solana@system_transfer. SPL, SPL-2022, and native SOL.
    • EVM DEX swaps mainnet@swaps, base@swaps, and 6 more EVM chains.
    • EVM ERC-20 transfers mainnet@erc20_transfers, plus the same 8 EVM networks.

    Live catalog at ws.pinax.network/streams. Supported networks at api.pinax.network/v1/networks.

    Subscription URL

    Channels are encoded in the URL path — no JSON-RPC subscribe message needed.

    wss://ws.pinax.network/ws/<network>@<table>?token=<API_KEY>

    Single channel, raw payload:

    wss://ws.pinax.network/ws/solana@swaps?token=$PINAX_KEY

    Multi-channel (wrapped envelope — { "stream": "<id>", "data": <raw> }):

    wss://ws.pinax.network/ws/solana@swaps/mainnet@erc20_transfers?token=$PINAX_KEY

    Wildcards work on either side — *@swaps, solana@*. Bare /ws is HTTP 400; use /ws/*@* to opt into everything.

    The network side also takes a comma-separated list — subscribe to the same table across chains in one selector. The server expands it into one entry per network.

    wss://ws.pinax.network/ws/solana,base,mainnet@swaps?token=$PINAX_KEY

    A bare *can't be mixed with named networks, and the comma list isn't allowed on the table side. Comma lists also work in SUBSCRIBE / UNSUBSCRIBE params.

    Messages vs events — billing

    Two counters matter, and they are not the same number:

    • Messages — one WebSocket frame per (network, table) per block. This is the billable unit.
    • Events — items inside each message's events[] array. A single message can carry dozens of swaps or transfers.

    Pricing is $0.00005 per message (= $0.50 per 10K). A package that emits two tables produces two messages per block — one per table — regardless of how many events each contains. Use server-side SET_FILTER to reduce billable messages further; heartbeats and protocol frames are free.

    Sample envelope (Solana swaps):

    {
      "network": "solana",
      "table": "swaps",
      "block_num": 422102644,
      "block_hash": "6AEzeSFDZstnhagF1D2FsCPNVELz1YBTvHFMHDSNP4fs",
      "timestamp": "2026-05-25T16:20:05Z",
      "timestamp_seconds": 1779726005,
      "module_hash": "411d6a46…b295",
      "events": [
        { "protocol": "pumpfun_amm", "user": "B6r52E…YUbC", ... },
        { "protocol": "raydium_clmm", "user": "9KaPDQ…X4tN", ... }
      ]
    }

    Authentication

    Every connection needs your API key. Two equivalent options:

    • Header (server libs): Authorization: Bearer <jwt>
    • Query param(browsers — can't set headers on the WS upgrade): ?token=<jwt>

    Same JWT as the rest of Pinax — RPC, Firehose, Token API. Quota is shared.

    Reconnects (live-only)

    This WebSocket is a live-only feed — it delivers blocks as they arrive and keeps no history. On reconnect you simply resume the live stream; there is no server-side catch-up. Cursor handling stays internal to the server, so the live feed keeps flowing across restarts with no client action.

    For historical data or to backfill a gap, use a Substreams gRPC stream — it resumes from any block, cursor, or timestamp with an explicit start_block, which is the right tool for replay.

    Server-side filtering

    Drop non-matching events before they hit the wire — this cuts billable messages, not just bandwidth. The filter is an SQE expression string (the same query language as Firehose substreams run -t) scoped to a selector: params is [selector, expression].

    • field:value — case-insensitive equality on an events[*] column.
    • bare value — matches when any column equals it.
    • operators || (OR), && or whitespace (AND), ! (NOT), ( ) (grouping).

    Ethereum — watch one wallet in any role (sender, receiver, or tx signer):

    { "method": "SET_FILTER",
      "params": ["mainnet@erc20_transfers",
                 "tx_from:0xd8dA…6045 || from:0xd8dA…6045 || to:0xd8dA…6045"],
      "id": 1 }

    Solana — Raydium CPMM swaps from one wallet:

    { "method": "SET_FILTER",
      "params": ["solana@swaps",
                 "protocol:raydium_cpmm && user:F2MUEfN1HG5mC5EiUoxhjjc7HpKi4QQnzvipnbGx6Av8"],
      "id": 2 }

    You can also set a filter on connect with ?filter=<url-encoded-expr> (alias ?sqe=). SET_FILTER replaces the filter for a selector — combine conditions with || in one expression. Max 512 terms, 16 distinct fields. Full reference and recipes: Filters docs.

    Use cases

    Live trade & transfer feeds

    Push DEX swaps and ERC-20 / SPL transfers into a UI the moment they confirm. One socket per network@table — no polling, no per-block REST fan-out.

    Real-time alerting

    Apply server-side SET_FILTER rules to drop non-matching events on the wire. Trigger Discord, Slack, or webhook notifications only on the trades you actually care about.

    Reorg-aware indexers

    undo lifecycle events surface chain reorganizations explicitly. Roll back materialized state past last_valid_block instead of rebuilding from scratch.

    AI agents & MCP

    Stream pre-parsed swaps into an agent loop with no schema work. Each message ships the protocol, the user, and the trade — ready to be summarized or acted on.

    Frequently asked

    Why is this marked Beta?
    Stream protocol is stable but channel names and payload field naming may evolve. Pin a module_hash in production and watch for stream lifecycle fatal messages.
    How many connections can I open?
    Beta caps per-key concurrency at 4 connections. Multiplex many channels over a single socket using the multi-channel URL form instead of opening one per stream.
    What happens to slow consumers?
    Each connection has a 1024-message buffer. If you fall behind, messages are dropped per-message rather than blocking ingest — so a sluggish consumer never backpressures other subscribers. When you catch up, the server emits a stream dropped frame with the count lost plus last_block / last_timestamp of where delivery resumed, so you can reconcile the gap instead of silently shipping incomplete data.
    Are heartbeats billable?
    No. WebSocket ping/pong frames are protocol-level and free. The server pings every 180s; clients that don't pong within 600s are closed.
    Are values in events[] strings or numbers?
    Strings on the wire — DatabaseChanges proto convention. Numeric parsing is the consumer's job. Use the field's known decimals (e.g. token decimals) to scale.
    Which event fields are dropped on the wire?
    ClickHouse-backfill provenance columns are stripped at decode time. On EVM: tx_index, tx_nonce, tx_gas_*, tx_value, log_index, log_topics, log_data, and all call_*. On SVM: compute_units_consumed, stack_height. Kept: tx_hash, tx_from/tx_to, log_ordinal, log_address, signature, fee_payer, program_id. Any *_raw field is split on commas into a JSON array under the suffix-stripped key — signers_raw "a,b,c" signers ["a","b","c"].
    How do I get older history?
    The WebSocket is live-only. For history or to backfill a gap, use Substreams gRPC directly with a start_block (it resumes from any block, cursor, or timestamp), or query Token API for indexed history.

    Related