Live stream (WebSocket)
One authenticated WebSocket that pushes closed Hyperliquid OHLCV bars as they complete. Lives on vike.io, not on the data.vike.io base URL, and appears in no OpenAPI spec.
One endpoint: a credential-gated WebSocket that pushes closed OHLCV bars for the
subscriptions you hold. Those bars are Hyperliquid's, read from HL's own fill stream, and
they are the only series this endpoint carries. It runs in the vike.io application, not the
one that serves every /v1 namespace on data.vike.io.
It is spelled /v1/stream but it is not on the /v1 base URL
The REST base URL is https://data.vike.io. This endpoint is wss://vike.io/v1/stream.
A client that follows the documented base and connects to wss://data.vike.io/v1/stream
cannot reach it: that host's /v1/ prefix is served by an application with no such route.
The path segment is the same word; the host is not.
This is a known inconsistency, and it now costs you two hosts rather than one: the REST
endpoint you backfill a gap from — /v1/hyperliquid/ohlcv,
the same series this stream polls — is on data.vike.io. Moving the stream onto
data.vike.io is worth doing and has not been done; until it is, point the socket at vike.io
and the backfill at data.vike.io.
This page describes behaviour, not captured traffic
This endpoint is invisible to the OpenAPI spec — OpenAPI has no WebSocket operation to emit — and it cannot be confirmed by probing (see Why you cannot probe it). Everything below describes how the running service behaves, including the refused intervals, the four-field subscription key and the closed-window boundary, all of which are covered by tests. No frame on this page was captured from the live endpoint, and where the behaviour is not settled, this page says so rather than guessing.
Endpoint
wss://vike.io/v1/stream
WEBSOCKET wss://vike.io/v1/streamSubscribe to one or more Hyperliquid series and receive a bar frame each time a candle
closes. Use it when you want to be told a bar has completed without polling
GET https://data.vike.io/v1/hyperliquid/ohlcv on a timer; use REST for history, for warm-up,
and for backfilling a gap.
The handshake takes one optional query parameter. Everything else is negotiated over the socket after it opens.
tokenqueryoptionalX-API-KEY header is present, or when an Authorization bearer validates. An Authorization header that does not validate — malformed, revoked, expired, or not a bearer at all — falls through, and the token is read and honoured. See Authenticating.The edge allows the WebSocket upgrade and drops a connection that goes 300 seconds without data. The server's ~15-second heartbeat keeps traffic well inside that window.
What it streams: Hyperliquid, from our own node
The feed is driven by the same engine behind
/v1/hyperliquid/ohlcv, reading one-minute and one-hour
rollups built from Hyperliquid fills captured by a node Vike runs itself. That is the whole
source: one venue, our own node, and the same series the REST endpoint serves. The one-minute
rollup trails wall clock by 18 seconds, measured 2026-09-03, which is the lag the
15-second poll cadence is sized for.
Exchange-pair symbols are not on this stream, and they fail silently
BTCUSDT, ETHUSDT, a _PERP suffix, anything shaped like an exchange pair: a subscribe
carrying one is accepted — nothing validates the symbol against HL's asset list — and then
streams nothing, forever, because Hyperliquid has no such asset. You cannot tell that apart
from a quiet market.
There is no second series behind this endpoint to fall back to. Subscribe with the HL asset —
a perp ticker (BTC), a spot token (HYPE) or a raw spot index (@107) — and resolve it over
REST first if you are not sure it trades.
Which markets and DEXs are reachable
An HL bar is not identified by symbol alone, so the subscription carries two more fields:
| Field | Values | Default |
|---|---|---|
market | perp or spot. Lower-cased before use, so "PERP" is accepted. | perp |
dex | The core venue HL, or a HIP-3 builder-deployed perp DEX. 1–16 alphanumeric characters, upper-cased before use, so "hl" is the default. | HL |
Symbols follow the REST endpoint: a perp ticker (BTC), a spot token (HYPE) or a raw spot
index (@107). Whatever you send is stripped and upper-cased before use.
Do not hard-code the dex list
Builder DEXs deploy permissionlessly. The live set is read from the data and cached for 10
minutes, falling back to a fixed nine-name list only when that read fails — and an earlier
three-hour sample of that fallback saw only five of the nine actually trading. Subscribe with
the dex you want and read the error message, which names the known set. Note also that spot
lives on the core venue only (HL is perp and spot; the rest are perp DEXs), so
market: "spot" with a builder dex is a well-formed subscription that will never produce a
bar.
What changed for a client, and what did not
The wire protocol is unchanged: subscribe/unsubscribe/pong, the bar frame shape, the
monotonic seq, the closed flag, the ~15-second ping, the bounded outbound queue and the
seq-gap-then-backfill recovery path are all exactly as they were. A client written against the
published spec still connects, still subscribes with {symbol, interval}, and still parses
every frame. Since 2026-09-03 the data behind those frames is Hyperliquid's, so a client
written before that date sees three differences:
- Symbols are HL assets (see the callout above). The venue is pinned by construction — it
is Hyperliquid — and
market/dexselect within it. - Six intervals are gone.
3m,2h,6h,8h,12hand1ware not in HL's set. A subscription to one is now an error frame, not silence. fundingis still alwaysnull, for a new reason: HL candle rows carryvolume_usdandn_trades, never a funding rate.
Authenticating the handshake
The handshake tries three credential sources in this order and stops at the first that produces an identity:
| Order | Source | Form |
|---|---|---|
| 1 | Authorization header | Bearer vk_oat_... — an OAuth 2.1 access token. Requires the vk_oat_ prefix; revoked and expired tokens are rejected. |
| 2 | X-API-KEY header | vk_... — matched against an active API key. |
| 3 | token query parameter | The same API key, in the URL: wss://vike.io/v1/stream?token=vk_... |
Both header forms reach the same account — the same rule as the REST surface, see Authentication.
The query parameter exists because browsers force it
The browser WebSocket constructor cannot set request headers, so ?token= is the only
way a page can authenticate. It puts a live credential in a URL. Prefer a header from any
client that can send one, and treat a key used this way as more exposed than one that is
not.
What a refusal looks like
If no source yields an identity the connection is closed with code 4401, and it is closed before the handshake completes. Most clients surface that as an HTTP 403 on the upgrade rather than as close code 4401.
403 does not tell you what was wrong
The server answers 403 identically for a connection refused before the handshake completes and
for a WebSocket path that matches no route. A 403 therefore cannot distinguish your key was
refused from your URL was wrong — /v1/stream, /v1/stream-bogus and /v1/nope all
look the same from outside. Check the URL by hand before concluding the credential failed.
Subscribing
After the socket opens, send JSON text frames. Exactly three values of action are
accepted:
| Field | Required | Notes |
|---|---|---|
action | Yes | subscribe, unsubscribe or pong. Anything else is an error frame. |
symbol | For subscribe/unsubscribe | HL perp ticker (BTC), spot token (HYPE) or raw index (@107). Stripped and upper-cased by the server before use. |
interval | For subscribe/unsubscribe | One of the seven below. |
market | No | perp or spot. Default perp. |
dex | No | HL or a builder DEX. Default HL. |
channel | No | Accepted and never read — nothing on the server inspects it, so "channel": "bars" is decoration. Bars are the only thing this endpoint streams. |
{"action": "subscribe", "channel": "bars", "symbol": "BTC", "interval": "1m"}
{"action": "subscribe", "channel": "bars", "symbol": "HYPE", "interval": "5m", "market": "spot"}
{"action": "subscribe", "channel": "bars", "symbol": "BTC", "interval": "1m", "dex": "XYZ"}
{"action": "unsubscribe", "channel": "bars", "symbol": "BTC", "interval": "1m"}
{"action": "pong"}Intervals are the HL engine's time intervals:
1m 5m 15m 30m 1h 4h 1dTick intervals are refused, on purpose
The REST endpoint accepts Nt bars (15t = 15 trades per bar). This one does not, for two
independent reasons, either sufficient:
- A tick bar has no wall-clock bucket, so which bars have closed — the only question this poller is built to answer — has no tick analogue.
- Tick bars are not stable across polls. Trades are counted off from the start of the
requested window, so the bar boundaries are anchored to that window. The poller advances
its window every cycle, which re-partitions the same trades into different bars: the same
tswould come back with different OHLC, and a monotonicseqover contradictory bars is worse than no bars at all.
A tick stream needs trade arrival to drive it, not a wider validator. Ask for tick bars over REST.
The subscription key
A subscription is (symbol, interval, market, dex) — all four fields, and everything the
server does with a subscription (registering it, attaching, detaching, tearing it down) keys on
all four. Consequences:
BTC/1mperp andBTC/1mspot are two independent subscriptions with two independent feeds and two independentseqcounters. They never collide, and neither do two builder DEXs.- Unsubscribe with the same four fields you subscribed with. The key is re-derived from the
unsubscribemessage, so unsubscribing{symbol, interval}does not cancel aspotsubscription — the keys differ, and the call is a silent no-op. - Subscribing to a key you already hold on that connection is a no-op; so is unsubscribing from one you do not hold.
A successful subscribe is answered with silence
There is no subscribed acknowledgement frame. The server replies to a valid subscribe
with nothing at all, and the next thing you receive is either a heartbeat ping or the
first bar — which for a 1d subscription is up to a day away. Do not wait for an ack;
treat the absence of an error frame as acceptance.
The error frame
Every client-side rejection uses the same shape and, today, the same code:
{"type": "error", "code": "bad_subscription", "message": "unknown action 'frobnicate'"}message is the only field that varies, and it now diagnoses rather than gestures — each fault
has its own message, checked in this order:
| What you got wrong | message |
|---|---|
No symbol, or a blank one | symbol is required |
| An interval outside the seven | unknown interval '12h'; allowed: 1m, 5m, 15m, 30m, 1h, 4h, 1d |
A market that is neither | market must be one of perp, spot; got 'futures' |
A malformed dex | dex must be 1-16 alphanumeric characters; got 'not a dex' |
A well-formed dex that is not deployed | unknown dex 'ZZZ'; known: ... — the live set, named |
Malformed JSON, a non-object payload and an unknown action are caught earlier still, before
the subscription is built, and arrive with the same code. The socket stays open in every
case; a rejected message is discarded and the connection continues.
Only the last row costs a round trip, and it is the one that can be skipped
The format checks are free. The membership check — is this dex actually deployed — needs a
database round trip, and it runs only when dex is not HL: the core venue is always
known and can never be the unknown one, so an ordinary subscription never pays for it. That
check is also deliberately permissive: if the lookup itself fails, the subscription proceeds.
It is there to catch typos, not to be a gate.
Server frames
Two frame types reach the client in normal operation — bar and ping — plus the error
frame above.
bar
The frame shape is fixed, and a test holds it that way:
| Field | Meaning |
|---|---|
type | Always "bar". |
symbol | The symbol as you subscribed it, stripped and upper-cased. Not the resolved HL asset — a spot subscription for HYPE reports HYPE, not @107. |
interval | The subscribed interval. |
ts | Candle open time, epoch milliseconds, UTC, left-aligned to the interval — the same convention as the REST candle. A bar covers [ts, ts+interval). |
open, high, low, close | The candle. |
volume | Base-asset, taker-side volume. Hyperliquid reports two rows per trade, one per side; the taker side is the figure venues publish, and counting both legs doubles it (24h BTC measured at $3.34B unfiltered against a per-trade-exact $1.67B, 2026-08-18). |
funding | Always null, see below. |
closed | true. Always, today — see below. |
market | "spot". Present only when the subscription is not the default perp. |
dex | The builder DEX name. Present only when the subscription is not the default HL. |
seq | Monotonic counter, per subscription key, incremented once per emitted frame. See Sequence numbers and gaps. |
The shape — the numbers are illustrative, and BTCUSDT here is a placeholder the server never
inspects, not a symbol that would return bars from HL:
{
"type": "bar", "symbol": "BTCUSDT", "interval": "1m",
"ts": 1738411260000, "open": 1.0, "high": 2.0, "low": 0.5,
"close": 1.5, "volume": 10.0, "funding": null, "closed": true, "seq": 5
}market and dex appear only when they are not the default
A subscription is keyed by four fields, but a default (perp/HL) frame names two — so it stays
byte-identical to the published spec and an existing client is unaffected. The cost is real and
worth planning for: two subscriptions on one connection differing only by market or dex
produce frames you cannot tell apart unless the non-default one is present, and seq cannot
demultiplex them either — it is per-subscription, and both start at 1. If you hold BTC-perp and
BTC-spot on one socket, route on the presence of the market key; do not route on symbol
alone.
funding is always null, and closed is always true
An HL candle carries ts, open, high, low, close, volume, volume_usd, n_trades and no funding
rate, so funding is present and null on every frame. The two extra HL columns are not in
the frame at all: volume_usd and n_trades exist on the REST response and are dropped here,
because adding fields would change a published shape. Fetch them over
REST if you need them.
closed is likewise fixed: every frame is built with closed: true, and the poll only ever
asks for a window ending at the last completed bucket. Both fields exist for a
live-forming-bar upgrade — a live trade feed driving the same wire protocol and emitting
closed: false frames — that is not shipped, so do not branch on either field expecting
variation today.
ping
{"type": "ping"}Heartbeat
The server sends {"type": "ping"} every 15 seconds. The client must answer with a pong
message, which resets the miss counter to zero.
Reply with a JSON pong, not a protocol pong
This is an application-level heartbeat carried in a JSON text frame. A WebSocket
protocol-level pong frame — what most libraries send automatically in response to a
protocol ping — never reaches the application and does not reset the counter. You must
send the literal pong action as a text message.
The counter is incremented immediately before each ping is sent, and the connection is closed with code 1011 once it exceeds 3 missed pongs. A client that never ponged is therefore dropped on the fourth heartbeat tick, roughly 60 seconds after the socket opened.
Note also that the server reads text frames only. Binary messages are not part of this protocol.
Sequence numbers and gaps
seq is what tells you a frame went missing, and its scope is the part that surprises
people: it belongs to the (symbol, interval, market, dex) feed, not to your connection.
There is one shared feed per active key on the server, with one poller serving every subscriber. Its counter starts at 0 when the feed is created — that is, when the first client anywhere subscribes to that key — and the feed is torn down when its last subscriber leaves.
The consequences worth planning for:
- If you subscribe to a key somebody else is already watching, your first frame's
seqis not 1. It is wherever that feed had got to. - If you are the only subscriber and you unsubscribe and resubscribe, the feed is destroyed
and rebuilt, and
seqrestarts at 0. Aseqyou saw before is not comparable with aseqyou see after. seqdoes not survive a server restart, and it is per-process.
Use seq for what it is designed for — detecting a gap within one continuous
subscription — and not as a durable cursor.
Backpressure
Each connection has a bounded outbound queue of 1000 frames. When a slow consumer's queue
is full the server counts the drop and moves on: the feed never blocks on one slow client. You
observe the loss as a jump in seq, and the documented recovery is to backfill the missing
window over REST — GET https://data.vike.io/v1/hyperliquid/ohlcv with the same symbol,
interval, market and dex, over the ts range you missed
(Hyperliquid OHLCV). That is the same series the feed
polls, so the backfilled bars are the same bars.
It drops the newest frame, not the oldest
A full queue is not drained to make room: the server discards the frame it could not enqueue.
Either way you see a seq gap, but the bars you lose are
the most recent ones, and the queue you are draining is stale. If your client falls
1000 frames behind, backfill from the last seq you received rather than assuming what you
are still reading is current.
What closes the connection
| Cause | Code | Notes |
|---|---|---|
| No valid credential | 4401 | Sent before the handshake completes, so it fails; clients usually see HTTP 403 instead. |
Four heartbeat ticks with no pong | 1011 | ~60 s after the last pong. |
| The client disconnects | — | Handled quietly. |
| Any unhandled server-side error on the connection | default | The socket is closed. |
On every one of these, all of that connection's subscriptions are detached, which tears down any feed left with no subscribers.
Freshness, and what the bars actually are
Each feed polls the HL series once every 15 seconds and broadcasts whatever closed bars are new since its last poll. The one-minute rollup behind it trails wall clock by 18 seconds, measured 2026-09-03.
These are closed bars only. There is no live forming candle, and the 18 seconds is not delivery latency at the bucket boundary — it is how long after the boundary the bar becomes readable at all. Stack the poll cadence on top and a closed bar reaches you somewhere in the region of 18 to 33 seconds after it closed, depending on where the boundary falls between two polls. That range is arithmetic over two constants, not an end-to-end measurement; nobody has timed a frame on the wire (see Known gaps).
Two mechanics behind that, both visible from outside:
- The poller floors its own window. It asks only for buckets that have already closed,
explicitly ending the window at the open of the forming bucket — otherwise it would ship a
partial bar stamped
closed: truethat nothing would ever correct. So a subscription never emits the bar that was forming when you subscribed until it closes, and never emits a partial bar at all. - A fresh subscription is quiet until the next boundary. A new feed starts just before the
forming bucket's open, so its closed window is empty and it queries nothing until a bucket
closes. On
1mbars at 15-second polls that is up to three silent cycles; on1dit is up to a day. Warm-up history comes from REST, not from the stream.
Every data-plane fault on this endpoint looks like silence
A subscribe is validated for shape, not for existence. Everything past that point degrades to nothing-arrives:
- A symbol HL does not trade (an exchange pair, a typo) returns an empty candle list, forever.
- A spot symbol the venue does not list makes the poll itself fail; the server retries next cycle and the client is told nothing.
- A well-formed but undeployed
dexis normally caught at subscribe time, but that check is permissive when the database is unreachable, so it can reach the poller and land in the same place.
No error frame is emitted from the feed — only from subscribe/unsubscribe handling. Emitting
one is a deliberate non-change, not an oversight. Resolve a symbol over
REST before you subscribe to it, or you cannot tell a
typo from a quiet market.
Load, and what a connection costs
Fan-out is per subscription key, not per connection: 1,000 clients watching BTC/1m on the
core perp produce one engine query per 15-second cycle, delivered 1,000 ways. Cost scales with
the number of distinct keys subscribed across the whole process, not with the number of
connections.
A spot subscription costs an extra query per poll
A market: "spot" poll resolves the spot-token map on every call, to turn HYPE into @107.
That is one additional query per feed per 15-second cycle, for as long as the subscription is
held. It is pre-existing behaviour of the underlying query rather than something the stream
added, but the stream is what turns it into a standing cost. The dex list is not in that class
— it caches for 10 minutes.
On metering: today this endpoint runs no rate-limit check and writes no usage record — authenticating the handshake is the entire gate, and there is no second gate at the edge. That is current behaviour, not a published allowance, so do not design around a stream being free. The hourly budget and its documented home are on Rate limits.
Why you cannot probe it
This endpoint cannot be confirmed or refuted by curling it, which is a large part of why it went undocumented for so long:
- The server answers 403 identically for an unmatched WebSocket path and for a connection
refused before the handshake completes.
/v1/streamand/v1/nopeare indistinguishable from outside. - The edge behaves differently by protocol. Over HTTP/2,
Connection: Upgradeis rejected outright — measured as a 400. Over--http1.1the request reaches the application and returns 403. A probe run over h2 will look like proof the endpoint does not exist.
The only positive confirmation is a completed connection: open the socket over HTTP/1.1 with a valid credential, subscribe to a symbol you know trades on HL, and wait for a bar.
Known gaps in this page
- No observed traffic. No bar on this page was captured from the live endpoint, and the 18-to-33-second delivery window above is arithmetic over the measured data lag and the poll cadence, not a timing anyone has taken on the wire.
- Multi-worker behaviour is not specified. A feed is per-process, and single-process
operation is what is assumed today. What
seqwould do across several processes is not settled, so this page does not claim it.
/archive/{partition path}
One path prefix covering the whole archive — two Hive partitionings of the same data behind one path, not two endpoints.
Schema and spec
The live OpenAPI document, Swagger UI and ReDoc — where they live, what the spec actually declares, and the three gaps that decide whether you can generate a working client from it.