Skip to content
BuildBuild on HyperCore16 pages
Architecture: info, exchange, WebSocketFoundationHyperliquid's API has three surfaces: an unsigned read endpoint, a signed write endpoint, and a WebSocket that streams data and can carry both.Your first requestFoundationReading Hyperliquid needs no key, no account and no risk: three unsigned POST requests return the list of markets, an order book and any address's positions.Asset ids and namingPractitionerOrders name a market by an integer asset id and info requests by a coin string, and perps, spot, HIP-3 and outcomes each derive both by a different rule.Precision: tick size, lot size, significant figuresPractitionerA HyperCore price has at most 5 significant figures and a decimal cap set by szDecimals, a size at most szDecimals decimals, and outcomes use a 0.00001 grid.Signing from first principlesExpertL1 actions sign a keccak hash of their msgpack bytes, user-signed actions sign readable EIP-712 data, and one wrong byte in either recovers a stranger's address.Nonces, expiry and API walletsPractitionerHyperCore keeps each signer's 100 highest nonces and accepts a new one only if unused, above the smallest, and within 2 days before and 1 day after block time.Orders: place, modify, cancel, batch, dead man's switchPractitionerAn order is a signed action carrying a batch of compact orders; modify, cancel, TWAP and the dead man's switch are separate actions, each with its own response.Transfers and withdrawalsPractitionerEvery HyperCore transfer is a signed action: sends and withdrawals need the account's own key, and an API wallet's transfer actions keep value inside the account.Multi-sig actionsExpertA multi-sig action carries enough authorised users' signatures over one nonce and one named leader, in an envelope only the leader signs and submits.WebSocket: subscriptions, post requests, heartbeatsPractitionerOne WebSocket streams book, trade and account updates as blocks execute and carries signed actions, but drops without warning and forgets every subscription.Rate limits and user limitsPractitionerHyperliquid meters each IP by request weight, 1200 per minute, and each address by actions: 10,000 plus one per USDC traded.Priority fees and the activation feeExpertHyperliquid sells latency in two burned auctions, read priority per IP and write priority per order, and charges 1 quote token to activate a new account.Error responsesPractitionerHyperliquid reports errors at four layers, from HTTP status to a string inside a successful response, and several name a cause other than the real one.Debugging signatures and wire shapesExpertA signature over wrong bytes still verifies, so HyperCore recovers a stranger's address and says it does not exist; the fix is almost always the encoding.Optimising latencyExpertMost of an order's round trip is consensus; code wins by streaming reads, cutting serial round trips, using the fast order class and never stalling orders.Data: historical, node and explorerPractitionerThe API keeps only recent history; a full record needs pagination, archived node data or your own node, and each trade counted once by its trade id.
BuildBuild on the HyperEVM9 pages
What the HyperEVM is, and is notFoundationThe HyperEVM is Hyperliquid's smart contract environment: it reads HyperCore state and queues HyperCore actions, but never executes them atomically.Blocks, gas and RPCPractitionerThe HyperEVM produces small blocks every second and big blocks every minute, prices gas with EIP-1559 in HYPE, and serves a JSON-RPC with no historical state.Moving assets between HyperCore and the HyperEVMPractitionerLinked tokens cross between HyperCore and the HyperEVM via per-token system addresses, with a decimal offset and a 200k-gas fee when leaving HyperCore.Linking a tokenExpertLinking binds a HyperCore token to one ERC20 through the spot deployer's request and the contract deployer's finalisation; the protocol checks almost nothing else.Reading HyperCore: precompilesExpertRead precompiles are 21 fixed addresses, 0x800 to 0x814, that return HyperCore state as of the EVM block's construction, and only at the latest block.Writing to HyperCore: CoreWriterExpertCoreWriter turns a contract call into a HyperCore action for the caller's own account, run after the EVM block, with no result returned and no revert on refusal.Execution order and timingExpertEach L1 block runs the EVM block, then EVM to HyperCore transfers, then CoreWriter actions; on mainnet most actions land within half a second, vault transfers in about 4 s.PatternsExpertA contract that touches HyperCore stays solvent by recording an intent, sending the action, and settling only on HyperCore state that visibly moved.Footguns, measuredExpertTwelve ways a HyperEVM contract loses money or silently does nothing when it touches HyperCore, each with the on-chain evidence and the fix.

