BloxwapHyperliquid SDK

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 (default 10_000; pass null to 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:

RequestWeight
info: l2Book, allMids, clearinghouseState, orderStatus, spotClearinghouseState, exchangeStatus2
info: any other documented request20
info: userRole60
explorer40
exchange1 + 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 rateLimit the 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 its expiresAfter timestamp 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 connected

WebSocket 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 for stableTimeout (default 3_000 ms).
  • Backoff — reconnectionDelay defaults to exponential backoff 2 ** attempt * 150 ms 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 Expired connection rotation) skips the delay on the first retry of a streak and emits no error event; only repeated clean closes fall back to the backoff.
  • Handshake bound — connectionTimeout (default 10_000 ms) 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"):

LimitValueScope
Connections10per IP
New connections30/minuteper IP
Subscriptions1000per IP
Unique users across user-specific subs14per IP
Messages sent to Hyperliquid2000/minuteper IP, across all connections
Simultaneous inflight post requests100per 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.

Edit this page on GitHub

On this page