— Contents 19 sections
  1. 01 How the Hyperliquid API works
  2. 02 Before you start: wallets, API wallets, and one signing setup
  3. 03 Getting the current price from the Hyperliquid API
  4. 04 Fetching Hyperliquid candlestick data
  5. 05 Reading Hyperliquid funding rates
  6. 06 Checking your Hyperliquid balance and positions in Python
  7. 07 Placing a limit order with the Hyperliquid API
  8. 08 Placing a market order on Hyperliquid (there is no market order type)
  9. 09 Setting a stop-loss or take-profit on Hyperliquid
  10. 10 Canceling orders and listing open Hyperliquid orders
  11. 11 Checking Hyperliquid order status and trade history
  12. 12 Depositing to Hyperliquid from code (there is no deposit API)
  13. 13 Withdrawing from Hyperliquid with the API
  14. 14 Moving USDC between Hyperliquid perp and spot balances
  15. 15 Streaming live Hyperliquid prices over WebSocket
  16. 16 Hyperliquid API rate limits and common errors
  17. 17 Running your Hyperliquid API code as a bot
  18. 18 Conclusion and next steps
  19. 19 Frequently asked questions

The official Hyperliquid documentation specifies every endpoint precisely and still leaves the hard part to you: the docs’ own signing page is a short troubleshooting note, the real signing specification lives in the Python SDK’s source code, and a reader who just wants the five lines that fetch a balance or place an order has to reverse-engineer them from example repositories. This guide fills that gap — for each common operation, what the docs specify, then the shortest code that actually works, then the mistakes that cost people time. Everything here was verified against the current official documentation and, wherever the endpoint allows it, against the live API. The scope is the native trading API — perpetuals first, spot basics second.

How the Hyperliquid API works

A floating island with two doors — one standing open onto a bright library, the other locked with a padlock while a sealed envelope waits beside it — and a stream flowing off the island's edge

Hyperliquid is one chain with two execution environments, and most navigation confusion starts by landing in the wrong one. HyperCore runs the on-chain order book — perps, spot, margin — and is what you trade against. HyperEVM is a general smart-contract environment with standard Ethereum JSON-RPC. Both live under the same “For developers” documentation tree, but trading never touches JSON-RPC:

SurfaceWhereWhat it isIn this guide
Info endpointPOST https://api.hyperliquid.xyz/infoevery read: prices, books, candles, balances, positions, orders, fillsYes
Exchange endpointPOST https://api.hyperliquid.xyz/exchangeevery write: orders, cancels, leverage, transfers, withdrawalsYes
WebSocketwss://api.hyperliquid.xyz/wslive subscriptions, plus an alternative transport for both endpoints aboveYes
HyperEVMhttps://rpc.hyperliquid.xyz/evmsmart-contract development on the EVM half of the chain (JSON-RPC, chain id 999)No — separate stack
Node / S3 archivenode software, s3://hyperliquid-archiveraw historical order-book data for research and latency-critical useNo

This guide covers the first three — the entire trading surface. That surface is genuinely small: two HTTP paths and one WebSocket URL. Everything is a POST, including every read — there are no REST-style GET routes, and the operation is selected by a type field in the JSON body, not by the path. Perps and spot share the same endpoints and are distinguished by the asset identifier, not by separate API trees.

Three things to internalize before the first request:

  • Reads are completely unauthenticated. No key, no signature, no account. That includes account state: because Hyperliquid’s state lives on a public blockchain, anyone can query any address’s positions, open orders and fills. Worth knowing before you publish your trading address anywhere.
  • Writes are authorized by wallet signatures, not credentials. Every /exchange request carries an action, a nonce (a number used once — here a millisecond timestamp — that stops a captured request from being replayed) and an EIP-712 signature (EIP-712 is Ethereum’s standard for signing structured data). The setup section below deals with this once, so the recipes stay short.
  • The API labels itself v0. The notation page concedes the current field names are nonstandard and says a breaking v1 will batch the cleanup, with no date given — one more reason to date-stamp your integration (this article’s facts were verified on 2026-08-18).

The nonstandard notation is terse, but there is not much of it. This table decodes most of what you will see in responses, and the single-letter keys used in order actions:

Docs termMeaning
Pxprice
Szsize, in units of the base coin
Szisigned size — positive long, negative short
Ntlnotional, Px * Sz in USD
SideB = bid/buy, A = ask/sell
Tiftime-in-force: Gtc (rest until canceled), Alo (post-only), Ioc (fill or cancel the rest)
a / b / p / s / r / t / cin order actions: asset id / isBuy / price / size / reduceOnly / order type / client order id

Before you start: wallets, API wallets, and one signing setup

A large ornate key resting on a cushion beside a closed vault, while a small plain key on a cord swings away to go work

The most important correction for anyone arriving from a centralized exchange: there is no API key on Hyperliquid. No secret, no passphrase, no IP whitelist, no permission checkboxes, no “create key” form. Identity is an Ethereum address; authorization is an EIP-712 signature from a private key. If you can sign, you can act — which is why the account structure below matters more than any settings page.

What you need: an EVM wallet (the onboarding docs name Rabby, MetaMask, WalletConnect and Coinbase Wallet), and a funded account — an address that has never received funds cannot send actions, and the first transfer into a brand-new account carries a one-time activation fee of 1 quote token (e.g. 1 USDC). If you signed up with an email login instead of a wallet, note that the embedded wallet’s private key must be exported before you can sign programmatically. If you don’t have a funded account yet, our Hyperliquid review covers onboarding, fees and the deposit flow.