Signing from first principles

Expert

L1 actions sign a keccak hash of their msgpack bytes, user-signed actions sign readable EIP-712 data, and one wrong byte in either recovers a stranger's address.

HyperCore has no logins: your action is authorised when its signature recovers the account, or an API wallet it approved. The server rebuilds the bytes it expects from your action. Sign a price as "0.650" where the server hashes "0.65", and recovery returns a different, valid-looking address; the action is refused, usually as coming from an account that does not exist.

Scheme For Form
L1 action Trading, in many shapes One 32-byte hash in a fixed two-field struct, so one type definition covers every action
User-signed action Transfers, withdrawals and approvals, which move funds or grant power Typed data the wallet can display (“human readable”, in the docs’ words)

The docs advise using an existing SDK. This page is for writing, auditing or debugging one.

HASH INPUT, IN THIS ORDER msgpack(action) keys in canonical order nonce u64, big-endian vault marker 00, or 01 + 20 bytes expiry 00 + u64, only if set keccak256 of the bytes = connectionId EIP-712 Agent(string source, bytes32 connectionId) domain Exchange, version 1, chainId 1337, zero verifyingContract signature { r, s, v }
Every L1 action is reduced to one 32-byte hash. The signer only ever signs the two-field Agent struct.

The bytes, in order:

Step Bytes Notes
1 msgpack(action) The action object, keys in canonical order (below)
2 nonce, 8 bytes, big-endian The same value as nonce in the request body
3 0x00, or 0x01 followed by the 20-byte vault address 0x01 form when vaultAddress is set
4 only if expiresAfter is set: 0x00 followed by expiresAfter, 8 bytes, big-endian The marker is 0x00, not 0x01; with no expiry, nothing is appended

connectionId = keccak256(bytes), signed as EIP-712 typed data:

domain = { name: "Exchange", version: "1", chainId: 1337,
verifyingContract: 0x0000000000000000000000000000000000000000 }
type = Agent(string source, bytes32 connectionId)
message = { source: "a" on mainnet, "b" on testnet, connectionId }

The domain is the same on both networks: source alone picks the network.

The server hashes its own msgpack re-encoding of your parsed action, with its field order and types, not your JSON. Your bytes must match:

# Rule Detail
1 Key order is hashed msgpack keeps insertion order. Build objects in the server’s order, never from an unordered map (that broke every signature in one codebase).
2 Decimals are strings, trailing zeros stripped "10.0" is "10", "0.650" is "0.65". The docs call trailing zeros a common cause of bad signatures, yet their splitOutcome example writes "123.0": sign "123". Guard against "1e-7".
3 Integers take the smallest msgpack width Some encoders write a JavaScript number of 2^32 or more as float64 unless it is a BigInt. Order ids pass that size: the docs show oid 287286372177.
4 Omit or null exactly as the server does Table below. Undefined keys are removed, not encoded as nil.
5 Addresses are lowercase A checksummed builder address recovers the wrong signer. The docs recommend lowercasing every address before signing and sending.
6 cloid is a 128-bit hex string For example 0x1234567890abcdef1234567890abcdef, not raw bytes.
7 Deployer actions sort lists of tuples In HIP-3 and HIP-4. A HIP-4 keywordToValue is signed as [key, value] pairs sorted by key; signing the object recovers someone else’s address.

Canonical key orders:

Object Key order
order action type, orders, grouping, builder (builder only if present)
each order a, b, p, s, r, t, c (c only if set)
t for a limit {"limit": {"tif": ...}}
t for a trigger {"trigger": {"isMarket", "triggerPx", "tpsl"}}, in that order
builder b, f
cancel action type, cancels; each cancel a, o
modify action type, oid, order
batchModify action type, modifies; each entry oid, order

Omit or null:

Field Rule
c on an order Omit when there is no cloid
builder on an order action Omit when there is no builder
f on cancel and cancelByCloid Omit when false; an action hashed with f: false is rejected
a on modify and batchModify Omit when false; an action hashed with a: false is rejected
destination on reserveRequestWeight Omit when unset; the documentation says it is skipped in hashing
time on scheduleCancel Omit to clear a scheduled cancel
amount on mergeOutcome and mergeQuestion Send null to merge the maximum
amount on borrowLend Send null when not given

