Building a Hyperliquid Client in Python – Part III
Part III of the Hyperliquid client series covers private API endpoints, secure EIP-712 signing, and comprehensive order management. It offers practical guidance for account functions, error handling, and safe Testnet experimentation before transitioning to Mainnet.
Posted by
Related reading
Building a Hyperliquid Client in Python – Part II
This hands-on continuation of the Hyperliquid client series focuses on implementing and using public /info endpoints. It details key data retrieval functions such as funding rates, candlesticks, and order books, and establishes a foundation for robust, production-ready API interactions.
Building a Hyperliquid Client in Python – Part I
A practical, developer-focused introduction to building a Python client for Hyperliquid perpetual futures from scratch. Covers core concepts like APIs, orderbooks, and perps while setting up the foundation for querying data and interacting with the exchange.

Introduction
Part I explained how to start building a Hyperliquid client.
Part II covered the /info endpoint’s public methods, which need no user or vault address.
Having established these foundations, let's now move to Part III, which introduces both /info private methods and /exchange methods.
For clarity, the HyperliquidClient example here minimizes error handling. In reality, strong failure handling is crucial. APIs can send unexpected payloads, rate-limit notices, or face connection issues. In live trading, ignoring these can mean missed fills, duplicate orders, or unwanted positions. A production client needs features like retries with backoff, request timeouts, and detailed logging to spot and fix problems.
One disclaimer: I am a researcher, not a professional developer. While the code has worked well during my own testing and examples, it has not gone through comprehensive production-level vetting. Consider it a learning tool or starting point rather than a production-ready solution.
Trading futures is high risk. You can lose more than your initial margin. This material is for educational purposes only.
Note: All examples use the Hyperliquid Testnet, a sandbox with simulated assets and no real risk. Testnet is the safest place to try API calls and signing before moving to Mainnet, where real capital is at stake.
Private /info methods
I will start by writing the private /info methods.
Account info
def get_account_info(self):
payload = {
'type': 'clearinghouseState',
'user': self.user_address,
}
return self.post(payload)The get_account_info function gets the current account information, including positions.
Open orders
def get_open_orders(self,
coin: str | None = None):
"""
Returns all open orders for the user, optionally filtered by coin.
"""
payload = {"type": "openOrders", "user": self.user_address}
resp = self.post(payload)
# normalize shape
orders = resp.get("openOrders", resp) if isinstance(resp, dict) else resp
if not isinstance(orders, list):
return []
if coin is None:
return orders
sym = coin.upper()
return [
o for o in orders
if (o.get("order", {}).get("coin") or o.get("coin", "")).upper() == sym
]
Recent trades
def get_my_trades(
self,
start_time=None,
end_time=None, # defaults to now if start_time is not set
aggregate_by_time: bool = True,
coin: str | None = None,
):
"""
Fetch recent fills (executed trades) for this account.
Uses 'userFills' (latest up to 2000) or 'userFillsByTime'
(time window; max 2000 per call).
Only the ~10,000 most recent fills are available server-side.
"""
st = None
if start_time is not None:
st = start_time
if not isinstance(st, int):
try:
st = timestamp_2_int(start_time, unit='ms')
except Exception:
print(f'Start time {start_time} not in the
correct format, it will be ignored')
st = None
if st is None:
payload = {
"type": "userFills",
"user": self.user_address,
"aggregateByTime": bool(aggregate_by_time),
}
if end_time is not None:
print("start_time is None, end_time will be ignored")
fills = self.post(payload)
else:
et = int(time.time() * 1000)
if end_time is not None:
et = end_time
if not isinstance(et, int):
try:
et = timestamp_2_int(end_time, unit='ms')
except Exception:
print(f'End time {end_time} not in the
correct format, it will be ignored')
et = int(time.time() * 1000)
payload = {
"type": "userFillsByTime",
"user": self.user_address,
"startTime": int(st),
"endTime": int(et),
"aggregateByTime": bool(aggregate_by_time),
}
fills = self.post(payload)
# end if
# flatten if server returns {"fill": {...}}
fills = [f.get("fill", f) for f in (fills or [])]
if coin is not None:
sym = coin.upper()
c_fills = [f for f in fills if f.get("coin", "").upper() == sym]
return c_fills
return fills
Signing
Hyperliquid’s docs strongly recommend using one of their existing SDKs: “It is recommended to use an existing SDK instead of manually generating signatures. There are many potential ways in which signatures can be wrong.” (see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/signing)
For this series, however, I’ve chosen to implement the signing manually, adapting the approach from the async-hyperliquid project. This gives us more control and a deeper understanding of how the API works under the hood.
To submit an action to Hyperliquid you must prove intent with an EIP-712 signature. Hyperliquid wraps a connectionId (a Keccak-256 hash of your action payload + an 8-byte nonce + an optional vault flag/address) inside a simple Agent typed-data message and asks you to sign it. This binds the signature to exactly what you’re doing (the msgpacked action), when you’re doing it (the nonce), and who you’re doing it as (wallet or vault), while staying replay-safe and frontend-agnostic.
EIP-712 is an Ethereum standard that defines how to encode, hash, and sign typed structured data with a domain separator, producing a deterministic digest that wallets can show and systems can verify. It improves security by preventing cross-domain replays and reduces phishing by making the signed fields explicit.
Keccak-256 is a cryptographic hash function that takes any bytes as input and deterministically produces a fixed-length 32-byte (256-bit) digest. It is used across Ethereum for things like addresses, logs, and signed message digests. In our Beyond the Buzzwords: Building a Real Blockchain from First Principles blog I give an example of how SHA-256 works, but, of course, Keccak-256 and SHA-256 are different hash functions.
def hash_action(self,
action: dict,
nonce: int) -> bytes:
# Hyperliquid defines the connectionId as (msgpack + nonce + vault flag).
data = msgpack.packb(action)
data += nonce.to_bytes(8, "big")
vault = self.vault_address
if vault is None:
data += b"\x00"
else:
data += b"\x01"
data += bytes.fromhex(vault.removeprefix("0x"))
return keccak(data)
msgpack (MessagePack) is a compact, binary serialization format (think “binary JSON”). It encodes common data types (ints, floats, bools, strings, bytes, arrays/lists, maps/dicts) into a small, fast-to-parse byte stream. It’s widely used when you need JSON-like structures but with less overhead and exact binary fidelity.
def sign_action(self,
action: dict,
nonce: int) -> dict:
h = self.hash_action(action, nonce)
# wrap that connectionId in the EIP-712 Agent schema and sign it
# 1) EIP-712 typed data (Agent)
typed = {
"domain": {
"name": "Exchange",
"version": "1",
"chainId": 1337,
"verifyingContract": "0x0000000000000000000000000000000000000000",
},
"types": {
"Agent": [
{"name": "source", "type": "string"},
{"name": "connectionId", "type": "bytes32"},
],
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
],
},
"primaryType": "Agent",
"message": {
"source": "a" if self.is_mainnet else "b",
"connectionId": h, # bytes32
},
}
# 2) Build SignableMessage
signable = encode_typed_data(full_message=typed)
# 3) Sign -> return JSON-safe fields
signed = self.account.sign_message(signable)
return {"r": to_hex(signed.r), "s": to_hex(signed.s), "v": int(signed.v)}
def get_nonce(self) -> int:
"""
Hyperliquid requires nonces to be:
- unique (no repeats),
- strictly increasing (every new nonce > previous),
- in milliseconds, close to current time,
- within [now - 2 days, now + 1 day].
This function generates such nonces locally.
"""
n = int(time.time() * 1000) # 1) Current Unix time in ms
if n <= self.last_nonce: # 2) If we already used this ms
n = self.last_nonce + 1 # (or got a smaller one) bump it up by 1
self.last_nonce = n # 3) Store it as the new "last used" nonce
return n
/exchange methods
Cancel order
def cancel_order(self,
coin_idx: int,
oid: int):
payload = {"type": "cancel", "cancels": [{"a": coin_idx, "o": int(oid)}]}
resp = self.execute_order(payload)
return resp
Execute order
def execute_order(self,
action: dict,
expires_after_ms: int | None = None):
if not action:
print("action was empty")
return None
nonce = self.get_nonce()
sig = self.sign_action(action, nonce=nonce)
payload = {"action": action, "nonce": nonce, "signature": sig}
if expires_after_ms is not None:
payload["expiresAfter"] = int(expires_after_ms)
if self.vault_address:
payload["vaultAddress"] = self.vault_address.lower()
r = self.post(payload, endpoint="exchange")
return r
Set leverage and margin
def set_leverage(self,
coin_idx: int,
leverage: int,
is_cross: bool = True):
"""
Set cross or isolated leverage for a coin.
- leverage: integer between 1 and the coin's maxLeverage
- is_cross: True = cross, False = isolated
"""
action = {
"type": "updateLeverage",
"asset": coin_idx,
"isCross": bool(is_cross),
"leverage": int(leverage),
}
return self.execute_order(action)
Order building util function
def build_limit_order(self,
coin_idx: int,
is_buy: bool,
sz: float | str,
px: float | str,
tif: str = "Gtc",
reduce_only: bool = False,
cloid: str | None = None):
pxs = str(px)
szs = str(sz)
order = {
"a": coin_idx,
"b": bool(is_buy),
"p": pxs.rstrip('0').rstrip('.') if '.' in pxs else pxs,
"s": szs.rstrip('0').rstrip('.') if '.' in szs else szs,
"r": bool(reduce_only),
"t": {"limit": {"tif": tif}},
}
if cloid:
order["c"] = cloid
return {"type": "order", "orders": [order], "grouping": "na"}
Example usage
In this section I will show how to use this code. I will be using my vault on the Testnet.
Client initialization
client = HyperliquidClient(
test_private_key,
is_mainnet=False,
user_address=None,
vault_address=test_vault_key,
)Account snapshot
client.get_account_info()
{'marginSummary': {'accountValue': '450.0',
'totalNtlPos': '0.0',
'totalRawUsd': '450.0',
'totalMarginUsed': '0.0'},
'crossMarginSummary': {'accountValue': '450.0',
'totalNtlPos': '0.0',
'totalRawUsd': '450.0',
'totalMarginUsed': '0.0'},
'crossMaintenanceMarginUsed': '0.0',
'withdrawable': '450.0',
'assetPositions': [],
'time': 1756626195756}
At the moment I have the initial amount I deposited in the vault and no positions.
Setting margin mode and leverage
Both margin mode and leverage are set in one go.
symbol_info = client.get_symbol_info()
coin_idx = symbol_info['HYPE']['assetIndex']
client.set_leverage(coin_idx, 5, False)
{'status': 'ok', 'response': {'type': 'default'}}
With this action I have set HYPE's leverage to 5 and the margin mode to isolated.
Executing a limit order
symbol_info = client.get_symbol_info()
coin = "HYPE"
px = "35.0"
sz = "1"
coin_idx = symbol_info[coin]["assetIndex"]
limit_order = client.build_limit_order(
coin_idx, is_buy=True, sz=sz, px=px, tif="Gtc"
)
client.execute_order(limit_order)
{'status': 'ok',
'response':
{'type': 'order',
'data': {'statuses': [{'resting': {'oid': 38317782182}}]}}}
Checking for open orders
client.get_open_orders(coin='HYPE')
[{'coin': 'HYPE',
'side': 'B',
'limitPx': '35.0',
'sz': '1.0',
'oid': 38317782182,
'timestamp': 1756639388523,
'origSz': '1.0'}]
Canceling open orders
client.cancel_order(coin_idx, 38317782182)
{'status': 'ok',
'response': {'type': 'cancel', 'data': {'statuses': ['success']}}}
client.get_open_orders(coin='HYPE')
[]
Executing a market order
Hyperliquid’s exchange endpoint doesn’t expose a standalone “market” order type.
But it is possible to build a market-like order, following these steps:
- Get the order book.
- From the order book, get a price that will very likely have your order fully executed.
- Make sure the previous price is valid (otherwise the order will be rejected).
- Use the
build_limit_ordermethod to build the order usingtif="Ioc"(immediate or cancel).
Checking positions and trades
Let's now consider another limit order example, this time one that will actually trade:
symbol_info = client.get_symbol_info()
coin = "BTC"
px = "114011"
sz = "0.0001"
coin_idx = symbol_info[coin]["assetIndex"]
limit_order = client.build_limit_order(
coin_idx, is_buy=True, sz=sz, px=px, tif="Ioc"
)
This order is a “market-like order”. I chose a price so the order would be executed with high probability, and I used tif="Ioc". Of course, as with any other limit order, there is no guarantee of execution.
Order execution:
client.execute_order(limit_order)
{'status': 'ok',
'response': {'type': 'order',
'data': {'statuses': [{'filled': {'totalSz': '0.0001',
'avgPx': '113997.0',
'oid': 38980114448}}]}}}
The order was executed for a total size of 0.0001 BTC (so I was able to trade my entire desired quantity) at an average price of 113,997, which is below the price I was willing to pay.
I can now check my BTC positions:
client.get_positions(coin='BTC')
{'type': 'oneWay',
'position': {'coin': 'BTC',
'szi': '0.0001',
'leverage': {'type': 'isolated', 'value': 5, 'rawUsd': '-9.123671'},
'entryPx': '113997.0',
'positionValue': '11.3951',
'unrealizedPnl': '-0.0046',
'returnOnEquity': '-0.002017597',
'liquidationPx': '92391.6050632911',
'marginUsed': '2.271429',
'maxLeverage': 40,
'cumFunding': {'allTime': '0.000142',
'sinceOpen': '0.000142',
'sinceChange': '0.000142'}}}
Checking the trades:
client.get_my_trades(
start_time=None,
end_time=None,
aggregate_by_time=True,
coin=None,
)
[{'coin': 'BTC',
'px': '113997.0',
'sz': '0.0001',
'side': 'B',
'time': 1757586657221,
'startPosition': '0.0',
'dir': 'Open Long',
'closedPnl': '0.0',
'hash': '0x407b0e9ee3dc736141f40419a65a5f01020026847edf9233e443b9f1a2d04d4b',
'oid': 38980114448,
'crossed': True,
'fee': '0.005129',
'tid': 833390239121581,
'feeToken': 'USDC',
'twapId': None}]
Conclusion
This series has walked through the essential steps to building a Hyperliquid client. In Part I, we explored the different Hyperliquid APIs, clarified the distinction between Mainnet and Testnet, and demonstrated how to obtain API keys. Part II focused on implementing Hyperliquid's /info public functions, which do not require user or vault addresses. The final section covered private /info and/exchange functions that require signing, such as checking account status, adjusting leverage and margin, managing orders, and monitoring trades and positions.
While this guide provides a foundation for understanding and interacting with Hyperliquid’s API, building a robust production client will require extra care around error handling, security, and rigorous testing. I encourage you to experiment on Testnet, explore further features, and consider contributing improvements back to the community.
Want deeper insights into risk and trading strategies? Subscribe to Trading Shepherd today and stay ahead of market volatility!"