Connect to Hyperliquid
Every client reaches Hyperliquid through a transport. Two are built in — HttpTransport and
WebSocketTransport — and both expose the same request API, so switching between them is a one-line
change.
The choice comes down to subscriptions: live data streams that only
WebSocketTransport can open. If you don't need them, HttpTransport is the simpler choice.
Common options
Both transports take the same two options:
isTestnet— connect to the Hyperliquid testnet instead of mainnet.timeout— abort a request after this many milliseconds (default10_000; passnullto disable).
import { HttpTransport, WebSocketTransport } from "@bloxwap/hyperliquid";
const transport = new HttpTransport({ isTestnet: true, timeout: 30_000 });
// ^^^^^^^^^^^^^
// or `WebSocketTransport`HTTP
Each request is an independent POST with no connection to keep alive, which suits serverless functions, edge workers, and unstable networks:
import { ExchangeClient, HttpTransport, InfoClient } from "@bloxwap/hyperliquid";
import { privateKeyToAccount } from "viem/accounts";
const wallet = privateKeyToAccount("0x...");
const transport = new HttpTransport();
const info = new InfoClient({ transport });
const exchange = new ExchangeClient({ wallet, transport });
// ^^^^^^^^^
// A single transport instance can be reused with any client
const mids = await info.allMids();HTTP endpoints
HttpTransport uses two endpoints, both defaulting to Hyperliquid's public URLs (set isTestnet to send requests to
the testnet URL):
apiUrl— info and exchange requests. Default:https://api.hyperliquid.xyz.rpcUrl— explorer requests. Default:https://rpc.hyperliquid.xyz.
Override either to run against your own node or a proxy:
import { HttpTransport } from "@bloxwap/hyperliquid";
const transport = new HttpTransport({
apiUrl: "https://custom-api.example.com",
rpcUrl: "https://custom-rpc.example.com",
});Fetch options
fetchOptions is merged into every request the transport sends, so you can set any
RequestInit field (except body and method) — extra
headers, credentials, cache, and the like:
import { HttpTransport } from "@bloxwap/hyperliquid";
const transport = new HttpTransport({
fetchOptions: {
headers: { "X-Custom-Header": "value" },
},
});Exchange timeout
timeout covers every request, so a hung /exchange POST blocks an order for the full duration. exchangeTimeout
gives the exchange endpoint its own — usually shorter — deadline, while info and explorer requests keep the global
one:
import { HttpTransport } from "@bloxwap/hyperliquid";
const transport = new HttpTransport({
timeout: 10_000, // info and explorer requests
exchangeTimeout: 5_000, // order placement, cancels, transfers
});Pass null to disable the timeout for exchange requests only. Like timeout, the field is mutable on the instance.
Rate limiting
Hyperliquid budgets REST requests at 1200 weight per minute per IP; going over yields HTTP 429, and repeated
violations get the IP banned. An exchange request costs 1 + floor(batchLength / 40) — unbatched actions cost 1, a
batch of 40–79 orders (or cancels) costs 2, 80–119 costs 3. Info endpoints cost 2–60 weight and explorer requests 40
(see Rate limits for the full table).
rateLimit opts HttpTransport into a client-side token bucket paced to that budget: every request acquires its
weight before sending and waits while the bucket is empty instead of failing with a 429 after the fact:
import { HttpTransport } from "@bloxwap/hyperliquid";
const transport = new HttpTransport({
rateLimit: { capacity: 1200, refillPerMinute: 1200 }, // the defaults, shown for clarity
});The limiter bills the documented weights:
| Request | Weight |
|---|---|
info: l2Book, allMids, clearinghouseState, orderStatus, spotClearinghouseState, exchangeStatus | 2 |
info: any other documented request | 20 |
info: userRole | 60 |
explorer | 40 |
exchange | 1 + floor(batchLength / 40) |
The exchange batch length is the longest array found anywhere in the action at any depth, unwrapping multi-sig
actions (the batch lives inside action.payload.action; the wrapper's signatures array is auth material, never
billed). That covers the documented batch keys (orders/cancels/modifies) and also bills deployer arrays
(spotDeploy genesis tuples, perpDeploy setter lists) and any future batch action — the docs define
batch_length as "the length of the array in the action" without closing the set, and over-billing is the safe
side. Arrays shorter than 40 entries (fixed tuples), twapOrder's single twap object, and
convertToMultiSigUser's wire-stringified signers keep the minimum weight of 1.
Response-size surcharges can only be known once the response arrives, so they are debited from the bucket after
the response: 1 extra weight per 20 returned items on the documented list endpoints (recentTrades, userFills,
historicalOrders, …), per 60 on candleSnapshot, and per returned block on explorer blockList. Later requests
then wait off the real cost rather than the estimate the request was sent with. Two caveats: the official docs warn
that older blockList blocks "may be weighted more heavily" server-side, so the +1-per-block debit is exact only
for recent blocks; and the item-count rule is an interpretation — the docs do not say whether the count is exactly
the top-level response array length (what the limiter bills) nor whether partial chunks round up or down (the
limiter rounds up). Both are settled client-side as deliberate conservative over-estimates; only the server-side
truth remains outstanding (issue #49).
- The wait happens before the request timeout is armed, so throttling never trips
timeout/exchangeTimeout; aborting the request's signal cancels the wait instead — an aborted request never reaches the wire. - A request that fails after the wait — even with HTTP 429 — keeps its pre-send weight debit (no refund): the server bills attempts, its own accounting of failed requests is undocumented, and keeping the debit is the conservative reading.
- The limiter is off by default; without
rateLimitthe transport never delays a request client-side.
The limiter is per HttpTransport instance. It tracks only the requests it sends itself — other transport
instances, other processes, and other machines behind the same IP do not coordinate, yet they all share the same
1200 weight/minute budget. Treat it as best-effort local throttling, not a guarantee against 429s: bursts that
exceed what one instance can see still hit the server limit, and there is no endpoint that reports the IP bucket's
state. Handle HttpRateLimitError (which carries status and, when the
server sends a Retry-After header, a retryAfter hint in seconds) as the backstop.
Automatic retry on 429
For hands-off 429 handling, opt into retryOnRateLimit:
const transport = new HttpTransport({
retryOnRateLimit: true, // or { maxRetries: 3, maxDelayMs: 30_000 } — the defaults
});When the server answers 429, the transport waits and retries instead of throwing: if the response carried a
Retry-After header it waits exactly that long (plus up to 1 s of jitter to spread herds); otherwise it falls back
to full-jitter exponential backoff. A Retry-After longer than maxDelayMs surfaces the HttpRateLimitError
rather than retrying sooner than the server allowed, and after maxRetries attempts the error propagates. The
overall timeout / exchangeTimeout spans every attempt and wait, caller aborts interrupt the wait, and when
rateLimit is also enabled each retry re-debits the bucket (the server bills attempts, not logical requests). The
two options complement each other: the limiter prevents 429s, the retry absorbs the rest. Off by default.
The separate address-based limits (requests allowed per user, growing with cumulative trading volume) are what
the userRateLimit info method reports — it has no view of the shared per-IP weight budget either.
Per the official docs, an address gets 1 request per 1 USDC traded cumulatively since inception, on top of an
initial buffer of 10,000 requests; once limited, it is allowed one request every 10 seconds. Sub-accounts count as
separate users, and the limit applies to actions only, not info requests. Cancels get their own cumulative limit of
min(limit + 100000, limit * 2), so hitting the address-based limit still leaves room to cancel open orders.
Batching interacts differently with the two budgets: a batch of n orders (or cancels) counts as one request
against the per-IP weight budget but as n requests against the address-based one.
Three adjacent rules from the exchange-endpoint docs matter to anyone pacing orders:
- Open-order limit — 1000 open orders per user plus one more per 5M USDC of trading volume, capped at 5000 total. An order placed while the user already has at least 1000 open orders is rejected if it is reduce-only or a trigger order.
- High-congestion throttling — during high congestion an address is limited to 2x its previous-day maker-share percentage of the block space; the maker share is scaled by the asset's fee-tier volume contribution (HIP-3 assets under growth mode count less) and computed once per UTC day. During high traffic it therefore helps not to resend cancels whose results the API already returned.
- Stale
expiresAfter— an action canceled because itsexpiresAftertimestamp went stale consumes 5x the usual address-based rate limit.
WebSocket
WebSocketTransport opens one connection and reuses it, shaving a little latency off each request and allows using the
subscription API:
import { SubscriptionClient, WebSocketTransport } from "@bloxwap/hyperliquid";
const transport = new WebSocketTransport();
const subs = new SubscriptionClient({ transport });
// ^^^^^^^^^^^^^^^^^^
// Unlike other clients, it supports only `WebSocketTransport`
await subs.allMids((data) => {
console.log(data.mids);
});
// Promise is resolved when the subscription is connectedWebSocket endpoints
Because a WebSocket transport is one open connection, it reaches a single endpoint per instance, unlike HttpTransport.
WebSocketTransport therefore takes one url (default wss://api.hyperliquid.xyz/ws) for info, exchange, and
subscriptions; explorer subscriptions need a second transport pointed at
wss://rpc.hyperliquid.xyz/ws:
import { ExplorerClient, WebSocketTransport } from "@bloxwap/hyperliquid";
const transport = new WebSocketTransport({ url: "wss://rpc.hyperliquid.xyz/ws" });
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Default value for `url` is `wss://api.hyperliquid.xyz/ws`.
// For explorer subscriptions, you need a separate transport pointed at `wss://rpc.hyperliquid.xyz/ws`.
const explorer = new ExplorerClient({ transport });
await explorer.explorerBlock((data) => {
console.log(data);
});Reconnection
WebSocketTransport reconnects on its own when the connection drops, through the in-house
ReconnectingWebSocket (packages/hyperliquid/src/transport/websocket/_reconnectingSocket.ts) — a minimal WebSocket wrapper with
reconnection logic.
Reconnection defaults, tuned for a latency-critical trading connection:
- Unbounded retries —
maxRetries: Infinity. The socket never silently stops reconnecting; the consecutive-failure counter resets once a connection stays open forstableTimeout(default3_000ms). - Backoff —
reconnectionDelaydefaults to exponential backoff2 ** attempt * 150ms with equal jitter (half the capped delay is fixed, half uniform random), capped at 10 s. The cap trades a slightly slower recovery after a long outage for not hammering an endpoint that is already struggling; pass a custom delay to change it. - Routine rotations reconnect immediately — a clean close (code 1000, e.g. Hyperliquid's
Expiredconnection rotation) skips the delay on the first retry of a streak and emits noerrorevent; only repeated clean closes fall back to the backoff. - Handshake bound —
connectionTimeout(default10_000ms) recycles a stuck connection attempt through the same retry policy.
Pass reconnect to change the retry count, delay, or connection timeout. See ReconnectingWebSocketOptions in the
source for the rest. transport.close() (or socket.close()) terminates permanently — no further reconnection is
attempted afterwards.
import { WebSocketTransport } from "@bloxwap/hyperliquid";
const transport = new WebSocketTransport({
reconnect: { maxRetries: 5, reconnectionDelay: 1_000 },
});Keep-alive
A ping/pong watchdog detects a half-open connection — one that looks open but no longer carries frames — and forces
a reconnect. Every interval it sends a ping; if no pong arrives within timeout, the connection
is recycled.
Defaults are interval: 5_000 and timeout: 3_000, so a dead feed is detected in at most ~8 s. The server closes a
connection that has been silent for ~60 s, so keep the interval well below that. Lower values detect a stale feed
faster at the cost of more ping traffic; raise them on constrained networks.
import { WebSocketTransport } from "@bloxwap/hyperliquid";
const transport = new WebSocketTransport({
keepAlive: { interval: 10_000, timeout: 5_000 },
});Resubscription
The SDK re-subscribes to every active channel on its own after a reconnect, so you never restore them
by hand. Delivery pauses while the connection is down and resumes once it's back. Turn that off with
resubscribe: false:
const transport = new WebSocketTransport({ resubscribe: false });If a subscription then fails to re-establish, every subscriber's onError callback is invoked and each subscription
handle's failureSignal aborts. Handle it as shown under subscription errors.
Connection state
The transport reduces the connection lifecycle to four states — connecting, connected, reconnecting (down,
retrying), and disconnected (permanently terminated by close(), or by the reconnection policy giving up). Read the
current one from transport.connectionState, and observe transitions on transport.events:
import { WebSocketTransport } from "@bloxwap/hyperliquid";
const transport = new WebSocketTransport();
transport.events.addEventListener("connectionstatechange", (event) => {
console.log(event.detail); // "connecting" | "connected" | "reconnecting" | "disconnected"
});The event fires once per actual transition. While reconnecting, outgoing frames are buffered and subscriptions resume
on their own (see reconnection and resubscription), so most consumers only need this
to surface connection status or to run their own teardown on disconnected.
WebSocket limits
Hyperliquid scopes every documented WebSocket limit to your IP address, not to the connection — two of them say so in their own text ("across all websocket connections"):
| Limit | Value | Scope |
|---|---|---|
| Connections | 10 | per IP |
| New connections | 30/minute | per IP |
| Subscriptions | 1000 | per IP |
| Unique users across user-specific subs | 14 | per IP |
| Messages sent to Hyperliquid | 2000/minute | per IP, across all connections |
| Simultaneous inflight post requests | 100 | per IP, across all connections |
The unique-user value needs a caveat: the official docs still say 10, while the server's own refusal message
says 15 (Cannot track more than 15 total users.) — and neither number is what the server enforces. A live mainnet
probe found the 15th distinct user refused, so the SDK guards at 14, one below the message's claim
(packages/hyperliquid/src/transport/websocket/_quota.ts). Erring low is the safe direction: refusing one subscription the server might
have taken costs a slot, while admitting one it refuses costs a 10 s request timeout.
Because that scope is the IP and not the socket, every WebSocketTransport on a network shares one budget by
default, and the subscription and unique-user guards count what the server counts. Two transports no longer admit 2000
subscriptions against a limit of 1000 — the excess is refused locally with a clear
WebSocketRequestError instead of by the server, whose refusal carries no echoed request and
therefore surfaces only as a request timeout ten seconds later.
Reservations are released when a subscription is unsubscribed or when its connection is permanently closed, which is
when the server frees them too. Call transport.close() on a transport you are done with.
Pass your own quota when the default's assumption does not hold — a process behind several egress IPs needs one per
IP, and tests usually want isolation. Pass rateLimit to keep the default's message pacing
on the replacement; a WebSocketQuota constructed without it disables message pacing while retaining connection admission:
import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";
const transport = new WebSocketTransport({ quota: new WebSocketQuota({ rateLimit: {} }) });WebSocket rate limiting
Outbound messages are paced by default: the shared quota runs a token bucket sized to the server's budget (capacity 2000, refilling 2000/minute), because the default transport is exactly the one that trips the limit — one reconnect re-subscribes every held subscription at once, so 1000 subscriptions spend half the minute's budget instantly, and a flapping socket repeats the burst until the server refuses.
Pacing only ever delays subscribe and unsubscribe frames. post requests and keep-alive pings never wait: an
exchange action's wire order — and therefore per-wallet nonce ordering — depends on reaching the socket synchronously,
and delaying the keep-alive watchdog is how a half-open connection goes unnoticed. Both still debit the budget, so a
burst of orders correctly slows subscription traffic rather than silently overrunning the shared limit.
To opt out of pacing — or to resize the bucket — pass your own quota; constructed without rateLimit, it keeps the
subscription, unique-user and connection guards but never delays an outbound frame client-side:
import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";
// No message pacing; connection admission still applies.
const transport = new WebSocketTransport({ quota: new WebSocketQuota() });
// Or keep pacing with a custom burst size and refill rate.
const paced = new WebSocketTransport({
quota: new WebSocketQuota({ rateLimit: { capacity: 1000, refillPerMinute: 2000 } }),
});As with HTTP rate limiting, the budget is client-side bookkeeping: other processes, other machines behind the same IP, and traffic the SDK cannot see all draw on the same server-side bucket.
WebSocket connection limits
The shared quota admits at most 10 connecting/open/closing sockets and 30 new connection attempts in any rolling 60 seconds, including initial handshakes, failures and reconnects. Excess attempts wait in FIFO order; closing a waiting transport cancels its admission. A socket reservation is released when the underlying socket closes, so reconnecting while the old socket is still closing cannot temporarily exceed the connection cap.
These settings apply independently of message pacing. Configure maxConnections and
maxConnectionAttemptsPerMinute on an explicit WebSocketQuota; set either to null to disable that guard.
Each quota coordinates only transports in the current process. Separate processes and other hosts behind the same IP
must coordinate externally or choose lower limits to leave room for one another. Connected order/post dispatch is
unchanged; the connection gate only delays creation of sockets.