No identity verification appears anywhere in the onboarding flow — but access is not open to everyone: the Terms of Use bar persons in the United States and Ontario, Canada from using the official interface, and the project states it holds no license or registration in any jurisdiction. Read the Terms and check your own position before trading.

Do not trade with your master key. The purpose-built alternative is an API wallet (the docs also call it an agent wallet): a second keypair that your master account authorizes to sign on its behalf. The split is structural, not a permission toggle — an API wallet can place orders, cancel and change leverage, but the official SDK states it plainly: “The agent does not have permission to transfer or withdraw funds.” A leaked API-wallet key can lose you trades, not your balance. Three rules from the nonces and API wallets page worth following from day one:

  • Query with the master address, never the agent address. API wallets only sign. Asking for the agent address’s balance returns an empty account — the docs call this out as the classic pitfall, and it looks exactly like a broken API.
  • One API wallet per trading process. Nonces are tracked per signing key, so two processes sharing one agent key will collide.
  • Never reuse a deregistered agent’s address. Its nonce history can be pruned, which reopens old signed actions to replay. Generate a fresh agent key instead; an account gets one unnamed plus up to three named agents, with more per sub-account, and a named agent can be given an expiry of up to 180 days.

The guided, beginner-paced version of this setup — from wallet to API wallet to a first order and an unattended loop — is our Hyperliquid trading bot guide.

You can create a named API wallet in the app at app.hyperliquid.xyz/API, or do it in code with the master key — the SDK generates the agent key locally and only the derived address ever leaves your machine:

# pip install hyperliquid-python-sdk
# one-time setup, run with the MASTER key; store the printed agent key safely
import eth_account, os
from hyperliquid.exchange import Exchange

master = eth_account.Account.from_key(os.environ["HL_MASTER_KEY"])
result, agent_key = Exchange(master, "https://api.hyperliquid.xyz").approve_agent("mybot")
print(result)          # {'status': 'ok', ...}
print(agent_key)       # the new API wallet's private key — shown here and nowhere else

The testnet is the same API at https://api.hyperliquid-testnet.xyz (WebSocket wss://api.hyperliquid-testnet.xyz/ws), with its own app at app.hyperliquid-testnet.xyz. Its faucet grants 1,000 mock USDC — but only to addresses that have already deposited on mainnet, per the faucet page. So the risk-free ladder looks like this: every read recipe below works right now with no setup at all; your signing code can be proven correct offline against the SDK’s test vectors (next); and only actually placing an order requires a funded account. One trap when you switch networks: asset indices differ between mainnet and testnet — on 2026-08-18, mainnet asset 0 was BTC while testnet asset 0 was SOL — so code must always resolve names to indices on the network it is pointed at, never hardcode them.

Every recipe below builds on this setup. Signing is the one part the docs tell you not to write yourself (“It is recommended to use an existing SDK instead of manually generating signatures”), and the signing page is a troubleshooting note rather than a spec — the reference implementation is hyperliquid/utils/signing.py in the first-party SDK. So the helper builds every request by hand, exactly as the docs describe it, and delegates only the signature to that official module:

# pip install hyperliquid-python-sdk   (brings the official signing module + requests)
import os, requests, eth_account
from hyperliquid.utils.signing import sign_l1_action, float_to_wire, get_timestamp_ms

BASE = "https://api.hyperliquid-testnet.xyz"        # mainnet: https://api.hyperliquid.xyz
# (the market-data recipes below call mainnet directly — public reads work on both)
IS_MAINNET = BASE == "https://api.hyperliquid.xyz"  # signing flag must follow the URL

# The API wallet's key signs trading actions; it cannot move funds.
# ADDRESS is the funded master account — every query uses it, never the agent's.
wallet = eth_account.Account.from_key(os.environ["HL_API_WALLET_KEY"])
ADDRESS = os.environ["HL_MASTER_ADDRESS"].lower()

def info(payload):
    r = requests.post(f"{BASE}/info", json=payload)
    r.raise_for_status()
    return r.json()

def exchange(action, vault_address=None):
    nonce = get_timestamp_ms()                      # ms timestamp; also the nonce
    sig = sign_l1_action(wallet, action, vault_address, nonce, None, IS_MAINNET)
    r = requests.post(f"{BASE}/exchange", json={
        "action": action, "nonce": nonce, "signature": sig,
        "vaultAddress": vault_address, "expiresAfter": None})
    r.raise_for_status()
    return r.json()

Hyperliquid has two signing schemes, and choosing the wrong one is the number one cause of failed requests. Trading actions — orders, cancels, leverage changes, scheduled cancels — are L1 actions (L1 here means the exchange’s own chain), and an API wallet can sign them. Anything that moves money or grants permission — withdrawals, transfers, agent approvals — is a user-signed action, and only the master key can sign it. The mechanical test: if an action’s documented shape carries hyperliquidChain and signatureChainId fields, it is user-signed; if it does not, it is an L1 action. Every recipe below names its scheme on the Signing line.

Four rules keep signatures valid — the first three named on the official signing page, the fourth spelled out on the exchange-endpoint page: build action dicts in the documented field order (the hash runs over msgpack, a binary serialization that preserves key order — reordering keys changes the signature); send prices and sizes as strings with trailing zeroes removed (float_to_wire does this and raises rather than silently rounding); lowercase every address before signing; and omit optional fields entirely rather than setting them to false or null — under msgpack, absent and null are different payloads.