These are ordinary EIP-712 typed data, field by field:

domain = { name: "HyperliquidSignTransaction", version: "1",
chainId: <signatureChainId as an integer>,
verifyingContract: 0x0000000000000000000000000000000000000000 }
primaryType = "HyperliquidTransaction:<Name>"
message = the action's fields named in the type, in the type's order
Field Rule
signatureChainId Only has to equal the domain’s chain id. Convention: 0xa4b1 (42161, Arbitrum) on mainnet, 0x66eee (421614, Arbitrum Sepolia) on testnet; the docs’ example signs with 42161.
hyperliquidChain "Mainnet" or "Testnet", inside the signed message: binds the network.
time The in-action nonce of usdSend, spotSend and withdraw3; equals the outer nonce.
nonce The in-action nonce of sendAsset, sendToEvmWithData, usdClassTransfer, cDeposit, cWithdraw, tokenDelegate, approveAgent, approveBuilderFee, userSetAbstraction and userDexAbstraction; equals the outer nonce.
destination Typed string, so its case is hashed: send it lowercase.
agentName approveAgent without a name signs the empty string.
expiresAfter, vaultAddress Not taken.

The docs publish two type tables: spotSend, and withdraw3, typed Withdraw, not Withdraw3. The rest match in at least two independent SDKs:

HyperliquidTransaction:UsdSend(string hyperliquidChain,string destination,string amount,uint64 time)
HyperliquidTransaction:SpotSend(string hyperliquidChain,string destination,string token,string amount,uint64 time)
HyperliquidTransaction:Withdraw(string hyperliquidChain,string destination,string amount,uint64 time)
HyperliquidTransaction:SendAsset(string hyperliquidChain,string destination,string sourceDex,string destinationDex,string token,string amount,string fromSubAccount,uint64 nonce)
HyperliquidTransaction:UsdClassTransfer(string hyperliquidChain,string amount,bool toPerp,uint64 nonce)
HyperliquidTransaction:ApproveAgent(string hyperliquidChain,address agentAddress,string agentName,uint64 nonce)
HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)
HyperliquidTransaction:UserSetAbstraction(string hyperliquidChain,address user,string abstraction,uint64 nonce)
HyperliquidTransaction:ConvertToMultiSigUser(string hyperliquidChain,string signers,uint64 nonce)

Field order is hashed: one swapped field signs a different message and fails like a bad msgpack encoding.

A documented action carrying hyperliquidChain and signatureChainId is user-signed; every other is L1, except the multiSig wrapper (multi-sig actions). That rule and the two published type tables settle four SDK disagreements:

Action Disagreement Per the documentation
withdraw3 One SDK names the type Withdraw3 The documentation’s own type is HyperliquidTransaction:Withdraw
cDeposit, cWithdraw One SDK signs them as L1 actions They carry hyperliquidChain and signatureChainId: user-signed, by the account’s own key
tokenDelegate One SDK omits the nonce field The action carries nonce, matching the outer nonce
scheduleCancel clear One SDK sends time: null Leave time out to remove the schedule; see orders
Constant Value
L1 domain name Exchange, version 1, chainId 1337, verifyingContract zero, on both networks
L1 struct Agent(string source,bytes32 connectionId)
source "a" mainnet, "b" testnet
Nonce in the hash 8 bytes, big-endian
Vault marker 0x00 without a vault; 0x01 plus 20 bytes with one
Expiry suffix 0x00 plus 8 bytes big-endian, only when expiresAfter is set
User-signed domain name HyperliquidSignTransaction, version 1, chainId from signatureChainId, verifyingContract zero
signatureChainId convention 0xa4b1 mainnet, 0x66eee testnet
hyperliquidChain "Mainnet" or "Testnet"
Signature on the wire { "r": "0x...", "s": "0x...", "v": 27 or 28 }

An illustrative implementation with viem and @msgpack/msgpack. It reproduces the order-hash and user-signed golden vectors; test any port against both.

