Authentication & Signing
EIP-712 signature requirements for write operations.
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:
- Be the wallet address itself (direct signing), OR
- 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 JSONprice: Price as string (e.g.,"100.0") - MUST match exactly what is sent in JSONtif: 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_orderaccepts 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 addressorderId: Order ID as string (e.g.,"123")nonce: Unique nonce
CancelOrderByClientId
Struct:
struct CancelOrderByClientId {
address wallet;
string clientId;
uint64 nonce;
}
Field requirements:
wallet: Wallet addressclientId: Client order ID (string)nonce: Unique nonce
ApproveAgent
Struct:
struct ApproveAgent {
address agent;
uint64 nonce;
}
Field requirements:
agent: Agent wallet address to authorizenonce: 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 revokenonce: 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
1or123are 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 WebSocketPlaceOrder; 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:
-
Approve agent (one-time):
POST /approve-agent{"agent": "0x...","nonce": 1791273600000,"signature": "0x..." # Signed by wallet owner} -
Sign orders with agent wallet:
- Use agent wallet to sign
PlaceOrder/CancelOrdermessages - Set
walletfield to the trading wallet address - Middleware verifies agent is authorized for that wallet
- Use agent wallet to sign
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 result | venueOrder |
|---|---|
| The order rests on the book | status is resting. oid is the Hyperliquid order ID. |
| The order filled | status 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.statusis the Hyperliquid order status. It is separate fromstage, which is the Hypercall submission outcome.oidis a JSON number.totalSzandavgPxare the decimal strings that Hyperliquid returned, unchanged.- Hypercall reports the result only from a verified response. Hypercall checks the Hyperliquid response against
venueResponseHashbefore it reads the result. - An accepted order can have a
nullvenueOrder.venueOrderisnullwhen Hypercall cannot verify the Hyperliquid response againstvenueResponseHash, and when Hyperliquid reports a status other than resting or filled.stagestaysaccepted. - Keep your own fallback. When
venueOrderisnull, read the order from Hyperliquid, for example on the Hyperliquid account WebSocket. - No other
stagecarries a result.venueOrderisnullon arejected,not_submitted, orunknownresponse.
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:
priceorsizestring formatting changed between signing and sending (e.g.,"100.0"vs"100")- A signed field differs from the JSON body (
route,clientId/client_id,nonce,tif), orroutewas signed but omitted from the body, or the reverse - Wrong domain (chain ID, name, version, verifying contract)
- Wrong struct for the request (for example
PlaceOrderinstead ofReplaceOrderonPUT /order) - The signer really is neither the wallet nor an approved agent. Approve the agent via
POST /approve-agentor 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:
- Generate signature with your implementation
- Send test order via
POST /order - Verify response: HTTP 200 with
status"ACKED", or"REJECTED"with areason - If the request fails with HTTP 401
unauthorizedand an unfamiliar signer address, or HTTP 400signature_verification_failed, check:- String formatting of
priceandsize - Domain parameters (especially
chainId) - That every signed field matches the body, including
routeandnonce
- String formatting of
Security Considerations
- Never expose private keys: Use hardware wallets or secure key management
- Nonce management: Use persistent, millisecond-timestamp nonces per signing key
- Agent authorization: Regularly audit authorized agents via
GET /authorized-agents - String encoding: Ensure
priceandsizeare strings in both signing and request
References
- EIP-712: https://eips.ethereum.org/EIPS/eip-712
- Implementation: handled by the signature recovery and middleware stack.