— Contents 19 sections
- 01 How the Hyperliquid API works
- 02 Before you start: wallets, API wallets, and one signing setup
- 03 Getting the current price from the Hyperliquid API
- 04 Fetching Hyperliquid candlestick data
- 05 Reading Hyperliquid funding rates
- 06 Checking your Hyperliquid balance and positions in Python
- 07 Placing a limit order with the Hyperliquid API
- 08 Placing a market order on Hyperliquid (there is no market order type)
- 09 Setting a stop-loss or take-profit on Hyperliquid
- 10 Canceling orders and listing open Hyperliquid orders
- 11 Checking Hyperliquid order status and trade history
- 12 Depositing to Hyperliquid from code (there is no deposit API)
- 13 Withdrawing from Hyperliquid with the API
- 14 Moving USDC between Hyperliquid perp and spot balances
- 15 Streaming live Hyperliquid prices over WebSocket
- 16 Hyperliquid API rate limits and common errors
- 17 Running your Hyperliquid API code as a bot
- 18 Conclusion and next steps
- 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
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:
| Surface | Where | What it is | In this guide |
|---|---|---|---|
| Info endpoint | POST https://api.hyperliquid.xyz/info | every read: prices, books, candles, balances, positions, orders, fills | Yes |
| Exchange endpoint | POST https://api.hyperliquid.xyz/exchange | every write: orders, cancels, leverage, transfers, withdrawals | Yes |
| WebSocket | wss://api.hyperliquid.xyz/ws | live subscriptions, plus an alternative transport for both endpoints above | Yes |
| HyperEVM | https://rpc.hyperliquid.xyz/evm | smart-contract development on the EVM half of the chain (JSON-RPC, chain id 999) | No — separate stack |
| Node / S3 archive | node software, s3://hyperliquid-archive | raw historical order-book data for research and latency-critical use | No |
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
/exchangerequest carries anaction, anonce(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 term | Meaning |
|---|---|
Px | price |
Sz | size, in units of the base coin |
Szi | signed size — positive long, negative short |
Ntl | notional, Px * Sz in USD |
Side | B = bid/buy, A = ask/sell |
Tif | time-in-force: Gtc (rest until canceled), Alo (post-only), Ioc (fill or cancel the rest) |
a / b / p / s / r / t / c | in order actions: asset id / isBuy / price / size / reduceOnly / order type / client order id |
Before you start: wallets, API wallets, and one signing setup
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
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
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
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)
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
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
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:
| Error | Meaning | Usual fix |
|---|---|---|
User or API Wallet 0x… does not exist. | wrong signature or unfunded account | the address test below |
Must deposit before performing actions. User: 0x… | account never funded | deposit first |
Price must be divisible by tick size. | price violates the 5-sig-fig / decimals rule | the rounding helper |
Order must have minimum value of $10. | notional below the perp floor | size from the live mid |
Post only order would have immediately matched, bbo was … | Alo priced through the book | reprice behind the echoed bbo, or use Gtc |
Order could not immediately match against any resting orders. | IOC found no liquidity within the limit | widen the aggressive buffer |
Insufficient margin to place order. | perp balance can’t carry the order | check withdrawable; funds may be on spot |
Order was never placed, already canceled, or filled. | canceling a dead oid | benign in cleanup loops |
Action already expired | action not accepted within 15 seconds | check connectivity; query state before retrying |
HTTP 422, Failed to deserialize the JSON body… | malformed body or unknown type | fix 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.