// Illustrative. Build the action with keys in canonical order and decimals stripped first.
import { encode } from "@msgpack/msgpack";
import { concat, hexToBytes, keccak256, numberToBytes, parseSignature, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
const ZERO = "0x0000000000000000000000000000000000000000";
type L1Opts = { nonce: number; vaultAddress?: Hex; expiresAfter?: number };
// connectionId = keccak256(msgpack(action) | nonce | vault marker | expiry suffix)
export function l1ActionHash(action: object, { nonce, vaultAddress, expiresAfter }: L1Opts): Hex {
const parts: Uint8Array[] = [
encode(action, { ignoreUndefined: true }), // keys hashed in insertion order
numberToBytes(BigInt(nonce), { size: 8 }), // u64 big-endian
vaultAddress ? concat([new Uint8Array([1]), hexToBytes(vaultAddress)]) : new Uint8Array([0]),
];
if (expiresAfter !== undefined) {
parts.push(new Uint8Array([0]), numberToBytes(BigInt(expiresAfter), { size: 8 }));
}
return keccak256(concat(parts));
}
export async function signL1Action(key: Hex, action: object, opts: L1Opts & { mainnet: boolean }) {
const sig = await privateKeyToAccount(key).signTypedData({
domain: { name: "Exchange", version: "1", chainId: 1337, verifyingContract: ZERO },
types: { Agent: [{ name: "source", type: "string" }, { name: "connectionId", type: "bytes32" }] },
primaryType: "Agent",
message: { source: opts.mainnet ? "a" : "b", connectionId: l1ActionHash(action, opts) },
});
const { r, s, v } = parseSignature(sig);
return { r, s, v: Number(v) }; // the exchange takes { r, s, v } with v 27 or 28
}
export async function signUserAction(
key: Hex,
action: { signatureChainId: Hex } & Record<string, unknown>,
primaryType: string, // "HyperliquidTransaction:UsdSend" and so on
fields: { name: string; type: string }[], // order is part of the hash
) {
const sig = await privateKeyToAccount(key).signTypedData({
domain: {
name: "HyperliquidSignTransaction",
version: "1",
chainId: Number(BigInt(action.signatureChainId)), // must equal signatureChainId
verifyingContract: ZERO,
},
types: { [primaryType]: fields },
primaryType,
message: Object.fromEntries(fields.map((f) => [f.name, action[f.name]])),
});
const { r, s, v } = parseSignature(sig);
return { r, s, v: Number(v) };
}

Using it:

const action = {
type: "order",
orders: [{ a: 0, b: true, p: "50000", s: "0.001", r: false, t: { limit: { tif: "Gtc" } } }],
grouping: "na",
};
const nonce = 1700000000000; // from a nonce generator; see the nonces page
const signature = await signL1Action(KEY, action, { nonce, mainnet: true });
const body = JSON.stringify({ action, nonce, signature }); // POST to /exchange
const usdSend = {
type: "usdSend",
signatureChainId: "0xa4b1",
hyperliquidChain: "Mainnet",
destination: "0x0d1d9635d0640821d15e323ac8adadfa9c111414", // lowercase: typed string
amount: "1",
time: 1690393044548, // equals the outer nonce
} as const;
const userSig = await signUserAction(KEY, usdSend, "HyperliquidTransaction:UsdSend", [
{ name: "hyperliquidChain", type: "string" },
{ name: "destination", type: "string" },
{ name: "amount", type: "string" },
{ name: "time", type: "uint64" },
]);
const userBody = JSON.stringify({ action: usdSend, nonce: usdSend.time, signature: userSig });

@msgpack/msgpack encodes safe integers of 2^32 and above as uint64 by default; with useBigInt64 on, they become float64 unless passed as BigInt (rule 3).

  • Browser wallets refuse chain id 1337, so they cannot sign L1 actions. Use an approved API wallet (a raw key) or a placeholder chain-1337 network in the wallet; activateOutcomeDeployer rejects agent signatures, so a master key in a browser wallet needs the placeholder network.
  • The L1 domain is always 1337. Signing under 42161 recovers the wrong address.
  • Local recovery proves only self-consistency. The server rebuilds from the parsed action, where bytes diverge.
  • Mixing the schemes (a user-signed action through the L1 path, or the reverse) is the first common error the docs list.
  • Wrong network, right encoding. source: "b" on mainnet, or hyperliquidChain: "Testnet" in a mainnet action, is valid for the other network and recovers a different address here.

For the checklist, the probe and golden vectors, see Debugging signatures and wire shapes. For nonces and who may sign, see Nonces and API wallets.