You can prove the whole setup correct without an account, funds or even a network connection. The SDK’s test suite publishes a throwaway private key with the expected signatures for fixed inputs — reproducing one is the only self-check that actually proves anything, because verifying your own signature against your own payload always “succeeds” even when the payload is wrong:

# continuing in the same file — offline signing self-check
test_wallet = eth_account.Account.from_key(
    "0x0123456789012345678901234567890123456789012345678901234567890123")
test_action = {"type": "order",
               "orders": [{"a": 1, "b": True, "p": "100", "s": "100",
                           "r": False, "t": {"limit": {"tif": "Gtc"}}}],
               "grouping": "na"}
sig = sign_l1_action(test_wallet, test_action, None, 0, None, True)
assert sig["r"] == "0xd65369825a9df5d80099e513cce430311d7d26ddf477f5b3a33d2806b100d78e"
print("signing setup verified")

The key is from the official SDK’s public test suite — it holds no real funds and is safe to print precisely because it is not a real secret. Never reuse it for anything real. Signing the same action with the testnet flag produces a completely different signature (r starting 0x82b2ba…), which is the network-mismatch trap in miniature: the wrong flag doesn’t error locally, it just signs for the wrong network.

Getting the current price from the Hyperliquid API

A person taking notes at an open window filled with thermometers of different heights, a row of small candles and a striped windsock

Docs say: the info request allMids returns mid prices for every actively traded coin in one response. No authentication. Signing: none.

import requests
mids = requests.post("https://api.hyperliquid.xyz/info",
                     json={"type": "allMids"}).json()
print(mids["BTC"], mids["ETH"])   # mid prices as strings, e.g. '64181.5'

Gotchas: every price in this API is a string — cast before arithmetic. The response is one flat map with mixed key formats: perp names ("BTC"), spot pairs ("@107", "PURR/USDC") and outcome-market codes ("#" followed by digits — these rotate, so never key on a specific one) appear together, so iterate with a filter rather than assuming every key is a perp. Builder-deployed (HIP-3) markets are not in this response — they need an explicit "dex" field in the request — but their "xyz:AAPL"-style names do turn up in fills and candles. If a book is empty the value falls back to the last trade price, per the docs. For one market’s order book, l2Book returns up to 20 levels per side — levels is a two-element array where index 0 is bids and index 1 is asks, not an object with named keys:

book = requests.post("https://api.hyperliquid.xyz/info",
                     json={"type": "l2Book", "coin": "BTC"}).json()
best_bid, best_ask = book["levels"][0][0], book["levels"][1][0]
print(best_bid["px"], best_bid["sz"], "/", best_ask["px"], best_ask["sz"])

