TL;DR
- There are two endpoints for everything.
POST https://api.hyperliquid.xyz/info(reads, body{type, ...}) andPOST https://api.hyperliquid.xyz/exchange(signed actions). WS:wss://api.hyperliquid.xyz/ws. Testnet: the same paths atapi.hyperliquid-testnet.xyz. Header:Content-Type: application/json. CORS permits calls directly from a browser (exact headers in §3.6). - Known-working stack (verified through 2026-09):
@nktkas/hyperliquid0.27.1 (prefer an exact pin, without^),viem2.50–2.52 (privateKeyToAccountfromviem/accounts),ws8.x, Node ≥ 20, TypeScript 5.x. - A common design: reads use raw
fetchagainst/info(known weights, controlled timeout), while the SDK is needed only for signed exchange actions. Public info requests do not require the SDK. ExchangeClientsigns with an AGENT wallet (API wallet) key, not the master-account key. Agent address =privateKeyToAccount(pk).address.toLowerCase(). Verify the association through infoextraAgents, includingvalidUntil.- SDK 0.27.1 THROWS
ApiRequestErroreven when some orders in a batch entered the book. The oids of placed orders exist only inerr.response.response.data.statuses. You cannot interpret “the SDK threw = nothing was placed”: that creates orphaned stops and loses accounting for live orders. ApiRequestErroris not exported from the package root. Identify it witherr.name === 'ApiRequestError';instanceofwill not work.- Transport errors.
HttpRequestErrorwith HTTP 4xx (except 408) or 429 means the exchange did NOT apply the request. With 5xx, a timeout, or a connection drop, the outcome is UNKNOWN (the order may have entered the book), so retry only after reconciliation. The SDK wraps HTTP 200 with malformed JSON inHttpRequestErrorand stores the originalSyntaxErrorin.cause. - The SDK runs every exchange request for one wallet strictly in sequence (semaphore, nonce =
Date.now()with monotonic increment). Parallelorder()calls do not speed up placement. - Info requests need a hard 8–10 s timeout (
AbortSignal.timeout). Without it, a stalled connection waits ~300 s (the undici default) on every retry attempt. - HIP-3 dex (for example,
xyz). Every read is made separately for each dex (dex:'xyz'in the body). An xyz order's asset id has an offset: observedxyz:TSLA = 110001,xyz:SP500 = 110052. Trading requiresagentEnableDexAbstractionfirst.
1. Versions and dependencies
| Package | Verified version | Purpose |
|---|---|---|
@nktkas/hyperliquid | 0.27.1 (prefer an exact pin, without ^) | ExchangeClient, InfoClient, HttpTransport; includes agentEnableDexAbstraction, sendAsset, usdClassTransfer, reserveRequestWeight |
viem | ^2.50.4 … 2.52.2 | privateKeyToAccount: a viem account is passed to the SDK as wallet |
ws | ^8.18.0 … ^8.21.0 | custom WS client |
undici | 6.21.2 (exact pin) | needed only for your own Agent (egress-IP binding), see §9 |
| Node | ≥ 20 | built-in fetch (using undici 6.x internally) |
| TypeScript / tsx | 5.6–5.9 / 4.19–4.22 |
All facts below about SDK response parsing apply to version 0.27.1 (package error logic: esm/src/api/exchange/_base/_errors.js). Recheck them after upgrading the SDK.
2. Endpoints: mainnet / testnet
| Mainnet | Testnet | |
|---|---|---|
| Base | https://api.hyperliquid.xyz | https://api.hyperliquid-testnet.xyz |
| Info | https://api.hyperliquid.xyz/info | https://api.hyperliquid-testnet.xyz/info |
| Exchange | https://api.hyperliquid.xyz/exchange | https://api.hyperliquid-testnet.xyz/exchange |
| WS | wss://api.hyperliquid.xyz/ws | wss://api.hyperliquid-testnet.xyz/ws |
| SDK | new HttpTransport() (mainnet by default) | new HttpTransport({ isTestnet: true }) |
export function hlUrls(testnet: boolean): { info: string; ws: string } {
return testnet
? { info: 'https://api.hyperliquid-testnet.xyz/info', ws: 'wss://api.hyperliquid-testnet.xyz/ws' }
: { info: 'https://api.hyperliquid.xyz/info', ws: 'wss://api.hyperliquid.xyz/ws' };
}
// The WS URL can be derived from base: base.replace(/^http/, 'ws') + '/ws'
// The SDK's HttpTransport receives the SAME isTestnet flag.
Protection against mixing networks (recommended pattern). If the info URL, exchange URL, and network flag point to different networks (for example, the official testnet info host with a mainnet signer), the process refuses to start and logs an explicit network-mismatch error. Otherwise testnet data could reach a mainnet signer. The info URL may point to a read-only proxy. In that case, specify the network with an explicit flag, which must match the transport's isTestnet.
3. Info API: request and response format
3.1 General format
- Method
POST, path/info, headerContent-Type: application/json. - Body:
{ "type": "<type>", "user"?: "0x...", "dex"?: "xyz", ... }. - The response is JSON whose shape depends on
type. - Pass addresses in lowercase, as every verified client does. Compare addresses in lowercase too.
- Each HIP-3 dex requires a separate request with the
dexfield. If the open-order count differs from the exchange UI, a dex was almost certainly omitted.
3.2 Raw fetch (TypeScript): reference wrapper
const INFO_URL = 'https://api.hyperliquid.xyz/info';
const INFO_FETCH_TIMEOUT_MS = 10_000;
export async function hlInfo<T>(body: Record<string, unknown>): Promise<T> {
const res = await fetch(INFO_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(INFO_FETCH_TIMEOUT_MS),
});
if (!res.ok) {
await res.body?.cancel().catch(() => {}); // best effort: release the undici connection
const err = new Error(`HL info HTTP ${res.status} for ${JSON.stringify(body)}`) as Error & { status?: number };
err.status = res.status; // 429 → retry(rateLimit), 5xx → retry(transient)
throw err;
}
try {
return (await res.json()) as T;
} catch (e) {
// 200, but JSON does not parse (the proxy/load balancer returned truncated JSON or HTML) — transient, retry
throw Object.assign(new Error('HL info: unparsable 200 body'), { transientResponseBody: true, cause: e });
}
}
One-liner for scripts:
const q = async (b: object) =>
(await fetch('https://api.hyperliquid.xyz/info', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b),
})).json();
await q({ type: 'frontendOpenOrders', user: '0xYOUR_ADDRESS' });
await q({ type: 'clearinghouseState', user: '0xYOUR_ADDRESS', dex: 'xyz' });
Variant with an AbortController (8 s timeout) and a separate 429 branch:
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 8000);
try {
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: ctl.signal });
if (r.status === 429) throw new Error('HL 429 rate limit');
if (!r.ok) throw new Error(`HL info HTTP ${r.status}`);
return await r.json();
} finally { clearTimeout(t); }
Put the info endpoint URL in configuration (default https://api.hyperliquid.xyz/info) so a proxy or testnet can be substituted.
3.3 Python without dependencies
import json, subprocess
def hl(body):
out = subprocess.run(
["curl", "-s", "--max-time", "12", "-X", "POST",
"https://api.hyperliquid.xyz/info",
"-H", "Content-Type: application/json", "-d", json.dumps(body)],
capture_output=True, text=True).stdout
try: return json.loads(out)
except Exception: return None # timeout or non-JSON (for example, 429 text)
perp = hl({"type": "clearinghouseState", "user": "0xyour_address_lowercase"})
# calling code guards against None: (perp or {}).get(...)
Variant using urllib:
import json, urllib.request
req = urllib.request.Request(API, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read())
3.4 Main info-request types
Weight is given for the IP budget. Values match the HL weight table: light requests weigh 2, most others 20, and userRole 60. “—” means the weight is not recorded here. Whether the weight of userFills/userFillsByTime/candleSnapshot grows with response size was not verified (a fixed weight of 20 is assumed here; see rate-limits.md), so the table has no “20+” labels.
type | Additional fields | Weight | What it returns / purpose |
|---|---|---|---|
meta | dex? | 20 | { universe: [{ name, szDecimals, maxLeverage, onlyIsolated?, isDelisted? }] }. Asset id = index in universe. Same shape as SDK info.meta(). Static and cacheable |
l2Book | coin | 2 | { levels: [bids[], asks[]], time? }, level { px, sz } (strings) |
allMids | dex? | 2 | mid prices by coin |
clearinghouseState | user, dex? | 2 | positions, accountValue, totalMarginUsed, per-position leverage |
spotClearinghouseState | user | 2 | { balances: [{ coin, total, hold, spotHold?, entryNtl, borrowed?, ltv? }], portfolioMarginEnabled? }. Add ONLY free stables to perp equity: max(0, total − reserve), where reserve = spotHold when present, otherwise hold. Do not add all spot: on Unified Account, hold mirrors perp margin (×2); on portfolio margin, hold < 0. Details in balance-and-equity.md §2–3 |
openOrders | user, dex? | 20 | [{ coin, oid, side: 'B'|'A', limitPx, sz }] |
frontendOpenOrders | user, dex? | 20 | live orders plus reduceOnly, time, and more |
portfolio | user | 20 | aggregate perp equity and history by DEX |
candleSnapshot | req: {...} | 20 | candles (for backtesting) |
userFills | user | 20 | latest fills |
userFillsByTime | user, startTime, aggregateByTime: false | 20 | fills over a period |
userNonFundingLedgerUpdates | user, startTime | 20 | [{ time, hash, delta }]: deposits, withdrawals, transfers |
userRateLimit | user | 20 | { cumVlm, nRequestsUsed, nRequestsCap, nRequestsSurplus? } |
userFees | user | 20 | { userCrossRate, userAddRate, activeReferralDiscount } |
extraAgents | user | 20 | [{ address, name, validUntil: number | null }], approved agent wallets |
userRole | user | 60 | { role: 'missing'|'user'|'vault'|'agent'|'subAccount', data?: { user?, master? } }. For agent, the master is in data.user; for subAccount, in data.master. Expensive: call once at startup |
userDexAbstraction | user | 2 | true only for legacy dex abstraction. Returns false for Unified Account; collateral mode is reliably visible only in WS webData3 (details in accounts.md §5.2) |
webData2 | user | — | aggregate “frontend” account snapshot |
maxBuilderFee | user, builder | 20 | number: approved builder-fee ceiling in tenths of a basis point (40 = 0.04%) |
Useful derived values:
pxDecimalsfor a perp =Math.max(0, 6 - szDecimals).- Approved agents:
extraAgents.filter(a => !a.validUntil || Number(a.validUntil) > Date.now()).map(a => a.address.toLowerCase()).
3.5 Asset id and HIP-3 dex
- Main perp dex: asset id = the coin's index in
meta.universe. - Builder-deployed dex (
xyzand others): request meta using{type:'meta', dex:'xyz'}; asset id = index in that dex's universe plus the dex offset. A smoke test producedxyz:SP500 = 110052andxyz:TSLA = 110001, so the xyz offset is 110000. - In the dex universe, the coin name is prefixed:
xyz:TSLA.
3.6 /info response codes and CORS (live read-only requests on 2026-09-22)
| Case | Status | Content-Type | Body |
|---|---|---|---|
Unknown type, body of the wrong shape, or required field missing or of the wrong type | 422 | text/plain; charset=utf-8 | Failed to deserialize the JSON body into the target type |
Entity not found: unknown dex, unknown coin for candleSnapshot / fundingHistory / recentTrades, invalid l2Book aggregation | 500 | application/json | null (Content-Length: 4) |
Unknown coin for l2Book, unknown dex for allMids | 200 | application/json | null |
500 null is an “unable to serve this request” response, not a transient failure: retrying produces the same result (@markpaper/hl-kit represents it as HlHttpError.invalidRequest, and retries do not repeat it). Type-specific details are in market-data.md §6–§8.
The same requests through WS post (wss://api.hyperliquid.xyz/ws, read-only, 2026-09-23 06:05Z) receive different answers than over HTTP, with a distinct WS shape for each of the three HTTP responses:
HTTP 500 null → {"channel":"post","data":{"id":1,"response":{"type":"error","payload":"500 Internal Server Error"}}}
(candleSnapshot / recentTrades / fundingHistory with an unknown coin, clearinghouseState and meta
with an unknown dex, l2Book nSigFigs 1)
HTTP 200 null → {"channel":"post","data":{"id":2,"response":{"type":"info","payload":{"type":"l2Book","data":null}}}}
(l2Book with an unknown coin, allMids with an unknown dex)
HTTP 422 → {"channel":"error","data":"Error parsing JSON into valid websocket request: {\"method\":\"post\",\"id\":3,\"request\":{…}}"}
(l2Book without coin, unknown type, fundingHistory without startTime): this is not a post response,
but an error-channel frame that echoes the envelope
SDK 0.33.3 rejects the first and third shapes as WebSocketRequestError with the payload / data text; for the third, the id is found in the echoed envelope (_dispatcher.js _handleErrorEvent). It returns the second shape as null.
Parameterized reference requests (HTTP, read-only, 2026-09-23) use the same three response classes:
| Request | Response |
|---|---|
perpAnnotation with an unknown coin | 500 null |
perpAnnotation BTC | 200 null (a main-dex coin has no annotation) |
perpAnnotation xyz:TSLA | 200, object { category, description, … } |
perpAnnotation without coin; tokenDetails with a tokenId that is not 34 hex characters or is a number; marginTable id: "x"; borrowLendReserveState token: "x"; settledOutcome outcome: "x" | 422 |
marginTable id: 999999 | 500 null |
settledOutcome outcome: 999999 | 200 null |
Longest names on the same day (perpDexs + allPerpMetas, 10 dexes, 524 coins): dex — 4 characters, coin — 14 (xyz:ALUMINIUM).
CORS. OPTIONS /info and OPTIONS /exchange (with and without Origin) → 200, Content-Length: 0, and headers access-control-allow-origin: *, access-control-allow-methods: *, access-control-allow-headers: *, allow: POST, vary: origin, vary: access-control-request-method, vary: access-control-request-headers. POST /info responses (identical for 200, 422, and 500) include access-control-allow-origin: *, access-control-expose-headers: *, and the same three vary headers. HL does not send access-control-allow-credentials; browser requests are made without cookies.
4. SDK: clients and transport
4.1 Imports and setup
import { ExchangeClient, InfoClient, HttpTransport } from '@nktkas/hyperliquid';
import { privateKeyToAccount } from 'viem/accounts';
// AGENT (API wallet) key. Normalize the 0x prefix:
const pk = (agentPrivKey.startsWith('0x') ? agentPrivKey : `0x${agentPrivKey}`) as `0x${string}`;
const account = privateKeyToAccount(pk); // throws for a malformed key
const agentAddress = account.address.toLowerCase();
const exchange = new ExchangeClient({
wallet: account,
transport: new HttpTransport({ isTestnet: false, timeout: 10_000 }),
});
const info = new InfoClient({ transport: new HttpTransport() }); // no parameters = mainnet
4.2 HttpTransport options (0.27.x)
| Option | Example | Meaning |
|---|---|---|
isTestnet | true / false (mainnet by default) | selects the URL and signing domain |
timeout | 10_000 | request timeout, ms |
server | { mainnet: { api: url }, testnet: { api: url } } | overrides the API base URL (for example, with a proxy) |
fetchOptions | { dispatcher: new Agent({ localAddress: ip }) } | additional fetch init fields (see §9 for egress IP) |
const transport = new HttpTransport({
isTestnet,
server: { mainnet: { api: exchangeApiUrl }, testnet: { api: exchangeApiUrl } },
});
const exchange = new ExchangeClient({ wallet: account, transport });
server controls only the destination address, while isTestnet controls the signing network. Both must point to the same network.
4.3 Subaccount / vault
Actions on behalf of a subaccount (or vault) are signed by the main account's agent, with the subaccount address added to the request:
const exchange = new ExchangeClient({
wallet: account,
transport,
...(vaultAddress ? { defaultVaultAddress: vaultAddress } : {}), // '0xSUBACCOUNT_ADDRESS'
});
4.4 Client lifecycle
- One
InfoClientper process./inforequires neither a signature nor an agent address, so the client can be shared. A separate client per user is unnecessary. - One
ExchangeClientper (agent key, master address) pair. Cache it inMap<accountId, client>under${pk}:${address}and recreate it when the key or address changes. Do not recreate the viem account and SDK client for every request. - Initialization is lazy and idempotent.
- SDK exchange calls should also pass through the common per-IP budget throttle at high priority, so they precede background info reads.
- Often
InfoClientis barely used and reads go through raw fetch, which makes weights and timeouts easier to control.
4.5 @nktkas/hyperliquid 0.33.3 and the official Python SDK 0.24.0: verified by reading source (2026-09-22)
- 0.33.3 validates only requests with valibot schemas (
parse(XRequest, …)inesm/api/*/_methods); response types exist only in TypeScript. The SDK does not reject a differently shaped response—the field is simplyundefined. Validate response shapes yourself. - The address option in 0.33.3 is
apiUrl(new HttpTransport({ isTestnet, apiUrl })), notserverfrom 0.27.x (§4.2). The SDK silently ignores an unknown option and sends requests to real HL. AnapiUrlwith a path preserves the path:…/proxybecomes…/proxy/info. The WS transport usesurlas-is. SymbolConverter.create()(@nktkas/hyperliquid/utils) loadsmeta,spotMeta, andoutcomeMetain onePromise.all(plusperpDexswhendexsis used). A server that does not answeroutcomeMetamakes converter creation fail entirely (HttpRequestError).- User-signed (EIP-712) actions in 0.33.3 are those routed through
executeUserSignedAction:approveAgent,approveBuilderFee,usdSend,spotSend,withdraw3,usdClassTransfer,sendAsset,sendToEvmWithData,cDeposit,cWithdraw,tokenDelegate,userDexAbstraction,userSetAbstraction,userPortfolioMargin,linkStakingUser,stakingLinkDisableTradingUser,convertToMultiSigUser(17). All other actions are L1 (signed by the agent key). - Python SDK 0.24.0: HTTP address is
base_url + url_path("/info","/exchange",hyperliquid/api.py), WS is"ws" + base_url[len("http"):] + "/ws"(websocket_manager.py), and the signing network is selected by exact comparisonbase_url == MAINNET_API_URL(exchange.py): with any other URL, the SDK signs as testnet. Itswebsocket-client1.9.2 sends the headerOrigin: http(s)://<host:port>by default (_handshake.py; disabled bysuppress_origin)—a server that allows WS only withoutOriginreturns 403 to the Python SDK. Not verified with a live run.
5. Agent wallets in the SDK
ExchangeClient.walletis a viem account made from the private key of the agent (API) wallet. A bot does not need the master key.- Store the key outside the repository in storage intended for secrets; the application chooses the exact storage and rotation design.
- If trading is enabled (not dry-run) and the key is missing, the process must fail at startup with an explicit error rather than silently enter dry-run.
- Association check before trading:
derived = privateKeyToAccount(pk).address.toLowerCase(). If the constructor throws, the key is malformed: setagentApproved = falseand log an explicit reason (malformed key, trading disabled).- The master address's
extraAgentslist, filtered byvalidUntil, must containderived. - Optionally,
userRole(derived)must returnrole === 'agent'with the master indata.user.
6. Exchange actions through the SDK
6.1 Orders and cancellations
// Batch of limit orders. tif: 'Gtc' | 'Alo' (post-only: an order that crosses the book is
// rejected by the exchange rather than executed as taker) | 'Ioc'.
const res = await exchange.order({
orders: specs.map((o) => ({
a: assetIndex, // asset id (§3.5)
b: o.side === 'B', // true = buy
p: o.pxStr, // price as a string, already quantized
s: o.szStr, // size as a string
r: o.reduceOnly === true, // reduce-only
t: { limit: { tif: o.tif } }, // 'Gtc' | 'Alo' | 'Ioc'
})),
grouping: 'na',
});
// Cancel by oid
await exchange.cancel({ cancels: oids.map((o) => ({ a: assetIndex, o })) });
Exchange response (the same body is stored inside ApiRequestError):
// order
{ "status": "ok", "response": { "data": { "statuses": [
{ "resting": { "oid": 123 } },
{ "filled": { "oid": 124, "totalSz": "0.5", "avgPx": "100.1" } },
{ "error": "rejection text" }
] } } }
// cancel: statuses = ["success" | { "error": "..." }]
Statuses appear in the same order in which the orders were sent. A cancellation rejection such as “never placed / already canceled / filled” usually means “the order is not in the book” for application logic; the caller makes that decision.
IP weights: an order batch weighs 1 plus 1 for every 40 orders. Under the address limit, every order counts as a separate request.
6.2 Other actions
| Action (SDK method) | Parameters | Idempotent? | Notes |
|---|---|---|---|
updateLeverage | { asset, isCross: true, leverage } | yes | repeating the same value is safe; transient errors can be retried broadly |
agentEnableDexAbstraction / enableDexAbstraction | — | yes (medium confidence) | enables dex abstraction before trading on a HIP-3 dex (xyz). A call error means execution failed |
reserveRequestWeight | { weight } | no (paid) | purchases additional address request capacity at 0.0005 USDC per request |
usdClassTransfer | { amount: '12.34', toPerp: true } | no | spot → perp transfer; amount is a string with 2 decimal places. On a unified account it fails with an error matching /unified|disabled/i: remember this and do not try again |
sendAsset | { destination, sourceDex: 'spot', destinationDex: 'spot', token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054', amount } | no | gasless USDC transfer to another address within HL. Working option for a unified account where USDC is in the spot wallet. amount is a string |
usdSend | — | no | gasless USDC transfer within HL Core (classic action) |
approveBuilderFee | { maxFeeRate: '0.04%', builder } | — | user-signed; signed by the MASTER wallet, not the agent (§8) |
await exchange.sendAsset({
destination: '0xRECIPIENT_ADDRESS',
sourceDex: 'spot',
destinationDex: 'spot',
token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054',
amount: '12.34',
});
await exchange.usdClassTransfer({ amount: (1.5).toFixed(2), toPerp: true });
Recommendations for automated transfers:
- a separate hot wallet with the minimum operating balance, so its compromise does not affect trading keys;
- dry-run by default;
- a minimum transfer amount;
- do not transfer dust (configure the threshold explicitly);
- a “transfer in progress” flag so a second transfer cannot start in parallel.
6.3 Request queue and nonce
- SDK 0.27.1 passes all exchange requests for one wallet through a one-at-a-time semaphore. Nonce =
Date.now()with monotonic increment. - Therefore,
Promise.all([ex.order(...), ex.order(...)])is no faster than sequential calls. Put more orders in one batch to increase throughput. - Start independent reads on the critical path (for example, account state and meta) in parallel: with cold caches, this materially reduces latency. A promise that may not be needed because of an early return must not reject (
catch → 0/nullinternally), or it creates an unhandled rejection.
7. SDK errors and their classification
7.1 What the SDK throws and when (0.27.1)
| Situation | What the SDK throws | Did the exchange apply it? | Action |
|---|---|---|---|
status: 'err': the entire action was rejected | ApiRequestError, err.response = { status: 'err', response: 'text' } | no | batchError = String(response) |
status: 'ok', but at least one statuses[i] contains {error} | ApiRequestError, err.response = { status: 'ok', response: { data: { statuses } } } | partially: adjacent orders may have entered the book | parse statuses element by element and collect oids |
| HTTP 429 | HttpRequestError, .response.status === 429 | no | IP limit: do not immediately repeat; backoff is required |
| HTTP 4xx except 408 | HttpRequestError with .response | no | known outcome |
| HTTP 5xx, 408 | HttpRequestError with .response | unknown | do not place again before reconciling with the book |
| timeout / disconnect | HttpRequestError without .response | unknown | same |
| HTTP 200, but body is not JSON | HttpRequestError, original SyntaxError in .cause | unknown | treat as transient |
Every exchange order rejection (insufficient margin, unmatched IoC, price band, reduceOnly violation) arrives as an exception. The SDK does not return an object with rejected status.
7.2 Snippets
/** Exchange response body from ApiRequestError (the class is not exported, so identify it by name).
* {status:'ok', response:{...}} for a partial rejection, {status:'err', response:'text'} for rejection of the whole action. */
export function apiErrorBody(e: unknown): { status: string; response: unknown } | null {
const x = e as { name?: string; response?: { status?: string; response?: unknown } };
if (!x || x.name !== 'ApiRequestError' || !x.response || typeof x.response.status !== 'string') return null;
return { status: x.response.status, response: x.response.response };
}
/** Classify an SDK transport error. 4xx (except 408) and 429 mean not applied; 5xx/timeout/disconnect have an unknown outcome. */
export function transportOutcome(e: unknown): { batchError: string; outcomeUnknown: boolean; rateLimited: boolean } {
const x = e as { name?: string; message?: string; response?: { status?: number } };
const status = x?.name === 'HttpRequestError' && x.response && typeof x.response.status === 'number' ? x.response.status : 0;
const msg = String(x?.message ?? e).slice(0, 200);
if (status === 429) return { batchError: `HTTP 429 (IP rate limit): ${msg}`, outcomeUnknown: false, rateLimited: true };
if (status >= 400 && status < 500 && status !== 408) return { batchError: `HTTP ${status}: ${msg}`, outcomeUnknown: false, rateLimited: false };
return { batchError: `transport: ${msg}`, outcomeUnknown: true, rateLimited: false };
}
type OrderStatus =
| { kind: 'resting'; oid: number }
| { kind: 'filled'; oid: number; totalSz: number; avgPx: number }
| { kind: 'error'; error: string };
/** Parse an exchange response (or ApiRequestError body) into per-item statuses. */
export function parseOrderStatuses(raw: unknown): OrderStatus[] {
const statuses = (raw as { response?: { data?: { statuses?: unknown[] } } })?.response?.data?.statuses;
if (!Array.isArray(statuses)) return [];
return statuses.map((s): OrderStatus => {
const x = s as { resting?: { oid: number }; filled?: { oid: number; totalSz: string; avgPx: string }; error?: string };
if (x?.resting) return { kind: 'resting', oid: Number(x.resting.oid) };
if (x?.filled) return { kind: 'filled', oid: Number(x.filled.oid), totalSz: Number(x.filled.totalSz), avgPx: Number(x.filled.avgPx) };
return { kind: 'error', error: String(x?.error ?? JSON.stringify(s)).slice(0, 200) };
});
}
export async function place(exchange: ExchangeClient, assetIndex: number, orders: PlaceSpec[]) {
if (!orders.length) return { statuses: [], batchError: null, outcomeUnknown: false };
try {
const res = await exchange.order({ orders: toWire(assetIndex, orders), grouping: 'na' });
return { statuses: parseOrderStatuses(res), batchError: null, outcomeUnknown: false };
} catch (e) {
const body = apiErrorBody(e);
if (body && body.status === 'ok') {
// partial rejection: adjacent orders may have ENTERED THE BOOK — parse element by element
return { statuses: parseOrderStatuses(body), batchError: null, outcomeUnknown: false };
}
if (body) return { statuses: [], batchError: String(body.response).slice(0, 200), outcomeUnknown: false };
return { statuses: [], ...transportOutcome(e) };
}
}
Cancellation parsing is the same, except statuses[i] is 'success' or {error}.
Placement result: { statuses, batchError, outcomeUnknown, rateLimited? }. With outcomeUnknown: true, do not place the orders again until reconciliation shows what is on the exchange. For status: 'err' and HTTP 4xx/429, outcomeUnknown is false: the exchange rejected the action and nothing was placed.
7.3 Transient detector that traverses .cause
export function isTransientError(e: unknown, depth = 0): boolean {
if (!e || depth > 6) return false;
const x = e as { name?: string; transientResponseBody?: boolean; cause?: unknown };
if (e instanceof SyntaxError || x.name === 'SyntaxError' || x.transientResponseBody === true) return true;
// + application logic: 5xx, network codes, timeouts
return isTransientError(x.cause, depth + 1);
}
Without traversing .cause, a “200 + HTML from the load balancer” response bypasses the retry layer entirely.
8. Signing requests
8.1 L1 actions (orders, cancellations, leverage)
The SDK signs these: ExchangeClient.wallet contains the agent's viem account, and the transport's isTestnet sets the signing domain. There is no need to build anything manually.
8.2 User-signed actions (EIP-712), using approveBuilderFee as an example
Protocol fields verified against the @nktkas/hyperliquid SDK:
- domain:
name: 'HyperliquidSignTransaction',version: '1', currentchainId, zeroverifyingContract; - primary type:
HyperliquidTransaction:ApproveBuilderFee; - message:
hyperliquidChain,maxFeeRate,builder,nonce; - in the action, the same chain id is passed as a hex string in
signatureChainId, andnoncemust match both the message and the outer request field; - for mainnet,
hyperliquidChain: 'Mainnet'; the testnet value was not verified (§12); /exchangebody:{ action, signature: { r, s, v }, nonce };- success is confirmed only by a response with
status === 'ok'; the current builder-fee ceiling is read with the separatemaxBuilderFeeinfo request.
The calling application chooses the builder address and maxFeeRate values. No UI, fee constants, or signing-page deployment design is fixed here.
9. Network: an SDK transport limitation
HttpTransport does not accept an undici dispatcher as a dedicated typed option. The transport builds a standard Request and calls global fetch; only fetchOptions is exposed. Passing a nonstandard dispatcher through this path was not confirmed with a live request (§12), so a local configuration is not proof of a distinct egress IP or IP budget.
10. Architectural patterns
- Separate read and write modules. The info module only reads (raw fetch), while the executor only writes (SDK). This simplifies auditing and tests.
- Put network access behind an injectable interface. Unit tests can supply response fixtures without sending signed actions or requiring an exchange account. Cover partial batch success, explicit rejection, and unknown outcomes independently.
- Before executing a batch, fetch coin meta. If meta is missing, log the reason and do not execute the batch (status “dropped”). For a HIP-3 coin, first ensure that dex abstraction is enabled (§6.2).
11. Pitfalls
| # | What breaks | Why | Correct approach |
|---|---|---|---|
| 1 | After a batch error, untracked orders and orphaned stops (a TP/SL pair) remain in the book | SDK 0.27.1 throws ApiRequestError if any statuses[i] contains {error}, even though adjacent orders entered the book | Catch the error, call apiErrorBody(e), and when status === 'ok', parse statuses element by element and retain the oids |
| 2 | e instanceof ApiRequestError is unavailable | The class is not exported from the package root | Check e.name === 'ApiRequestError' and typeof e.response?.status === 'string' |
| 3 | One rejected order aborts the rest of processing even though previous actions already occurred | Exchange rejection arrives as an exception, not an object | Wrap every call: convert ApiRequestError to { status: 'REJECTED', rawError }, and rethrow transport and signature errors. For batches, account for pitfall #1 |
| 4 | Duplicate orders after a timeout | With 5xx, timeout, or disconnect, the exchange may have accepted the request | Set outcomeUnknown = true, reconcile against openOrders/frontendOpenOrders, and decide only then |
| 5 | “200 OK” with HTML or truncated JSON is not retried | The SDK stores the SyntaxError in .cause of HttpRequestError; there is no error HTTP status | Traverse .cause recursively to depth 6 in isTransientError |
| 6 | The bot stalls for minutes: order management, including protective closes, freezes | No timeout: a stalled connection waits ~300 s (undici default) on every retry attempt | AbortSignal.timeout(8–10 s) on every info request and timeout in HttpTransport |
| 7 | Connections leak on non-2xx | An unread body holds the undici socket | await res.body?.cancel() before throwing |
| 8 | Parallel order() calls do not speed up placement | The SDK passes one wallet's exchange requests through a one-at-a-time semaphore | Put more orders in one batch; parallelize only reads |
| 9 | The bot does not see all orders and positions | A request without dex returns only the main perp dex | Poll every dex separately and reconcile the order count with the UI |
| 10 | Testnet data reaches a mainnet signer (or vice versa) | server or info URL disagrees with isTestnet | Validate the URL and network flag at startup; refuse to start when they are mixed |
| 11 | SDK requests use the wrong IP and limits are not sharded | The SDK calls global fetch and drops nonstandard fields, while Agent({connect:{localAddress}}) is silently ignored | new Agent({ localAddress }) through fetchOptions or a global dispatcher, plus a mandatory IP echo check at startup |
| 12 | 429 storm after “sharding” | Two throttle buckets use the same physical IP | Separate buckets only for genuinely distinct addresses |
| 13 | approveBuilderFee is rejected | The builder address uses mixed case | Use a lowercase builder address in both action and message |
| 14 | usdClassTransfer emits warnings every N minutes | A unified account cannot transfer spot → perp (error matches /unified|disabled/i) | Remember an “unsupported” flag and stop retrying. For transfers, use sendAsset with spot/spot |
| 15 | The agent key does not parse | Missing 0x or junk such as a trailing newline from an env file | Normalize (trim, add 0x). A malformed key means refusing to trade with an explicit log |
| 16 | The dispatcher field breaks tsc compilation | RequestInit from lib.dom does not know the field | Use a local cast at the call boundary |
| 17 | Egress binding stops after an upgrade | undici 7.x changed the handler API | Pin [email protected] without ^ |
| 18 | An unused promise terminates the process via unhandled rejection | The promise started in parallel, but the code returned early | “Just in case” promises must not reject (catch → null) |
12. Open questions / not verified
fetchOptions: { dispatcher }orsetGlobalDispatcher. The SDK acceptsfetchOptionswithdispatcher, but it buildsnew Request(url, init)(dropping nonstandard fields) and calls global fetch, so replacing the global dispatcher is more reliable for the SDK. The IP self-check confirmed lanes for raw fetch; the SDK path was not checked separately. How to verify: send a request through the SDK transport to an echo service and compare the IP. If it does not match, usesetGlobalDispatcher.- Interpreting a batch error. Treating every
ApiRequestErroras full rejection (REJECTED) is wrong: inspection of SDK 0.27.1 (2026-09) showed that whenresponse.status === 'ok', some orders may have entered the book. This conclusion (§7) takes precedence. enableDexAbstraction/agentEnableDexAbstraction. Idempotency is marked with medium confidence. The exact signature and the distinction between user and agent variants are not described here.usdSendorsendAsset.usdSendas a transfer mechanism is described with medium confidence and without its parameter shape.sendAssetwas verified for a unified account (2026-06).usdSendwas not separately rechecked on a regular (non-unified) account.- HIP-3 asset id. Only
xyz(offset 110000) was verified. The general formula for other dexes (according to HL documentation:100000 + perpDexIndex * 10000 + index) was not verified live. - Testnet for user-signed actions. Only the mainnet signature (
hyperliquidChain: 'Mainnet') was verified live. The testnet value (expected to be'Testnet') was not verified. - The weight of
webData2is not recorded here. Read it from the current HL table. Other weights in §3.4 were reconciled with rate-limits.md §2.1. - A Python sidecar for signing (Python SDK for L1 signing next to a TS bot) is not described here. Only dependency-free Python reads from
/infoare included. - Shapes of
candleSnapshot.req,portfolio,webData2. These requests are used, but their exact request and response fields are not recorded here. - Builder fee on an order is described in fees.md §3: field
builder: { b, f }at the top level of the action next toorders/grouping, lowercaseb,fin tenths of a bp (perp ceiling 100), not abovemaxBuilderFee. Whetherbuildercan be attached to native TP/SL is not verified (fees.md §8); see fees.md §3.7 for the risk of stalefduring close retries. - Gtc and trigger orders are described in orders.md §2 and §4 and risk-and-margin.md §9.2 (
grouping:'positionTpsl',s:'0', string statuses without oid).
Knowledge snapshot: 2026-09; dates of individual checks are in the text. The HL API changes—recheck limits and response shapes.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license.