Skip to main content

Authentication & Signing

EIP-712 signature requirements for write operations.

Production signing domain

Use chain ID 999 for production. Testnet is temporarily disabled until Hypercall acquires more testnet HYPE.

Overview​

All write operations require EIP-712 typed data signatures. The signer must either:

  1. Be the wallet address itself (direct signing), OR
  2. Be an authorized agent for the wallet (see Agent auth)

EIP-712 Domain​

Domain separator:

{
"name": "Hypercall",
"version": "1",
"chainId": 999,
"verifyingContract": "0x0000000000000000000000000000000000000000"
}

Chain ID: 999 (production)

Message Types​

PlaceOrder​

Struct with explicit route:

struct PlaceOrder {
address wallet;
string symbol;
string side;
string size;
string price;
string tif;
string route;
string clientId;
uint64 nonce;
}

Struct when route is omitted:

struct PlaceOrder {
address wallet;
string symbol;
string side;
string size;
string price;
string tif;
string clientId;
uint64 nonce;
}

Field requirements:

  • wallet: Wallet address (0x-prefixed hex string)
  • symbol: Option symbol (e.g., "BTC-20250131-100000-C")
  • side: "Buy" or "Sell" (exact string match required)
  • size: Size as string (e.g., "0.1") - MUST match exactly what is sent in JSON
  • price: Price as string (e.g., "100.0") - MUST match exactly what is sent in JSON
  • tif: Time-in-force: "gtc", "ioc", or "fok" (exact string match required)
  • route: Optional order route: "best_execution" or "book_only" (exact string match required when present). POST /bulk_order accepts only "book_only". "rfq_only" is part of the signed vocabulary but every order endpoint currently rejects it; use the RFQ endpoints for RFQ execution.
  • clientId: Client-provided order ID (string, can be empty "")
  • nonce: Millisecond-timestamp nonce (uint64), unique per signer. See Nonce Management

Critical: price and size MUST be strings in both the signed message and the JSON request body. String formatting must match exactly.

Route default: send route. An order without it is still accepted and routed as best_execution, but POST /order answers with Deprecation, Sunset, and Warning headers, and the published sunset date (July 4, 2026) has passed. The omitted-route form can be removed without a further grace period. When you do omit route from the JSON, omit it from the typed data too.

PlaceOrderReduceOnly​

A reduce-only order ("reduce_only": true in the JSON body) signs a separate struct. It requires an explicit route.

struct PlaceOrderReduceOnly {
address wallet;
string symbol;
string side;
string size;
string price;
string tif;
string route;
string clientId;
bool reduceOnly;
uint64 nonce;
}

reduceOnly must be true and match the request body. A reduce-only order without a signed route is rejected with missing_field.

ReplaceOrder​

PUT /order and each entry of PUT /bulk_order sign a ReplaceOrder. It has no route field; a route key in the JSON body is ignored.

struct ReplaceOrder {
address wallet;
string orderId;
string symbol;
string side;
string size;
string price;
string tif;
string clientId;
uint64 nonce;
}

orderId is the decimal string of the order being replaced. A reduce-only replacement ("reduce_only": true) signs ReplaceOrderReduceOnly, which adds bool reduceOnly between clientId and nonce. The TypeScript SDK exports both type maps.

CancelOrder​

Struct:

struct CancelOrder {
address wallet;
string orderId;
uint64 nonce;
}

Field requirements:

  • wallet: Wallet address
  • orderId: Order ID as string (e.g., "123")
  • nonce: Unique nonce

CancelOrderByClientId​

Struct:

struct CancelOrderByClientId {
address wallet;
string clientId;
uint64 nonce;
}

Field requirements:

  • wallet: Wallet address
  • clientId: Client order ID (string)
  • nonce: Unique nonce

ApproveAgent​

Struct:

struct ApproveAgent {
address agent;
uint64 nonce;
}

