# WebSocket stream

> Real-time Robinhood Chain data over one WebSocket - trades, prices, live candles, new launches, graduations, new pools, LP and tax changes, wallet activity, with replay and reorg handling.

Source: https://docs.crius.sh/reference/stream/ · Updated 2026-10-28

WS `wss://api.crius.sh/v1/{chain}/stream`

**Plan:** Free and above, key with the `stream` scope · **Cost:** 1 credit per connection

One connection carries many subscriptions. Messages are pushed as soon as a block is decoded, typically well under a second after it was produced.

| Plan | WebSocket connections | Subscriptions per connection |
| --- | --- | --- |
| Anonymous (no key) | none | none |
| Free | 1 | 10 |
| Starter | 3 | 100 |
| Pro | 10 | 1,000 |
| Business | 50 | 10,000 |
| Enterprise | custom | custom |

See [Plans & limits](https://docs.crius.sh/plans-and-limits/) for the rest of each plan.

## Connecting

Authenticate with the `x-api-key` header, or with `?api_key=` where a client can’t set headers (browsers):

```js
const ws = new WebSocket(`wss://api.crius.sh/v1/robinhood/stream?api_key=${key}`);
```

The key is checked before the upgrade, like any REST call: no key returns `403 plan_required`, and a key without the `stream` scope returns `403 scope_required`.

On connect the server sends a greeting with your limits (`max_subscriptions` is your plan’s subscriptions per connection):

```json
{"ch": "hello", "data": {"chain": "robinhood", "heartbeat_s": 15, "max_subscriptions": 10}}
```

## Operations

Send JSON messages with an `op`:

| Op | Message | Reply |
| --- | --- | --- |
| Subscribe | `{"op": "subscribe", "channels": ["trades:0x…", "launches:all"]}` | `{"ch": "subscribed", "data": {"channels": […], "finality": "latest"}}` |
| Unsubscribe | `{"op": "unsubscribe", "channels": ["trades:0x…"]}` | `{"ch": "unsubscribed", "data": {"channels": […]}}` |
| Settled data only | `{"op": "subscribe", "channels": […], "finality": "safe"}` | events are held until their block is `safe` |
| Resume after a reconnect | `{"op": "resume", "seqs": {"trades:0x…": 90455}}` | missed messages (up to 60 s), then `{"ch": "resumed"}` |
| Ping | `{"op": "ping"}` | `{"ch": "pong", "ts": …}` |

-   **Subscribe** adds channels to the connection; the `subscribed` and `unsubscribed` replies list every channel the connection now has, not only the ones in the message.
-   Channel names are lowercased by the server, and messages carry the lowercase name in `ch`. Send lowercase addresses so the names you match on are the ones you receive.
-   **Price snapshot.** For each `price:` channel in a `subscribe` message, the current price is sent right away, before the `subscribed` reply: `{"ch": "price:0x…", "snapshot": true, "data": {"usd": …, "eth": …, "liquidity_usd": …}}`. A snapshot has no `seq`, `block` or `ts`, its `ch` is the name exactly as you sent it, and it is only sent for tokens that have a price.
-   **Finality.** `"finality": "safe"` (or `"finalized"`, which behaves the same) applies to the whole connection, for every channel, from that message on; the reply says `"safe"`. Without it, events are sent at `latest`.
-   **Resume.** `seqs` maps a channel to the last `seq` you saw on it; you receive the newer messages still in the replay buffer (the last 60 seconds) for channels you’re subscribed to, then `{"ch": "resumed"}`. `from_seq` (a number) is used for subscribed channels missing from `seqs`. Channels with neither are not replayed, and resume only covers channels already subscribed on this connection: send `subscribe` first.

## Errors

Errors arrive as messages on the `error` channel; the connection stays open.

| `data.code` | When | Other fields |
| --- | --- | --- |
| `channel_denied` | A `firehose:` channel on a plan below Enterprise, or subscribing past `max_subscriptions` | `channels` (the ones refused), `message`, `max_subscriptions` |
| `lagged` | Your client read too slowly and the server dropped messages for it | `dropped` (how many), `hint: "resume with your last seq"` |
| `bad_message` | The message wasn’t valid JSON, or had no `op` | `message` |
| `unknown_op` | `op` is not `subscribe`, `unsubscribe`, `resume` or `ping` | `op` |

```json
{"ch": "error", "data": {"code": "lagged", "dropped": 120, "hint": "resume with your last seq"}}
```

On `lagged`, send `resume` with your last `seq` per channel to get the dropped messages back (if they’re still in the 60-second buffer).

## Channels

| Channel | Pushes |
| --- | --- |
| `trades:<token>` | Every trade of a token, in any pool |
| `trades:pool:<pool>` | Every trade in one pool (v2/v3 pool address or v4 pool id) |
| `price:<token>` | Price updates (`usd`, `eth`, `liquidity_usd`) when the token trades, at most every 100 ms per token; a snapshot is sent right after subscribing |
| `candles:<token>:<interval>` | The live candle for `1s`, `30s`, `1m`, `5m`, `15m`, `1h`, `4h`, `6h` or `1d`, after every trade |
| `token:<token>` | Price and holder count (`price_usd`, `holders`), with the same timing as `price:` |
| `tax:<token>` / `tax:all` | Realized buy or sell tax changes of one token, or of every token |
| `lp:<token>` / `lp:all` | Liquidity added or removed in the token’s pools, or in every pool |
| `launches:<launchpad>` / `launches:all` | New launches (`pons`, `flap`, `bankr`, `long`, `flywheel`, `standard_reserve`) |
| `graduations:<launchpad>` / `graduations:all` | Graduations, with the new pool |
| `pools:new` | Every new Uniswap v2 / v3 / v4 pool, with hook permissions |
| `wallet:<address>` | Trades the wallet made and token transfers to or from it |
| `dev:<address>` | New tokens launched by a creator |
| `firehose:trades` | Every trade on the chain (Enterprise) |

`finality`, `reorg` and `heartbeat` messages reach every connection without subscribing.

Every message has the same envelope: `ch`, `seq`, `block`, `ts` (unix ms: the block’s time for chain events, the server’s time on `finality`, `reorg` and `removed` re-sends) and `data`. This is the [trade tape](https://docs.crius.sh/reference/trades/trade-tape/) sample’s trade `100410183-9-12` as the stream sends it:

```json
{
  "ch": "trades:0x322f0929c4625ed5bad873c95208d54e1c003b2d",
  "seq": 90455,
  "block": 100410183,
  "ts": 1793225424000,
  "data": {
    "id": "100410183-9-12",
    "type": "sell",
    "ts": 1793225424000,
    "block": 100410183,
    "token": "0x322f0929c4625ed5bad873c95208d54e1c003b2d",
    "symbol": "TSLA",
    "logo": null,
    "pool": "0x3ea70ce0b34473692549f97e630a1d82dc05cea94fa55f81b50f10c8c6d8186f",
    "dex": "uniswap_v4",
    "trader": "0x278d858f05b94576c1e6f73285886876ff6ef8d2",
    "signer": "0xe07ba3acee529ff1d4f7290cbae32fa065e18acd",
    "amount_base": 0.68873541086638403,
    "amount_quote": 0.358712450309036398,
    "price_usd": 402.641,
    "value_usd": 277.313,
    "tax_pct": 0
  }
}
```

`seq` increases by one per channel. Keep the last `seq` you saw for each channel and send them in a `resume` op after reconnecting to receive what you missed (60-second replay buffer).

### Message data per channel

| Channel | `data` fields |
| --- | --- |
| `trades:`, `trades:pool:`, `firehose:trades`, and trades on `wallet:` | `id` (`block-tx-log`), `type` (`buy` / `sell`), `ts`, `block`, `token`, `symbol`, `logo`, `pool`, `dex`, `trader`, `signer`, `amount_base`, `amount_quote` (human units), `price_usd`, `value_usd`, `tax_pct` (`null` when no tax was measured) |
| Transfers on `wallet:` | `type: "transfer"`, `token`, `from`, `to`, `amount`, `id`. Mints and burns are not sent |
| `price:` | `usd`, `eth`, `liquidity_usd` |
| `candles:` | `t` (bucket start, unix ms), `o`, `h`, `l`, `c`, `v` (USD volume), `n` (trades) |
| `token:` | `price_usd`, `holders` (`null` until the token’s holder history is complete) |
| `tax:` | `token`, `side` (`buy` / `sell`), `from`, `to` (whole percent), `source: "realized"` |
| `lp:` | `type` (`add_liquidity` / `remove_liquidity`), `pool`, `dex`, `provider`, `token0`, `token1`, `amount0`, `amount1` |
| `launches:`, `dev:` | `address`, `launchpad`, `creator`, `curve`, `quote` (the quote token’s symbol, e.g. `ETH`), `tx` |
| `graduations:` | `token`, `launchpad`, `pool`, `position_id` |
| `pools:new` | `pool`, `dex`, `token0`, `token1`, `fee`, `tick_spacing`, `hook` (`null`, or `address` and `permissions`, e.g. `beforeSwap`) |

A stream trade is a compact version of a trade tape row, with the same `id`. It doesn’t carry `tx`, `tx_index`, `executor`, `via`, `fees`, `finality`, `quote`, `quote_symbol`, `value_eth`, `price_eth`, `eth_usd_at_trade`, `market_cap_usd_at_trade` or the `trader_label` / `signer_label` / `trader_app` labels: fetch the trade tape for those.

Notes on timing:

-   `price:` and `token:` are only sent when the token trades, and at most once per 100 ms per token.
-   `candles:` channels have no snapshot: the first message comes with the token’s next trade, and a bucket without trades sends nothing. A new candle opens at the previous close, and a small trade far from the token’s current price counts in `v` and `n` without moving `h`, `l` or `c`.
-   `tax:` fires when the realized tax on one side, rounded to a whole percent, differs from that side’s previous trade.

## Heartbeats, finality and reorgs

-   `{"ch": "heartbeat", "ts": …, "uptime_s": …}` arrives every 15 seconds (`uptime_s` is how long this connection has been open); reconnect if you hear nothing for 45 seconds.
-   `{"ch": "finality", "seq": …, "block": 0, "ts": …, "data": {"safe_block": …, "finalized_block": …}}` arrives on every connection whenever the safe or finalized block moves.
-   If blocks are replaced, a `{"ch": "reorg", "data": {"common_ancestor", "removed_blocks", "depth"}}` message comes first (`removed_blocks` lists the replaced blocks that had messages, `depth` how many there are), then every affected event is re-sent on its channel with `"removed": true` added to its `data` (same `id`, a new `seq`), then the replacement events as their blocks are decoded.
-   With `"finality": "safe"`, events are held until the `finality` message that makes their block safe, then delivered in order. The `removed` re-sends of a reorg are held the same way, so keep handling `removed: true`.

A complete reconnecting client is in the [real-time stream example](https://docs.crius.sh/examples/stream/).
