Event stream close codes, errors and resuming
The event stream at wss://stream.vike.io/v1/stream/hl puts a cursor on every event. This page gives every close code and error frame, what a client does about each, and how it resumes without losing or repeating an event.
The event stream pushes Hyperliquid events as they happen, one event frame each, over one WebSocket:
wss://stream.vike.io/v1/stream/hl. Every frame carries a cursor. A client that keeps the cursor of the last
event it handled can reconnect after any disconnect, subscribe with that cursor, and receive what it missed and then
the live events. Nothing is skipped, and a repeat, if one comes, is one you can drop by its id.
This page is about how a connection ends and how to pick it up again: every close code, every error frame and the
resume. It is not the candle stream, Live stream at wss://data.vike.io/v1/stream, which
pushes closed bars, has no cursor and has its own rules. The credential is the same on both: an API key in the
X-API-KEY header, an OAuth bearer, or ?token= for a browser (see
Authenticating the handshake).
The event stream moved to stream.vike.io
The address is wss://stream.vike.io/v1/stream/hl, on its own host, not on data.vike.io. The old address,
wss://data.vike.io/v1/stream/hl, no longer connects: it answers HTTP 410 with
{"error":{"code":"STREAM_MOVED","message":"the stream moved to wss://stream.vike.io/v1/stream/hl"}}. Use the new
address with the same key. The candle stream stays on data.vike.io.
Connecting, subscribing and reconnecting are not counted
A connection, a subscribe and a reconnect on this stream do not count against your hourly request ceiling and use no credits. The stream never answers 429 and never closes with 4429. A client that was cut can reconnect as often as it needs to, except after 4410 (see One connection per key).
The rule: reconnect at once, with your cursor
Whenever a connection ends, open a new one at once and send a subscribe for each subscription you held, with the
cursor of the last event you handled on it. The server replays what you missed, then continues live. It holds nothing
against a client that connects again straight away: there is no waiting list, no penalty and no count. Two ends are
the exception, because a reconnect does not help them: a refused credential (401) and a connection replaced by a
newer one (4410). After a 4410 do not reconnect automatically; see
One connection per key.
Only three closes put a cursor in a frame (4408, 4409 and 4410). Every other end gives you no frame, so keep your own cursor for each subscription as you go, as Resuming shows.
One connection per key
A key holds one stream connection at a time, and an account holds at most 3 at a time, across all its keys and
OAuth apps. The newest wins. A new connection is always accepted: it is never refused because the key is already
connected or the account already has three. The older one is closed with 4410 (replaced), after a last error
frame: the key's previous connection, or, when the account already had three, its oldest. For an OAuth bearer the
"key" is the user plus the OAuth app. This is so that an old connection that only looks alive, after a network drop or
a laptop that slept, can never lock you out of a new one. In rare cases the older connection is not closed; the newer
one is still never refused.
Two programs on the same key replace each other without end, because each reconnect is the newest. So do four programs on one account. This is why a client must not reconnect automatically after a 4410: the other program would then be the one replaced, and the two would take the connection from each other forever.
Use one key per program, and at most 3 programs per account. An account holds up to 3 active keys; see Authentication.
The setup we recommend: one key per program, each with its own connection.
| Key | Program | Its connection |
|---|---|---|
test | your test client | Its own. |
prod | your production service | Its own. |
backup | a standby server | Its own. It receives the same events as production; on both sides, drop a repeat by event.event_id. |
The frames
You send JSON text frames only, at most 64 KiB each. A binary frame is answered with a bad_subscription error.
A larger message closes the connection with the standard code 1009 (message too big).
action | Fields | What it does |
|---|---|---|
subscribe | id, channel, filter, cursor | Starts a subscription. id is yours: 1 to 32 characters from A-Z a-z 0-9 _ -, unique on the connection. channel is a channel name such as hl.fills. filter is an optional object; the keys depend on the channel, and a key the channel does not take is a bad_subscription error. cursor is optional: leave it out to start from now, send one to resume. |
unsubscribe | id | Ends one subscription. |
pong | none | Answers a ping. |
A connection holds at most 100 subscriptions. A single subscription can still watch many things, because a filter
key that takes a list matches any value in it, and keys combine with AND. wallet takes up to 1,000 addresses
and coin up to 200 names; the other list keys (the cohorts, action, index) take up to 50 values. An empty list
is refused. So one subscription can follow a thousand wallets, and the 100 counts subscriptions, not wallets.
The server sends four kinds of frame.
{"type": "subscribed", "id": "whales", "channel": "hl.fills", "cursor": "1793178870512-0003", "replay": {"from": "none", "events": 0}}{
"type": "event",
"ids": ["whales"],
"channel": "hl.fills",
"cursor": "1793178871012-0004",
"event": {
"schema": "vike.event.v1",
"event_id": "hl.fills:0x1111111111111111111111111111111111111111:1793178870482:123456789",
"source": "hl.fills",
"kind": "fill",
"ts": "2026-10-28T09:14:30.482Z",
"cursor": "1793178870482-00000000000123456789",
"chain": "hyperliquid",
"subject": {"wallet": "0x1111111111111111111111111111111111111111", "symbol": "BTC"},
"data": {"action": "open", "side": "buy", "size_usd": 1250000.0, "price": 100000.0},
"labels": {"pnl_cohort": "2x smart", "size_cohort": "3x whale"}
}
}The two frames above show the shape. Their values are made up, and data and labels are trimmed: they are not a
capture. The event object is the same event the alert feed describes.
idslists every one of your subscriptions on this connection that the event matched. The event is sent once, however many matched.- The frame's
cursoris the one to keep. It is the stream's own position and the only cursor you may send back. Thecursorinsideeventis a different cursor, of the event's source: never send that one. event.event_idis unique and the same every time the same event arrives. Use it to drop a repeat.ping,{"type": "ping"}, arrives every 15 seconds. Answer with the JSON text frame{"action": "pong"}; a WebSocket protocol-level pong does not count. Without apongfor three pings the connection is closed with 1011, about 60 seconds after your last one.errorframes are listed under Error frames.
Compression
A client that offers permessage-deflate in its handshake gets compressed frames; one that offers nothing gets plain
frames, never a refusal. Most WebSocket libraries offer it by default and inflate for you, so frames, cursors and ids
look the same to your code. The stream answers permessage-deflate; server_no_context_takeover; client_no_context_takeover,
so each message inflates on its own.
Resuming
- Keep one cursor per subscription. After each event you handle, store the frame's
cursorunder every id inids. A new subscription'ssubscribedframe carries a starting cursor too: for a subscribe without a cursor it is the channel's current position, so a client that has not seen an event yet can still resume. - After any disconnect, subscribe again with the same
id,channelandfilter, and the storedcursor. - Read the answer.
subscribedrepeats your cursor and says how the replay comes in, inreplay:fromisnonefor a subscribe without a cursor (nothing is replayed);bufferwhen the events you missed are recent (andeventsis how many follow);storewhen they are older (they follow in pages andeventsisnull). - The missed events arrive oldest first, then the live ones. The hand-over is one step: an event is either in the replay or live, never in both and never in neither.
Delivery is at least once, so drop any event whose event.event_id you already handled. A repeat is expected when
you resume from the cursor in a 4408, 4409 or 4410 frame: events that were being sent when the connection was cut can
arrive again. Resuming from your own stored cursor, the cursor of an event you handled, does not repeat events you handled.
import json
cursors = {} # subscription id -> cursor of the last event handled on it
handled = set() # event ids already handled (keep the last few thousand)
def on_message(text, send):
frame = json.loads(text)
if frame["type"] == "event":
if frame["event"]["event_id"] not in handled:
handled.add(frame["event"]["event_id"])
handle(frame["event"]) # your code
for sub_id in frame["ids"]:
cursors[sub_id] = max(cursors.get(sub_id, ""), frame["cursor"])
elif frame["type"] == "subscribed":
cursors.setdefault(frame["id"], frame["cursor"])
elif frame["type"] == "ping":
send(json.dumps({"action": "pong"}))
def subscribe_all(send, subscriptions):
for sub in subscriptions: # {"id": ..., "channel": ..., "filter": ...}
message = {"action": "subscribe", **sub}
if sub["id"] in cursors:
message["cursor"] = cursors[sub["id"]]
send(json.dumps(message))Cursors are fixed-width strings, so a later cursor compares greater than an earlier one as plain text. Treat them as opaque otherwise: take one from a frame and send it back unchanged.
Keep a cursor per subscription, not one per connection
The events of one subscription arrive in cursor order. Across two subscriptions on the same connection they do not: the replay of one can arrive after live events of another. A single cursor for the whole connection can therefore skip events. Compare cursors only within one subscription.
How far back a resume reaches:
- A cursor older than 3 days is refused with
cursor_expired. Subscribe without a cursor to continue from now. The stream keeps three days; read the older part from the Hyperliquid REST endpoints. - A cursor newer than anything the stream has issued is refused with
cursor_ahead. - A replay larger than the socket will carry in one go is refused with
replay_too_large; see Error frames.
Close codes
The first seven rows are closes of a connection that was open. The last three are numbers that a connection refused before it opened carries in the body of an HTTP answer, never as a close: see Refused at the handshake.
| Code | Name | Why | What the client does |
|---|---|---|---|
4408 | slow_consumer | The client accepted no frame for 30 seconds while frames were waiting for it. | Reconnect at once and subscribe with the cursor in the frame sent just before the close. |
4409 | server_catching_up | Not the client's doing. It was reading, and the stream got further ahead than the connection's queue holds. | The same: reconnect at once and subscribe with the frame's cursor. |
4410 | replaced | A newer connection took over: another connection with the same key, or a fourth connection of the account. Not a fault of yours or of the stream. | Do not reconnect automatically. If the newer connection is your own program's, carry on there with the cursors in the frame. Otherwise find the other program: one key per program, at most 3 programs per account. |
1011 | none | No pong for three pings, or an error on the server side of the connection. | Reconnect at once. No frame: use your own cursors. |
1012 | none | The service is restarting, or a channel's feed had to restart. | Reconnect at once. No frame: use your own cursors. |
1009 | none | A message you sent was over 64 KiB. | Send smaller messages (an ordinary subscribe is well under it), reconnect, resume. |
1001, 1006, none | none | The server went away, or the connection dropped with no close frame. | Reconnect at once. Use your own cursors. |
4401 | unauthorized | No valid credential. Answered as HTTP 401. | Fix the credential. |
4503 | capacity | The server process that took the connection is at its connection limit. Answered as HTTP 503. | Send the same upgrade request again. |
1013 | unavailable | The credential could not be checked just now. Answered as HTTP 503. | Send the same upgrade request again. |
Three of the closes carry a frame. Each is an error frame sent just before the close. It names one channel and gives
a cursor to resume from on it.
The close itself also carries a reason for these three, the same word as the code of that error frame:
slow_consumer on 4408, server_catching_up on 4409 and replaced on 4410. A client that reports only the close code
and reason therefore still says why the connection ended. The other closes have no reason.
4408: slow_consumer
The connection stopped reading. Frames were waiting and, for 30 seconds, the client took none of them. A client that keeps reading, however slowly, is not closed with 4408 (if the stream gets too far ahead of it, the close is 4409). A client that sends invalid frames and never reads is closed the same way, and so is one that keeps asking for replays it does not read.
{
"type": "error",
"code": "slow_consumer",
"message": "the connection stopped reading; resume from the cursor (via REST if the replay is refused)",
"channel": "hl.fills",
"cursor": "1793178870512-0003",
"rest": "<a path>"
}{"action": "subscribe", "id": "whales", "channel": "hl.fills", "filter": {"coin": ["BTC", "ETH"]}, "cursor": "1793178870512-0003"}The cursor is the position just before the oldest event the server had not yet got to you, so a few events that were
being sent can arrive twice: drop them by event_id. If the connection held subscriptions on several channels, the
frame names only one; resume the others from your own cursors. rest is a path and may be absent; you do not need
it to resume. In the one case where the client kept asking for
replays it did not read, the frame has no cursor and no channel, and its message starts replay backlog full.
Use your own cursors then.
4409: server_catching_up
This one is not the client's fault. The connection was reading. A burst of events, or a large catch-up, put more frames in front of it than its queue holds.
{
"type": "error",
"code": "server_catching_up",
"message": "the stream got further ahead of this connection than its queue holds; resume from the cursor",
"channel": "hl.fills",
"cursor": "1793178870512-0003",
"rest": "<a path>"
}The client's step is the same as for 4408: reconnect at once and subscribe with cursor. Nothing is lost.
4410: replaced
A newer connection took over: another connection with the same key, or the account's fourth. The old connection gets
one last error frame and then the close. Besides channel, cursor and rest (a path, may be absent) for the
channel it was furthest behind on, the frame has cursors: a cursor to resume from for each channel the connection was
subscribed to. Use a channel's cursor for every subscription you held on that channel, or your own per-subscription
cursors. Repeats are possible with the first; drop them by event_id.
{
"type": "error",
"code": "replaced",
"message": "a newer connection of this API key or account took over; resume there from the cursors",
"channel": "hl.fills",
"cursor": "1793178870512-0003",
"rest": "<a path>",
"cursors": {"hl.fills": "1793178870512-0003"}
}Do not reconnect automatically after a 4410. If the newer connection is your own program's, which opened a second connection before the first one was seen to be gone, carry on there; if it has not resumed yet, subscribe on it with these cursors (a subscription id the connection already holds is refused as a duplicate). If it is another program on the same key, or a fourth on the account, a reconnect takes the connection back and closes theirs, and they do the same to you. Find the other program and give each its own key: one key per program, at most 3 programs per account.
{"action": "subscribe", "id": "whales", "channel": "hl.fills", "filter": {"coin": ["BTC", "ETH"]}, "cursor": "1793178870512-0003"}1012: restart, with no frame
The service closes every connection with 1012 when it restarts. It sends no frame, so the cursor comes from the client's own store:
{"action": "subscribe", "id": "whales", "channel": "hl.fills", "filter": {"coin": ["BTC", "ETH"]}, "cursor": "1793178871012-0004"}Refused at the handshake
A connection refused before it opens is an HTTP answer to the upgrade request, not a WebSocket close. Most libraries
show it as a failed handshake with the status. The body is JSON, except for Cloudflare's 521 and 522; its
close_code names the number the same refusal would carry as a close, which you will not see as one.
| Status | code | close_code | Meaning | What the client does |
|---|---|---|---|---|
401 | unauthorized | 4401 | No valid credential. | Fix the credential; a retry without one fails the same way. |
503 | capacity | 4503 | The server process that took the connection is at its connection limit. | Send the same upgrade request again. |
503 | unavailable | 1013 | The credential could not be checked just now. | Send the same upgrade request again. |
521 or 522 | none: a Cloudflare HTML page | none | The stream is stopped or restarting and refused the connection (521), or did not answer in time (522). | Send the same upgrade request again, then resume. |
410 | STREAM_MOVED | none | You used the old address on data.vike.io. | Use wss://stream.vike.io/v1/stream/hl. |
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
{"type":"error","code":"capacity","message":"the stream server is at its connection limit","close_code":4503}The connection never opened, so there is nothing to resume: send the same request again, then subscribe with your
cursors. A capacity answer is about one server process; the next connection is taken by whichever has room. The
stream does not delay, queue or count a connection you open again at once.
When the stream is stopped or restarting, nothing answers for it. Cloudflare, which sits in front of the stream, returns
its own HTML error page: 521 when the connection is refused, 522 when it times out. There is no JSON body to read;
the status is the signal. Send the same upgrade request again, then resume with your cursors.
The old address answers with a JSON body that names the new one:
{"error":{"code":"STREAM_MOVED","message":"the stream moved to wss://stream.vike.io/v1/stream/hl"}}Error frames
An error frame that is not followed by a close leaves the connection open: a rejected message is discarded and
the rest continue. Every error frame has type (error), code and message, and an id when it is about one of
your subscribe messages.
code | When | message and extra fields | What the client does |
|---|---|---|---|
bad_subscription | The message or its filter is not valid. | Names the fault, for example invalid JSON, unknown action 'x', id must be 1-32 characters of A-Z a-z 0-9 _ -, duplicate subscription id 'x', no such subscription (for an unsubscribe), filter key 'x' is not supported on hl.fills, filter.min_usd: must be a number, filter.wallet: takes at most 1000 values, or cursor is not a stream cursor (take one from a subscribed or event frame). | Fix the message. Nothing was subscribed. |
unknown_channel | The channel is not one this endpoint serves. | unknown channel 'x'; available: [...], the list being the channels it serves. | Use a channel from the list. |
rate_limited | The connection already holds 100 subscriptions. | at most 100 subscriptions per connection. | Unsubscribe from one, or put several wallets or coins into one subscription's filter. |
cursor_expired | The cursor is older than 3 days. | cursor is older than 3 days; subscribe without a cursor and backfill via REST. | Subscribe without a cursor to continue from now. The stream keeps three days: read the older part from the Hyperliquid REST endpoints. |
cursor_ahead | The cursor is newer than the stream. | cursor is newer than the stream; use one from a subscribed or event frame. | Send a cursor you took from a frame. |
replay_too_large | The replay you asked for is too big to send over the socket. | the replay is over <n> buffered or <m> stored events; catch up via REST from the cursor, plus cursor and rest (a path, may be absent). The subscription was not started, or was stopped part-way. | Subscribe again from the frame's cursor. If it is refused again, narrow the filter: the size of a replay is the number of events your filter matches. |
catchup_unavailable | The server could not read your replay. | catch-up is unavailable right now; subscribe again, plus cursor and rest (a path, may be absent). The subscription was stopped. | Subscribe again at once with the frame's cursor. |
slow_consumer, server_catching_up, replaced | A close follows. | See Close codes. | See Close codes. |
For replay_too_large and catchup_unavailable, cursor is the last position the server had sent you for that
subscription, or the cursor you sent if it had sent none, and the events up to it are valid. Start from it.
What was checked
Every code, message and field on this page was read from the stream's code and its tests. Two answers were also
checked against the live endpoints on 2026-10-31, over an HTTP/1.1 upgrade with no credential: the new address answered
401 with the code unauthorized and the close code 4401, and the old address answered 410 with the body shown. The
closes 4408, 4409, 4410, 4503, 1011 and 1012, and the Cloudflare 521 and 522, were not provoked on the
live service, since each needs a stalled client, a full server or a restart. The frames shown are built from the code with made-up
values; none is a capture of a live session.
Live stream (WebSocket)
One authenticated WebSocket on data.vike.io that pushes closed Hyperliquid OHLCV bars as they complete. It is outside the hourly request limit and appears in no OpenAPI spec.
Alert feed and webhooks
GET /v1/alerts/events returns the events your alert rules produced, oldest first, paged by cursor, and POST /v1/alerts/deliveries/{id}/resend sends one of their deliveries again. This page also gives the webhook body and every event shape. The routes appear in no OpenAPI spec.