Field requirements:

  • agent: Agent wallet address to authorize
  • nonce: Unique nonce

Note: The wallet authorizing the agent is derived from the recovered signature.

RevokeAgent​

Struct:

struct RevokeAgent {
address agent;
uint64 nonce;
}

Field requirements:

  • agent: Agent wallet address to revoke
  • nonce: Unique nonce

RevokeAllAgents​

DELETE /revoke-all-agents removes every agent for the signing wallet.

struct RevokeAllAgents {
uint64 nonce;
}

Risk simulation​

POST /risk/simulate/portfolio and POST /risk/simulate/orders are public, read-only engine-authority requests. They require no signature, authentication, or nonce. The wallet field selects the account snapshot to evaluate.

Portfolio request example:

curl -X POST https://api.hypercall.xyz/risk/simulate/portfolio \
-H 'Content-Type: application/json' \
-d '{
"wallet": "0x1111111111111111111111111111111111111111",
"add_positions": true,
"positions": [
{"symbol": "BTC-20261231-100000-C", "quantity": "1.25"}
]
}'

Atomic order request example:

curl -X POST https://api.hypercall.xyz/risk/simulate/orders \
-H 'Content-Type: application/json' \
-d '{
"wallet": "0x1111111111111111111111111111111111111111",
"execution_mode": "atomic",
"hypothetical_open_orders": [
{"symbol": "BTC-PERP", "side": "Buy", "size": "0.1", "price": "100000", "reduce_only": false}
],
"orders": [
{"symbol": "BTC-20261231-100000-C", "side": "Buy", "size": "1", "price": "4200", "reduce_only": false},
{"symbol": "BTC-20261231-120000-C", "side": "Sell", "size": "1", "price": "1800", "reduce_only": false}
]
}'

hypothetical_open_orders is optional. Its entries are treated as resting GTC orders and admitted sequentially in array order before orders. For this request only, they are evaluated together with the account's authoritative live open orders and active reservations. The engine state is not modified. On a successful simulation, simulated and delta include the entire hypothetical sequence plus the proposed resting order or atomic package. The two order arrays may contain at most 50 entries in total.

Simulation responses are snapshots, not execution guarantees. Routing, RPI availability, matching, nonce admission, and future trading-halt state are not simulated. A later real order can differ after engine state advances.

Signing Process​

Step 1: Construct Message​

Example for PlaceOrder with explicit route:

const message = {
wallet: "0x1111111111111111111111111111111111111111",
symbol: "BTC-20250131-100000-C",
side: "Buy",
size: "0.1", // MUST be string
price: "100.0", // MUST be string
tif: "gtc",
route: "best_execution",
clientId: "mm-1",
nonce: Date.now() // current ms epoch; send this exact value in the body
};

Step 2: Define Domain​

const domain = {
name: "Hypercall",
version: "1",
chainId: 999,
verifyingContract: "0x0000000000000000000000000000000000000000"
};

Step 3: Sign Typed Data​

Using ethers.js v6 (in v5 the method is _signTypedData):

const signature = await signer.signTypedData(domain, {
PlaceOrder: [
{ name: "wallet", type: "address" },
{ name: "symbol", type: "string" },
{ name: "side", type: "string" },
{ name: "size", type: "string" },
{ name: "price", type: "string" },
{ name: "tif", type: "string" },
{ name: "route", type: "string" },
{ name: "clientId", type: "string" },
{ name: "nonce", type: "uint64" }
]
}, message);

Step 4: Send Request​

Critical: The JSON request body must use the exact same string values for price and size:

const body = {
wallet: message.wallet,
symbol: message.symbol,
price: message.price, // same string as signed
size: message.size, // same string as signed
side: message.side,
tif: message.tif,
route: message.route,
client_id: message.clientId,
nonce: message.nonce, // same value as signed
signature
};

Nonce Management​

