vike

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.

KeyProgramIts connection
testyour test clientIts own.
prodyour production serviceIts own.
backupa standby serverIts 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).

actionFieldsWhat it does
subscribeid, channel, filter, cursorStarts 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.
unsubscribeidEnds one subscription.
pongnoneAnswers 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.

subscribed: the answer to a subscribe
{"type": "subscribed", "id": "whales", "channel": "hl.fills", "cursor": "1793178870512-0003", "replay": {"from": "none", "events": 0}}
event: one event for you
{
  "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.

  • ids lists every one of your subscriptions on this connection that the event matched. The event is sent once, however many matched.
  • The frame's cursor is the one to keep. It is the stream's own position and the only cursor you may send back. The cursor inside event is a different cursor, of the event's source: never send that one.
  • event.event_id is 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 a pong for three pings the connection is closed with 1011, about 60 seconds after your last one.
  • error frames 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

  1. Keep one cursor per subscription. After each event you handle, store the frame's cursor under every id in ids. A new subscription's subscribed frame 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.
  2. After any disconnect, subscribe again with the same id, channel and filter, and the stored cursor.
  3. Read the answer. subscribed repeats your cursor and says how the replay comes in, in replay: from is none for a subscribe without a cursor (nothing is replayed); buffer when the events you missed are recent (and events is how many follow); store when they are older (they follow in pages and events is null).
  4. 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.

Keep one cursor per subscription, and resume with it
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.

CodeNameWhyWhat the client does
4408slow_consumerThe 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.
4409server_catching_upNot 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.
4410replacedA 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.
1011noneNo pong for three pings, or an error on the server side of the connection.Reconnect at once. No frame: use your own cursors.
1012noneThe service is restarting, or a channel's feed had to restart.Reconnect at once. No frame: use your own cursors.
1009noneA message you sent was over 64 KiB.Send smaller messages (an ordinary subscribe is well under it), reconnect, resume.
1001, 1006, nonenoneThe server went away, or the connection dropped with no close frame.Reconnect at once. Use your own cursors.
4401unauthorizedNo valid credential. Answered as HTTP 401.Fix the credential.
4503capacityThe server process that took the connection is at its connection limit. Answered as HTTP 503.Send the same upgrade request again.
1013unavailableThe 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.

4408: the frame before the close
{
  "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>"
}
4408: the client subscribes again with that cursor
{"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.

4409: the frame before the close
{
  "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.

4410: the frame before the close
{
  "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.

4410: subscribe on the surviving connection with the cursor for the channel
{"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:

1012: resume from your own cursor
{"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.

Statuscodeclose_codeMeaningWhat the client does
401unauthorized4401No valid credential.Fix the credential; a retry without one fails the same way.
503capacity4503The server process that took the connection is at its connection limit.Send the same upgrade request again.
503unavailable1013The credential could not be checked just now.Send the same upgrade request again.
521 or 522none: a Cloudflare HTML pagenoneThe 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.
410STREAM_MOVEDnoneYou used the old address on data.vike.io.Use wss://stream.vike.io/v1/stream/hl.
503 capacity: the answer to the upgrade request
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:

410 STREAM_MOVED: the old address on data.vike.io
{"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.

codeWhenmessage and extra fieldsWhat the client does
bad_subscriptionThe 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_channelThe 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_limitedThe 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_expiredThe 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_aheadThe 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_largeThe 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_unavailableThe 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, replacedA 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.

On this page