Markets.xyz

Trading API

Sign every action yourself: prepare, verify, sign and submit.

Last updated

On a client-signed account the API never holds a key. Every exchange action comes back as EIP-712 typed data; you sign it yourself and send back only the signature. Choose this mode if your security policy requires you to hold every signing key. We set the mode when we create your account; GET /v1/account shows it in signing_mode (client).

Everything else (endpoints, request bodies, results, errors) is the same as on a managed account.

Who signs what

Action

Signed by

Typed data

Orders, market orders, closes, cancels, modifies, leverage, schedule-cancel

Your master wallet or an agent it approved

Agent, domain Exchange, chainId 1337

Approving an agent

Master wallet only

HyperliquidTransaction:ApproveAgent, chainId of your choice

Approving the builder fee

Master wallet only

HyperliquidTransaction:ApproveBuilderFee, chainId of your choice

The usual setup: generate an agent key (Hyperliquid calls it an API wallet) and keep it in your trading systems. Your master wallet approves it once and then stays cold. The agent can trade but can never withdraw or transfer, and the master can revoke it on Hyperliquid at any time.

Onboarding

  1. Generate an agent key and store it securely. It never goes to the API.
  2. POST /v1/agents/approve with {"agent_address": "0x…", "agent_name": "bot"}. Sign the returned action with the master wallet and submit it.
  3. POST /v1/builder-fee/approve. Sign with the master wallet and submit.
  4. GET /v1/account/status shows ready_to_trade: true and your agent under signers.approved_agents.

Approvals take an optional signature_chain_id: the chainId to sign under. Browser wallets only sign for their active chain, so pass it. Hyperliquid only requires the typed data and the action to agree, and the API sets both.

Client-signed accounts are not switched to unified account mode automatically. If you trade HIP-3 markets, call POST /v1/account/unified-account and sign it with the master wallet.

Prepare, sign, submit

1. Prepare

Call any trading endpoint as usual. Instead of a result, you get a prepared action:

json
{
  "id": "5fcab0cf-45f1-43e3-b02f-b06c05409445",
  "kind": "order",
  "status": "prepared",
  "expires_at": "2026-09-27T07:12:45.660480Z",
  "nonce": 1790493105660,
  "builder_fee_tenths_bps": 50,
  "action": {
    "type": "order",
    "orders": [
      {
        "a": 4,
        "b": true,
        "p": "2500",
        "s": "0.01",
        "r": false,
        "t": { "limit": { "tif": "Alo" } }
      }
    ],
    "grouping": "na",
    "builder": { "b": "0x…", "f": 50 }
  },
  "typed_data": {
    "domain": {
      "name": "Exchange",
      "version": "1",
      "chainId": 1337,
      "verifyingContract": "0x0000000000000000000000000000000000000000"
    },
    "primaryType": "Agent",
    "types": {
      "EIP712Domain": ["…"],
      "Agent": [
        { "name": "source", "type": "string" },
        { "name": "connectionId", "type": "bytes32" }
      ]
    },
    "message": { "source": "a", "connectionId": "0xc356a095…22b8" }
  }
}

action and nonce are exactly what will be sent to Hyperliquid. typed_data is complete eth_signTypedData_v4 JSON, ready to sign.

2. Verify

A trading payload is an opaque hash (connectionId), so no wallet can show you what you are signing. Recompute it from action and nonce, check it matches, then inspect action itself: asset a, side b, price p, size s, builder fee builder.f.

text
connectionId = keccak256( msgpack(action) || nonce as u64 big-endian || 0x00 )

The API returns action in Hyperliquid's field order, so hash the JSON exactly as received. In Python, with msgpack and eth-utils:

python
data = msgpack.packb(prepared["action"]) + prepared["nonce"].to_bytes(8, "big") + b"\x00"
assert "0x" + keccak(data).hex() == prepared["typed_data"]["message"]["connectionId"]

In TypeScript, with @msgpack/msgpack and viem:

ts
const packed = encode(prepared.action)
const bytes = new Uint8Array(packed.length + 9)
bytes.set(packed)
new DataView(bytes.buffer).setBigUint64(packed.length, BigInt(prepared.nonce))
if (keccak256(bytes) !== prepared.typed_data.message.connectionId) {
  throw new Error('connectionId mismatch')
}

Approvals don't need this step: wallets display their fields in plain text.

3. Sign

Sign typed_data as returned. In Python, with eth-account:

python
import os

from eth_account import Account
from eth_account.messages import encode_typed_data

agent = Account.from_key(os.environ["AGENT_KEY"])
signature = agent.sign_message(encode_typed_data(full_message=prepared["typed_data"])).signature.to_0x_hex()

In TypeScript, with viem (which adds EIP712Domain itself, so leave it out):

ts
const { EIP712Domain, ...types } = prepared.typed_data.types
const signature = await agent.signTypedData({
  domain: prepared.typed_data.domain,
  message: prepared.typed_data.message,
  primaryType: prepared.typed_data.primaryType,
  types,
})

Browser wallets such as MetaMask refuse to sign for a chainId other than their active chain, and trading payloads use 1337. Sign approvals with the browser wallet and trades with an agent key. Wallets has snippets for MetaMask, Phantom, Turnkey and Privy.

4. Submit

bash
curl -s -X POST https://hl-api-production-65c8.up.railway.app/v1/actions/5fcab0cf-45f1-43e3-b02f-b06c05409445/submit \
  -H "Authorization: Bearer $API_KEY" \
  -H 'content-type: application/json' \
  -d '{ "signature": "0x…" }'