Requirements:

  • Nonces must fall strictly within two days before and one day after server time in milliseconds, so use the current Unix time in milliseconds. Small counters such as 1 or 123 are rejected.
  • Nonces are tracked per signing key. For orders and cancels that is the agent when an agent signs; for agent approval and revocation it is the owner wallet.
  • A nonce cannot be reused. Order nonces are shared across POST /order, bulk orders, and WebSocket PlaceOrder; a nonce used on one transport cannot be reused on another.
  • Gaps are allowed. The engine keeps the 100 most recent nonces per signer, and once it holds 100 a new nonce must be greater than the smallest one kept.

Best practices:

  • Seed from millisecond epoch time and persist the last nonce per signer, returning max(now_ms, last + 1)
  • Share one allocator across every process that signs with the same key
  • If a request fails, sign it again with a new nonce

Agent Authorization​

If using an agent wallet to sign:

  1. Approve agent (one-time):

    POST /approve-agent
    {
    "agent": "0x...",
    "nonce": 1791273600000,
    "signature": "0x..." # Signed by wallet owner
    }
  2. Sign orders with agent wallet:

    • Use agent wallet to sign PlaceOrder / CancelOrder messages
    • Set wallet field to the trading wallet address
    • Middleware verifies agent is authorized for that wallet

See Agent auth for full agent authorization details.

Perp Orders​

Perp orders do not use the Hypercall domain above. They are signed as an HLOrder in the HypercallApiSign domain (chain ID 999, zero verifying contract) by an API wallet registered on your Hypercall Account. See EIP-712 Signing for the struct and units.

The TEE route is opt-in over the API: you choose it by calling the TEE endpoints. In the Hypercall app it is the default. To use it, submit the signed order to POST /v1/tee-perp/orders/submit with account, nonce, action, and signature. Hypercall runs the margin check, then a secure enclave signs and sends the native Hyperliquid order for your Account. Every perp order carries the Hypercall builder code at a 0% fee, so the Account manager must approve that builder once before the first order (the app's Enable trading step). Without it the order is rejected with builder_not_approved. See Perps for the flow and the API Reference for the request and response schema.

Hyperliquid's own Exchange domain (chain ID 1337, MessagePack Agent payloads) is not accepted by the Hypercall API.

Order result in the submit response​

The response to POST /v1/tee-perp/orders/submit reports the outcome in stage. When stage is accepted, venueOrder carries the result that Hyperliquid returned for the order.

Hyperliquid resultvenueOrder
The order rests on the bookstatus is resting. oid is the Hyperliquid order ID.
The order filledstatus is filled. oid is the Hyperliquid order ID. totalSz is the filled size. avgPx is the average fill price.
{
"stage": "accepted",
"venueOrder": {
"status": "filled",
"oid": 77738308,
"totalSz": "0.0200",
"avgPx": "1891.40"
}
}

The example shows only the fields that this section describes.

  • venueOrder.status is the Hyperliquid order status. It is separate from stage, which is the Hypercall submission outcome.
  • oid is a JSON number. totalSz and avgPx are the decimal strings that Hyperliquid returned, unchanged.
  • Hypercall reports the result only from a verified response. Hypercall checks the Hyperliquid response against venueResponseHash before it reads the result.
  • An accepted order can have a null venueOrder. venueOrder is null when Hypercall cannot verify the Hyperliquid response against venueResponseHash, and when Hyperliquid reports a status other than resting or filled. stage stays accepted.
  • Keep your own fallback. When venueOrder is null, read the order from Hyperliquid, for example on the Hyperliquid account WebSocket.
  • No other stage carries a result. venueOrder is null on a rejected, not_submitted, or unknown response.

Common Errors​

unauthorized (HTTP 401): "Signer 0x... is not authorized to act for wallet 0x..."​

EIP-712 recovery always yields some address. If anything you signed differs from what you sent, the recovered address is not your key, and the request fails here rather than with a signature error.

Causes:

  1. price or size string formatting changed between signing and sending (e.g., "100.0" vs "100")
  2. A signed field differs from the JSON body (route, clientId/client_id, nonce, tif), or route was signed but omitted from the body, or the reverse
  3. Wrong domain (chain ID, name, version, verifying contract)
  4. Wrong struct for the request (for example PlaceOrder instead of ReplaceOrder on PUT /order)
  5. The signer really is neither the wallet nor an approved agent. Approve the agent via POST /approve-agent or sign with the wallet directly

When the message names a signer address you do not recognize, it is causes 1 to 4.

signature_verification_failed (HTTP 400)​

Cause: The signature could not be decoded or recovered at all, for example wrong length or invalid hex.

invalid_parameter (HTTP 400): "price must be provided as a string for signature verification"​

Cause: price or size was sent as a JSON number. Send both as strings.

Implementation Examples​

Python (eth_account 0.10 or later)​

import time

from eth_account import Account
from eth_account.messages import encode_typed_data

domain = {
"name": "Hypercall",
"version": "1",
"chainId": 999,
"verifyingContract": "0x0000000000000000000000000000000000000000"
}

message = {
"wallet": "0x1111111111111111111111111111111111111111",
"symbol": "BTC-20250131-100000-C",
"side": "Buy",
"size": "0.1",
"price": "100.0",
"tif": "gtc",
"route": "best_execution",
"clientId": "mm-1",
"nonce": int(time.time() * 1000) # current ms epoch; send this exact value in the body
}

types = {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}
],
"PlaceOrder": [
{"name": "wallet", "type": "address"},
{"name": "symbol", "type": "string"},
{"name": "side", "type": "string"},
{"name": "size", "type": "string"},
{"name": "price", "type": "string"},
{"name": "tif", "type": "string"},
{"name": "route", "type": "string"},
{"name": "clientId", "type": "string"},
{"name": "nonce", "type": "uint64"}
]
}