Testnet: works (https://api.hyperliquid-testnet.xyz/info). Reference: allMids · l2Book

Fetching Hyperliquid candlestick data

Docs say: candleSnapshot returns OHLCV candles for a coin, an interval from 1m to 1M, and a start/end time in epoch milliseconds. Only the most recent 5,000 candles per market are available. Signing: none.

import time
now = int(time.time() * 1000)
candles = requests.post("https://api.hyperliquid.xyz/info", json={
    "type": "candleSnapshot",
    "req": {"coin": "BTC", "interval": "1h",
            "startTime": now - 24 * 3600 * 1000, "endTime": now}}).json()
closes = [float(c["c"]) for c in candles]

Gotchas: lowercase t is the candle’s open time and uppercase T its close time — both are present and easy to swap. OHLCV values are strings, like everything else. The 5,000-candle cap is hard: the official S3 archive holds book snapshots but explicitly not candles, so longer candle history is something you record yourself. Supported intervals are 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 3d, 1w, 1M — case-sensitive. Testnet: works. Reference: candleSnapshot

Reading Hyperliquid funding rates

Docs say: metaAndAssetCtxs returns the perp universe together with a per-asset context — mark price, oracle price, current funding rate and open interest. fundingHistory returns historical hourly funding for one coin from a startTime. Signing: none.

meta, ctxs = requests.post("https://api.hyperliquid.xyz/info",
                           json={"type": "metaAndAssetCtxs"}).json()
for asset, ctx in zip(meta["universe"], ctxs):
    if asset["name"] == "BTC":
        print(ctx["markPx"], ctx["funding"], ctx["openInterest"])

history = requests.post("https://api.hyperliquid.xyz/info", json={
    "type": "fundingHistory", "coin": "BTC",
    "startTime": now - 7 * 24 * 3600 * 1000}).json()
print(history[-1]["fundingRate"], history[-1]["time"])   # fundingRate here, not funding

Gotchas: the response is a two-element array — the metadata object first, then the contexts array, aligned by index with universe. The funding field is the current hourly rate as a string — but fundingHistory names the same quantity fundingRate, so the two responses are not interchangeable. History-style requests cost extra rate-limit weight per 20 items returned, so don’t poll fundingHistory in a loop when one metaAndAssetCtxs call answers the “what is funding right now” question. Testnet: works. Reference: asset contexts · funding history

Checking your Hyperliquid balance and positions in Python

A person holding open a satchel with two transparent compartments — stacked coins on one side, small colored tokens on the other — and a front pocket holding loose coins

Docs say: clearinghouseState returns the perp account — margin summary, withdrawable balance and every open position. Spot balances live in a separate request, spotClearinghouseState. Signing: none — but pass the master address.

# continuing in the same file — info() reads whichever network BASE points at
state = info({"type": "clearinghouseState", "user": ADDRESS})
print("withdrawable:", state["withdrawable"])
for p in state["assetPositions"]:
    pos = p["position"]
    print(pos["coin"], pos["szi"], "entry", pos["entryPx"],
          "uPnL", pos["unrealizedPnl"], "liq", pos["liquidationPx"])

spot = info({"type": "spotClearinghouseState", "user": ADDRESS})
for b in spot["balances"]:
    if float(b["total"]) > 0:
        print(b["coin"], "total", b["total"], "on hold", b["hold"])

Gotchas: the number that matters for sizing is withdrawable, not accountValue — equity includes unrealized PnL you cannot spend. An empty response usually means you queried the API wallet’s address; agents only sign, and the docs name this exact mistake. szi is signed size — negative means short, and an empty assetPositions array means flat. On the spot side, hold is the amount locked in your own resting orders; what you can spend is total - hold, and the balances list includes every token the address ever held, dust included. Two funding-flow surprises worth knowing: deposits land on the perp side by default, and the support FAQ notes that with cross-margin positions at negative unrealized PnL, new deposits and spot-to-perp transfers go toward collateral for those positions — so “I deposited 1000 but see less available” is usually margin accounting, not a missing deposit. Accounts in the newer unified/portfolio-margin modes are documented to show all balances in the spot state instead. Testnet: works. Reference: clearinghouseState · spot balances

Placing a limit order with the Hyperliquid API

A hand placing a round wooden token onto a pegboard where pieces only fit on the pegs, next to a small tray of queued colored tokens

Docs say: the order action places one or more orders. Each order carries the asset index a, side b, price p, size s, reduce-only flag r and an order type t such as {"limit": {"tif": "Gtc"}}. Prices must have at most 5 significant figures and at most 6 - szDecimals decimal places for perps (8 for spot); sizes round to the asset’s szDecimals (the number of decimal places that asset’s size may carry); and the order’s notional must clear $10 for perps. Signing: L1 action — an API wallet can sign it.

First, resolve the market’s index and rounding rules from meta. This is the prerequisite for every order — indices shift as assets list and delist, and they differ per network:

# continuing in the same file
meta = info({"type": "meta"})
live = [(i, a) for i, a in enumerate(meta["universe"]) if not a.get("isDelisted")]
ASSET = {a["name"]: i for i, a in live}          # name -> asset index
SZ_DECIMALS = {a["name"]: a["szDecimals"] for i, a in live}

def round_px(px, coin):        # the official rounding recipe (SDK examples/rounding.py)
    if px > 100_000:           # integer prices are always allowed
        return round(px)
    return round(float(f"{px:.5g}"), 6 - SZ_DECIMALS[coin])   # spot: 8 instead of 6

def round_sz(sz, coin):        # sizes round to the asset's szDecimals
    return round(sz, SZ_DECIMALS[coin])

Then the order itself:

coin = "ETH"
action = {"type": "order",
          "orders": [{"a": ASSET[coin], "b": True,
                      "p": float_to_wire(round_px(1800.0, coin)),
                      "s": float_to_wire(round_sz(0.006, coin)),
                      "r": False,
                      "t": {"limit": {"tif": "Gtc"}}}],
          "grouping": "na"}
result = exchange(action)

Order failures arrive inside a successful response. The HTTP status is 200 and the top-level status is "ok" even when the order was rejected — the verdict is nested per order. This small helper handles all three shapes and belongs in every script:

def order_statuses(result):
    if result["status"] != "ok":            # signature/account-level failure
        raise RuntimeError(result["response"])
    return result["response"]["data"]["statuses"]

for st in order_statuses(result):
    if "error" in st:
        print("rejected:", st["error"])     # e.g. 'Order must have minimum value of $10.'
    elif "resting" in st:
        print("resting on the book, oid", st["resting"]["oid"])
    elif "filled" in st:
        print("filled", st["filled"]["totalSz"], "@", st["filled"]["avgPx"])

Gotchas: the three time-in-force values are Gtc (rests until canceled), Alo (post-only — canceled instead of matching immediately, with the error helpfully echoing the current best bid/offer) and Ioc (the unfilled part is canceled). The two rejections that dominate first attempts are "Price must be divisible by tick size." — fix with the rounding helper — and the $10 minimum notional. The 5-significant-figure price rule has one release valve: integer prices are always valid, which is why a BTC price like 64178 works even though 64178.5 would be one significant figure too many. An optional client order id c (a 128-bit hex string) lets you find and cancel orders without storing the server’s oid — but if you don’t use it, leave the key out entirely; a null changes the signed payload. orders is an array, so several orders can ship in one signed action. Testnet: works, with a funded testnet account. Reference: place an order · tick and lot size

Placing a market order on Hyperliquid (there is no market order type)

Docs say: nothing, because the exchange endpoint has no market order type. A Hyperliquid market order is an aggressively priced IOC limit order; the official SDK’s own comment reads “Market Order is an aggressive Limit Order IoC”, with a default slippage buffer of 5%. Signing: L1 action — an API wallet can sign it.

mid = float(info({"type": "allMids"})["ETH"])
px = round_px(mid * 1.05, "ETH")            # buy: pay up to 5% above mid (SDK default)
sz = round_sz(11 / mid, "ETH")              # ~$11 of ETH — clears the $10 floor
action = {"type": "order",
          "orders": [{"a": ASSET["ETH"], "b": True,
                      "p": float_to_wire(px),
                      "s": float_to_wire(sz),
                      "r": False,
                      "t": {"limit": {"tif": "Ioc"}}}],
          "grouping": "na"}
print(order_statuses(exchange(action)))

Gotchas: the limit price is a protection bound, not a target — the order fills at the book’s actual prices, and anything beyond the bound is canceled rather than chased. Sell orders buffer downward (mid * 0.95). If you see "Order could not immediately match against any resting orders.", the buffer didn’t reach any liquidity; widening it is a choice about slippage tolerance, not a bug fix. To close a position at market, read szi from clearinghouseState, flip the side, and send the same IOC shape with "r": true (reduce-only) so a stale size can’t accidentally open the opposite position. Size the order from the $10 floor and the live mid, as the snippet does — a hardcoded size goes stale as prices move, and round_sz is what keeps a recomputed size on the asset’s size grid (a raw 11 / mid has far too many decimals and float_to_wire refuses to round silently). Testnet: works, with a funded testnet account. Reference: place an order · SDK market order example

Setting a stop-loss or take-profit on Hyperliquid

Docs say: stops and take-profits are the same order action with a trigger order type instead of limit: {"trigger": {"isMarket": true, "triggerPx": "1700", "tpsl": "sl"}}. The mark price (the exchange’s reference price for margin and triggers, not the last trade) is what fires them. Triggered market orders carry a 10% slippage tolerance; a triggered limit order rests at your limit price instead. Signing: L1 action — an API wallet can sign it.

# stop-loss for an existing 0.006 ETH long: sell if mark drops through 1700
action = {"type": "order",
          "orders": [{"a": ASSET["ETH"], "b": False,
                      "p": float_to_wire(round_px(1650.0, "ETH")),  # worst acceptable px
                      "s": float_to_wire(round_sz(0.006, "ETH")),
                      "r": True,                                    # reduce-only
                      "t": {"trigger": {"isMarket": False,
                                        "triggerPx": float_to_wire(1700.0),
                                        "tpsl": "sl"}}}],
          "grouping": "positionTpsl"}
print(order_statuses(exchange(action)))

Gotchas: the docs’ own worked example shows what the trigger limit price really controls: a stop with trigger $10 and limit $10 will likely rest unfilled if price gaps straight from $11 to $9, while a limit of $8 fills somewhere between $9 and $8 — the gap between triggerPx and p is your real slippage budget. TP/SL orders must be reduce-only, and pre-validation rejects the whole batch otherwise. grouping ties the trigger to its context: positionTpsl follows the position, normalTpsl ties TP/SL children to a parent order. One behavior that bites automated code: cancel a partially filled parent order and its child TP/SL orders are canceled with it — the docs state you must re-place protection for the filled portion yourself. Testnet: works, with a funded testnet account. Reference: TP/SL orders · place an order

Canceling orders and listing open Hyperliquid orders

Docs say: openOrders (info) lists resting orders for an address; the cancel action takes an array of {"a": asset, "o": oid} pairs; cancelByCloid does the same by client order id. There is no single cancel-all action — cancel-all is a batched cancel over your open orders. Signing: cancels are L1 actions — an API wallet can sign them; the listing is an unauthenticated read.

open_orders = info({"type": "frontendOpenOrders", "user": ADDRESS})
perp_orders = [o for o in open_orders if o["coin"] in ASSET]   # skip spot/builder markets
if perp_orders:                        # an empty cancels batch is rejected outright
    result = exchange({"type": "cancel",
                       "cancels": [{"a": ASSET[o["coin"]], "o": o["oid"]}
                                   for o in perp_orders]})
    print(order_statuses(result))      # 'success' or a per-cancel error

Gotchas: prefer frontendOpenOrders over plain openOrders — it adds origSz, reduceOnly and trigger fields, without which you cannot tell a partially filled order from a small one. Canceling something already gone returns "Order was never placed, already canceled, or filled." — treat it as benign in a cleanup loop, and don’t resend cancels whose results already came back (the docs ask exactly that during congestion, and every action spends your address-based budget). Two traps specific to this action pair: cancel uses the short keys a/o but cancelByCloid uses the full words asset/cloid; and the optional fast-cancel flag f must be omitted entirely when false — an action hashed with "f": false is rejected. For unattended code, the better safety net is the built-in dead man’s switch: the scheduleCancel action cancels everything at a future time (at least 5 seconds out, at most 10 triggers per day, reset at 00:00 UTC), so a crashed bot’s orders die with it:

exchange({"type": "scheduleCancel", "time": get_timestamp_ms() + 180_000})
# re-arm on a timer; keep the deadline several loop intervals long — a deadline
# equal to your loop period fires between healthy iterations (max 10 triggers/day)

Testnet: works, with a funded testnet account. Reference: cancel · open orders · schedule cancel

Checking Hyperliquid order status and trade history

Docs say: orderStatus looks up one order by oid or client order id and returns its full state plus a status string; userFills returns the address’s most recent 2,000 fills; userFillsByTime pages through the most recent 10,000 by time window. Signing: none — these are reads.

st = info({"type": "orderStatus", "user": ADDRESS, "oid": 77738308})
print(st["order"]["status"] if st["status"] == "order" else "unknown oid")

fills = info({"type": "userFills", "user": ADDRESS})
for f in fills[:5]:
    print(f["coin"], f["dir"], f["px"], f["sz"],
          "fee", f["fee"], f["feeToken"], "maker" if not f["crossed"] else "taker")

Gotchas: orders tell you what you asked for; fills tell you what happened — any PnL or fee accounting must come from userFills, where fee is the total (a builderFee component appears only when nonzero) and crossed marks you as taker. The status vocabulary is much richer than open/filled/canceled: values like marginCanceled, selfTradeCanceled, scheduledCancel and liquidatedCanceled explain orders that vanished through no action of yours — when an order disappears, orderStatus is where the reason is. An unknown id returns {"status": "unknownOid"} rather than an error. Fill entries mix coin formats (perps as "AVAX", spot as "@107", builder markets as "xyz:AAPL"), so a parser that assumes plain names will mis-read a mixed history. This matters most when a request times out: the action’s fate is unknown, so query state before retrying — the docs’ 15-second action expiry (the "Action already expired" error) exists precisely because a delayed order landing late can mean duplicate fills. Testnet: works. Reference: order status · user fills

Depositing to Hyperliquid from code (there is no deposit API)

Coins hopping across a small bridge onto an island where two pouches are joined by a path, while a sealed envelope carries a coin away on the far side

Docs say: nothing under /exchange — because depositing is not an exchange action at all. There is no deposit endpoint and no deposit-address API. Funds enter Hyperliquid by an on-chain transfer on another network, and they are credited to the address that sent them — your “deposit address” is simply your own wallet address. Signing: not a Hyperliquid action — deposits are transactions on the source chain.

The current officially preferred route for USDC is CCTP (Circle’s Cross-Chain Transfer Protocol); the USDC page links Circle’s contracts and flow for minting USDC natively on Hyperliquid. The older route — sending native USDC on Arbitrum to the bridge contract (0x2df1c51e09aecf9cacb7bc98cb1742757f163df7) — still works but is explicitly deprecated, and it carries a hard rule worth repeating verbatim: the minimum deposit is 5 USDC, and an amount below that “will not be credited and be lost forever”. The app additionally supports USDC arriving from Ethereum, Base and Polygon, and non-USDC assets (BTC, ETH, SOL and others) that land as spot balances — the non-USDC routes run through third-party operators, so treat the in-app deposit screen as the source of truth for what is currently supported.

Gotchas: the first transfer into a brand-new account pays the one-time 1 USDC activation fee, so a fresh bot wallet funded with exactly 5 USDC does not end up with 5 USDC of margin. Deposits credit the perp balance by default — if your spot order then fails on balance, the money is on the other side (see the transfer recipe below). And since testnet’s faucet is gated on a prior mainnet deposit, the practical order of operations for a cautious reader is: verify signing offline first, deposit a small amount on mainnet once, then unlock the faucet and rehearse everything else on testnet. Testnet: the faucet replaces deposits (1,000 mock USDC). Reference: USDC and bridges · activation fee

Withdrawing from Hyperliquid with the API

Docs say: the withdraw3 action sends USDC from your perp balance to an address on Arbitrum. It needs only a Hyperliquid signature — validators handle the Arbitrum side, no transaction of yours required there. The docs state a $1 fee “at the time of this writing”, and arrival in “3-4 minutes” on one page and “approximately 5 minutes” on another — call it a few minutes. Signing: user-signed action — master key only. This is the point of the API-wallet design: an agent key cannot sign this, so a compromised bot cannot drain the account.

A safety frame before any code, the same one as in our Hyperliquid trading bot guide: automated trading never requires withdrawal power. Keep the master key off the bot machine entirely, run withdrawals as a separate manual or tightly controlled process, and send the smallest amount first.

from hyperliquid.utils.signing import sign_withdraw_from_bridge_action

master = eth_account.Account.from_key(os.environ["HL_MASTER_KEY"])
nonce = get_timestamp_ms()
action = {"type": "withdraw3",
          "destination": "0xYourArbitrumAddress".lower(),
          "amount": "12.5",
          "time": nonce}                      # must equal the envelope nonce
sig = sign_withdraw_from_bridge_action(master, action, IS_MAINNET)
r = requests.post(f"{BASE}/exchange", json={
    "action": action, "nonce": nonce, "signature": sig})
print(r.json())                               # {'status': 'ok', ...}

Gotchas: the signing helper adds two fields to the action dict (hyperliquidChain and signatureChainId) — send the mutated dict as-is; posting a clean copy without them fails, because the server rebuilds the EIP-712 message from the action you transmit. time must equal the outer nonce exactly (the docs flag this in capitals), and the destination address should be lowercased before signing. Withdrawn funds come out of the perp balance — money sitting on the spot side must be transferred first (next recipe). If you hand-roll the typed data instead of using the helper: the domain is HyperliquidSignTransaction, and signatureChainId is not a network selector — the SDK uses 0x66eee on both networks while the docs’ example uses 0xa4b1, and both are valid because the server derives the domain from whatever value you send; the actual network split is the hyperliquidChain field. On verification: the request construction and signature here are checked against the official spec and the SDK’s published test vectors; the live round trip needs a funded account, which is one more reason the smallest amount goes first. Testnet: works, with a funded testnet account. Reference: initiate a withdrawal

Moving USDC between Hyperliquid perp and spot balances

Docs say: the usdClassTransfer action moves USDC between your perp and spot balances: toPerp: true for spot → perp, false for the reverse. Signing: user-signed action — master key only.

from hyperliquid.utils.signing import sign_usd_class_transfer_action

nonce = get_timestamp_ms()
action = {"type": "usdClassTransfer",
          "amount": "100",
          "toPerp": False,                    # perp -> spot
          "nonce": nonce}                     # note: 'nonce', not 'time'
sig = sign_usd_class_transfer_action(master, action, IS_MAINNET)
r = requests.post(f"{BASE}/exchange", json={
    "action": action, "nonce": nonce, "signature": sig})
print(r.json())

Gotchas: this is the recipe that resolves “why does my order say insufficient balance when I just deposited” — perp orders spend the perp balance, spot orders the spot balance, and a deposit lands on one side only. Mind the field naming: withdraw3 and usdSend carry a time field, but usdClassTransfer carries nonce inside the action — copying the withdraw shape here produces a signature for a payload the server will never reconstruct. Sending USDC to a different Hyperliquid address is its own action, usdSend (instant, stays on Hyperliquid, same user-signed scheme); sending a spot token is spotSend, whose token field wants the "NAME:tokenId" form from spotMeta — the docs publish a complete worked typed-data example for it, the one to read if you are hand-rolling user-signed actions. Testnet: works, with a funded testnet account. Reference: perp ⇄ spot transfer

Streaming live Hyperliquid prices over WebSocket

A smiling faucet pouring a continuous stream of colorful droplets into an open laptop, with a heartbeat pulse line running along the pipe

Docs say: connect to wss://api.hyperliquid.xyz/ws and send {"method": "subscribe", "subscription": {...}} messages. Two dozen subscription types exist — allMids, l2Book, trades, candle and bbo for market data; userFills, orderUpdates and others for account activity. The server closes any connection it hasn’t sent a message to in 60 seconds; {"method": "ping"} keeps it alive. Signing: none for subscriptions.

# pip install websocket-client
import json, threading, time, websocket

def on_open(ws):
    ws.send(json.dumps({"method": "subscribe",
                        "subscription": {"type": "trades", "coin": "BTC"}}))
    def heartbeat():
        while ws.keep_running:
            ws.send(json.dumps({"method": "ping"}))
            time.sleep(30)
    threading.Thread(target=heartbeat, daemon=True).start()

def on_message(ws, raw):
    msg = json.loads(raw)
    if msg["channel"] == "trades":
        for t in msg["data"]:
            print(t["px"], t["sz"], t["side"])
    elif msg["channel"] == "subscriptionResponse":
        print("subscribed:", msg["data"]["subscription"])
    elif msg["channel"] == "error":
        print("refused:", msg["data"])

ws = websocket.WebSocketApp("wss://api.hyperliquid.xyz/ws",
                            on_open=on_open, on_message=on_message)
ws.run_forever()

Gotchas: every successful subscribe is acknowledged on the subscriptionResponse channel, and a bad subscription arrives as a message on the error channel while the connection stays open — handle both channels or you will miss silent failures. One naming inconsistency is documented: userEvents messages arrive on the channel named user, so a router keyed strictly on subscription names drops that one feed. On reconnect, time-series subscriptions replay a snapshot tagged isSnapshot: true — filter it or you will double-count fills. The documented limits: 10 connections, 1,000 subscriptions, and 2,000 messages sent per minute, and the docs are blunt that disconnects “may happen periodically and without announcement”, so reconnect logic is part of the minimum viable client, not an optimization. The same socket can also carry ordinary info requests and even signed actions via post messages — the signature is computed exactly as over HTTP; the transport changes nothing about signing. Testnet: works (wss://api.hyperliquid-testnet.xyz/ws). Reference: subscriptions · timeouts and heartbeats

Hyperliquid API rate limits and common errors

A closed gate flanked by an hourglass meter on one side and a clear tube being fed coins on the other, with a small open archway beside it

Hyperliquid runs two unrelated limiters, and the second has no CEX analogue. The first is conventional: 1,200 request-weight per minute per IP across REST. Cheap reads cost weight 2 (allMids, l2Book, clearinghouseState, orderStatus), most other info requests cost 20 plus per-item surcharges on history queries, and exchange actions cost 1 + floor(batch/40). At weight 2 you can poll a price ten times a second all day; a naive userFills polling loop burns the same budget an order of magnitude faster — use the WebSocket for anything continuous.

The second limiter is per address and earned by trading: 1 action per 1 USDC of cumulative traded volume since the address’s creation, on top of a starting buffer of 10,000 actions. Run the buffer dry and the address is throttled to one action per 10 seconds until volume earns more. Two details soften it: reads never count against this limiter, and cancels get a carve-out (min(limit + 100000, limit * 2)) so a throttled bot can always pull its resting orders. One detail sharpens it: batching does not help — a batch of n orders counts as one request for the IP limiter (one request whose weight is the 1 + floor(batch/40) above) but as n for the address limiter. Check your own budget anytime, no signature needed:

print(info({"type": "userRateLimit", "user": ADDRESS}))
# {'cumVlm': ..., 'nRequestsUsed': ..., 'nRequestsCap': ..., 'nRequestsSurplus': ...}

The docs specify no HTTP status code for hitting either limiter — the documented behavior is the 10-second throttle, so key your retry logic on non-success responses with backoff rather than watching for a 429 that is not promised. Also documented: 1,000 open orders per user (growing with volume to a 5,000 cap), and actions canceled for a stale expiresAfter field cost 5× their usual address-based weight.

The errors you will actually meet, from the official error table and live testing:

ErrorMeaningUsual fix
User or API Wallet 0x… does not exist.wrong signature or unfunded accountthe address test below
Must deposit before performing actions. User: 0x…account never fundeddeposit first
Price must be divisible by tick size.price violates the 5-sig-fig / decimals rulethe rounding helper
Order must have minimum value of $10.notional below the perp floorsize from the live mid
Post only order would have immediately matched, bbo was …Alo priced through the bookreprice behind the echoed bbo, or use Gtc
Order could not immediately match against any resting orders.IOC found no liquidity within the limitwiden the aggressive buffer
Insufficient margin to place order.perp balance can’t carry the ordercheck withdrawable; funds may be on spot
Order was never placed, already canceled, or filled.canceling a dead oidbenign in cleanup loops
Action already expiredaction not accepted within 15 secondscheck connectivity; query state before retrying
HTTP 422, Failed to deserialize the JSON body…malformed body or unknown typefix the typo — this one is not a signing problem

The first row is deliberately ambiguous: a wrong signature makes the server recover a different signer address (which has no funds), and a correct signature from an unfunded account fails the same way. The error message itself tells you which one you have. Compare the address it echoes to your own: if it differs, your signature is wrong — go back to the offline test vector; if it matches, your signing is fine and the account simply needs a deposit. Both branches behave exactly this way in practice: a correct testnet signature from a fresh key echoes that key’s own address back, and the same action signed with the mainnet flag echoes a completely different, signature-derived address.

Running your Hyperliquid API code as a bot

The recipes above are the pieces; a bot is a loop that runs them unattended — read state, decide, act, repeat — plus the unglamorous machinery that keeps a loop alive: WebSocket reconnects with isSnapshot filtering, retries that query state before resending, and a kill switch. Hyperliquid’s docs endorse the pattern directly — the nonces page describes a recommended architecture of one API wallet per trading process feeding batched orders on a ~0.1-second cadence — and the platform ships the piece most exchanges make you build yourself: a scheduleCancel dead man’s switch, so your resting orders don’t outlive your process. For a single-threaded script, get_timestamp_ms() per action is a sufficient nonce; the atomic-counter designs in the docs only matter once you can sign twice in the same millisecond. That journey — from wallet setup through testnet rehearsal to a first order and an unattended loop — is the subject of our step-by-step Hyperliquid trading bot guide. What this guide deliberately does not cover is the strategy question — when to buy or sell — and the operational discipline around running money unattended; our algorithmic trading guide is the starting point for that side.

Conclusion and next steps

The pattern behind every recipe is the same: resolve names to asset indices on the network you are actually pointed at, build the action dict in documented field order, let the official signing module produce the signature, and read the verdict from inside the response rather than from the HTTP status. The unusual parts of Hyperliquid — no API keys, signatures instead of credentials, a rate limit earned by volume, market orders that are really IOC limits — all become mechanical once seen, and the offline test vector plus the error-address check will diagnose nearly every failed request. Rehearse everything order-shaped on the testnet; treat everything that moves money as master-key territory that your bot never touches; and remember that the orders and transfers your code submits are your own responsibility — code executes exactly the mistake you give it, at full speed. If you don’t have a funded Hyperliquid account yet, our Hyperliquid review covers onboarding, fees and features in detail.

All endpoints, actions, error strings and limits verified against the official Hyperliquid documentation on 2026-08-18, with live checks against the public API and signature verification against the official SDK’s test vectors. Specifications change — the reference links in each section point at the current official source.

Frequently asked questions

What is the Hyperliquid API?

Two HTTP endpoints and one WebSocket URL. POST /info answers every read — prices, candles, balances, positions, orders, fills — with no authentication at all. POST /exchange takes every write — orders, cancels, transfers, withdrawals — authorized by an EIP-712 wallet signature instead of an API key. This guide covers that native trading API for perps and basic spot.

Does Hyperliquid have API keys?

No. There is no API key, secret or passphrase anywhere. Your identity is an Ethereum address and every write is signed with a private key. The nearest analogue to a scoped API key is an API wallet (agent wallet): a separate keypair your account authorizes to sign trading actions on its behalf — it can place and cancel orders but can never withdraw or transfer funds.

Is the Hyperliquid API free to use?

There is no charge for API access and no paid tier. Orders placed through the API pay the same trading fees as the app, market data needs no account at all, and trading itself is gas-free. The costs that do exist: a $1 withdrawal fee (per the current docs), and a one-time 1 USDC activation fee on the first transfer into a brand-new account.

Can I test Hyperliquid API code without real money?

Partly. Every read works immediately with no account, and your signing code can be verified offline against the known-key test vectors in the official SDK. Placing test orders needs the testnet (api.hyperliquid-testnet.xyz), and its faucet — 1,000 mock USDC — only unlocks for addresses that have already deposited on mainnet.

Does the Hyperliquid API work for US users?

Hyperliquid's Terms of Use bar persons in the United States and Ontario, Canada from using the official interface, and the project states it holds no license in any jurisdiction. Read the Terms at app.hyperliquid.xyz/terms and check your own position before trading.

Which SDK should I use for the Hyperliquid API?

The only first-party SDK is hyperliquid-python-sdk, in the official hyperliquid-dex GitHub org — the docs treat its signing code as the reference implementation. The TypeScript and Rust SDKs linked from the docs are community-written, and CCXT is maintained by CCXT. The examples here use plain requests for every API call, the official SDK's signing module for signatures — the part the docs say not to hand-roll — and the SDK's one-line helper for creating an API wallet.