The signature is 65 bytes of hex (r, s, v), or {"r": "0x…", "s": "0x…", "v": 27}. The API checks the signer is your master wallet or an approved agent, then submits the stored action unchanged. The response is the same result a managed account gets (see Placing orders).

Because you send only a signature, nothing can alter the action between what you verified and what reaches the exchange.

Wallets

Each snippet below turns prepared.typed_data into the signature you submit. Sign approvals with the master wallet and trades with an agent, as above.

MetaMask

MetaMask signs eth_signTypedData_v4, but only when the domain's chainId is its active chain. Use it for the master's approvals, preparing them with signature_chain_id set to that chain, and let a separate agent key sign trades. The typed data is already in v4 shape, EIP712Domain included, so pass it as it is:

ts
const [address] = await window.ethereum.request({
  method: 'eth_requestAccounts',
})
const chainId = Number(await window.ethereum.request({ method: 'eth_chainId' }))
// Prepare approvals with { "signature_chain_id": chainId }, then:
const signature = await window.ethereum.request({
  method: 'eth_signTypedData_v4',
  params: [address, JSON.stringify(prepared.typed_data)],
})

If the user switches networks between preparing and signing, MetaMask refuses ("chainId … must match the active chainId"): read the chain again and prepare again.

Phantom

Phantom's EVM provider supports eth_signTypedData_v4 too, so the MetaMask flow applies unchanged. Use Phantom's provider, which may not be window.ethereum, and take the chain it reports as signature_chain_id:

ts
const provider = window.phantom?.ethereum
if (provider === undefined) {
  throw new Error('Phantom is not installed')
}
const [address] = await provider.request({ method: 'eth_requestAccounts' })
const chainId = Number(await provider.request({ method: 'eth_chainId' }))
const signature = await provider.request({
  method: 'eth_signTypedData_v4',
  params: [address, JSON.stringify(prepared.typed_data)],
})

Turnkey

Turnkey signs inside its enclaves with no active-chain check, so a Turnkey key can sign both approvals and trades. A common backend setup keeps the master in Turnkey for approvals only and a second Turnkey key as the agent. With @turnkey/sdk-server and @turnkey/viem:

ts
import { Turnkey } from '@turnkey/sdk-server'
import { createAccount } from '@turnkey/viem'

const turnkey = new Turnkey({
  apiBaseUrl: 'https://api.turnkey.com',
  apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY,
  apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY,
  defaultOrganizationId: process.env.TURNKEY_ORGANIZATION_ID,
})
const agent = await createAccount({
  client: turnkey.apiClient(),
  organizationId: process.env.TURNKEY_ORGANIZATION_ID,
  signWith: AGENT_ADDRESS,
})

const { EIP712Domain, ...types } = prepared.typed_data.types
const signature = await agent.signTypedData({
  domain: prepared.typed_data.domain,
  message: prepared.typed_data.message,
  primaryType: prepared.typed_data.primaryType,
  types,
})

In the browser, @turnkey/sdk-browser clients work the same way with createAccount.

Privy

Embedded wallets in React take the typed data as it is:

tsx
import { useSignTypedData, useWallets } from '@privy-io/react-auth'

const { signTypedData } = useSignTypedData()
const { wallets } = useWallets()
const { signature } = await signTypedData(prepared.typed_data, {
  address: wallets[0].address,
})

Each call may show a Privy confirmation, so approve an agent once with the embedded wallet and let the agent sign trades. External wallets connected through Privy follow the MetaMask rules.

Server wallets (@privy-io/node) sign from your backend:

ts
import { PrivyClient } from '@privy-io/node'

const privy = new PrivyClient({
  appId: process.env.PRIVY_APP_ID,
  appSecret: process.env.PRIVY_APP_SECRET,
})
const { EIP712Domain, ...types } = prepared.typed_data.types
const { signature } = await privy
  .wallets()
  .ethereum()
  .signTypedData(WALLET_ID, {
    params: {
      typed_data: {
        domain: prepared.typed_data.domain,
        message: prepared.typed_data.message,
        primary_type: prepared.typed_data.primaryType,
        types,
      },
    },
  })

Wallets owned by a user rather than your app also need an authorization context; see Privy's documentation on authorization keys.

Rules for prepared actions

  • Single use. A prepared action can be submitted once; a second submit returns 409.
  • Short-lived. Submit before expires_at, 60 seconds after preparing. After that you get 410: prepare again.
  • Market orders are priced when prepared. Sign and submit promptly, or the price may have moved past your slippage.

Troubleshooting

Symptom

Cause and fix

403 signature recovers to an address that isn't allowed

Signed with an unapproved key, or signed modified typed data. Sign typed_data exactly as returned.

403 must be signed by the master wallet

Approvals can't be signed by an agent.

400 invalid_signature

The signature isn't 65 bytes of hex or {r, s, v}.

410 expired

Signed too late. Prepare again.

409 conflict

Already submitted. Each prepared action is single use.

422 User or API Wallet 0x… does not exist

Hyperliquid doesn't know the signer: the account has never deposited, or the agent isn't approved.

Wallet error: chainId must match the active chainId

A browser wallet was asked to sign a trade (chainId 1337). Use an agent key for trades.

Your verifier reports a connectionId mismatch

action was re-serialised with different key order or number types. Hash the JSON exactly as received.