structured_msg = {
"types": types,
"domain": domain,
"primaryType": "PlaceOrder",
"message": message
}

encoded = encode_typed_data(full_message=structured_msg)
signed = Account.sign_message(encoded, private_key)
signature = signed.signature.hex()

JavaScript (ethers.js v6)​

const { ethers } = require("ethers");

const domain = {
name: "Hypercall",
version: "1",
chainId: 999,
verifyingContract: "0x0000000000000000000000000000000000000000"
};

const types = {
PlaceOrder: [
{ name: "wallet", type: "address" },
{ name: "symbol", type: "string" },
{ name: "side", type: "string" },
{ name: "size", type: "string" },
{ name: "price", type: "string" },
{ name: "tif", type: "string" },
{ name: "route", type: "string" },
{ name: "clientId", type: "string" },
{ name: "nonce", type: "uint64" }
]
};

const message = {
wallet: "0x1111111111111111111111111111111111111111",
symbol: "BTC-20250131-100000-C",
side: "Buy",
size: "0.1",
price: "100.0",
tif: "gtc",
route: "best_execution",
clientId: "mm-1",
nonce: Date.now() // current ms epoch; send this exact value in the body
};

const signature = await signer.signTypedData(domain, types, message);

Testing Signatures​

Example for testing signature generation:

  1. Generate signature with your implementation
  2. Send test order via POST /order
  3. Verify response: HTTP 200 with status "ACKED", or "REJECTED" with a reason
  4. If the request fails with HTTP 401 unauthorized and an unfamiliar signer address, or HTTP 400 signature_verification_failed, check:
    • String formatting of price and size
    • Domain parameters (especially chainId)
    • That every signed field matches the body, including route and nonce

Security Considerations​

  1. Never expose private keys: Use hardware wallets or secure key management
  2. Nonce management: Use persistent, millisecond-timestamp nonces per signing key
  3. Agent authorization: Regularly audit authorized agents via GET /authorized-agents
  4. String encoding: Ensure price and size are strings in both signing and request

References​