> Treat every fee rate, margin ratio, order limit, minimum and address in this documentation as an illustration of the shape of the value, never as a constant to hardcode - the live values are served by GET /markets and GET /config on the Perps API.

# Authentication & Signing

> Trading keys and the EIP-712 signing scheme for the Perps API.

Every write to the Perps API is authorized by an **EIP-712 signature carried in the request body**. There is no API key and no bearer token — the server recovers the signer from the signature.

Signing is split across two keys so that a key used for order flow can never move funds:

* Your **main wallet** signs the custody-level actions — authorizing a trading key, revoking one, and withdrawing.
* A **trading key** signs everything else. It is a separate keypair you generate locally and your main wallet authorizes once; from then on it signs all order flow. If it leaks, the attacker can trade on your account but cannot withdraw — and you can revoke it instantly.

> **Note**
>
> The typed data and request fields call the trading key an **agent** (`AuthorizeAgent`, `agent_wallet_address`). The two words mean the same key.

## The EIP-712 domain

All typed data shares one domain — note there is **no `verifyingContract` and no `salt`**:

```json
{
  "name": "Hyperflow Perps",
  "version": "2",
  "chainId": 999
}
```

`chainId` is **999 on mainnet, 998 on testnet** — it identifies the HyperFlow environment, not your connected wallet's chain. Trading actions require the pinned value; wallet-signed actions accept any `chainId > 0` as long as the body's `signature_chain_id` carries the same number. Using 999 everywhere on mainnet is simplest.

Typed data that includes a `hyperflowEnvironment` field carries the literal string `"Mainnet"` or `"Testnet"` — a second replay guard across environments. There is no public testnet base URL today; every request goes to mainnet.

## The signed actions

Seven actions are signed. `nonce` is always `uint256` in the typed data; the column below is how it travels in the JSON body.

| Action                 | Signed by   | `account` field          | `nonce` in JSON | Signature in body    |
| ---------------------- | ----------- | ------------------------ | --------------- | -------------------- |
| `AuthorizeAgent`       | Main wallet | `address` (`wallet`)     | number          | `signature` string   |
| `RevokeAgent`          | Main wallet | `address` (`wallet`)     | number          | `signature` string   |
| `Withdraw`             | Main wallet | `address` (`wallet`)     | string          | `{ r, s, v }` object |
| `Order`                | Trading key | `bytes32` (`account_id`) | string          | `signature` string   |
| `Cancel`               | Trading key | `address` (`wallet`)     | string          | `signature` string   |
| `Modify`               | Trading key | `bytes32` (`account_id`) | string          | `signature` string   |
| `UpdateIsolatedMargin` | Trading key | `bytes32` (`account_id`) | number          | `signature` string   |

`wallet` and `account_id` are as defined in [Account format](/developers/perps-api/overview#account-format).

### Structs

```
AuthorizeAgent(address user,address agent,string hyperflowEnvironment,uint256 expiresAt,uint256 nonce)
RevokeAgent(address user,address agent,string hyperflowEnvironment,uint256 nonce)
Withdraw(address user,address destination,uint256 amount,string hyperflowEnvironment,uint256 nonce)
Order(bytes32 account,string market,string side,string orderType,string size,string limitPrice,string timeInForce,bool reduceOnly,uint256 leverage,uint256 nonce,string triggerPrice,bool isMarket,string tpsl)
Cancel(address account,string orderId,string orderType,uint256 nonce)
Modify(bytes32 account,string orderId,string clientOrderId,string limitPrice,string size,string triggerPrice,uint256 nonce)
UpdateIsolatedMargin(bytes32 account,string market,string amount,uint256 nonce)
```

Three members are not where the request body suggests:

| Member                                   | Value comes from                                                                                                                              |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `Cancel.orderId`, `Modify.orderId`       | The URL path — it is not in the body                                                                                                          |
| `Modify.clientOrderId`                   | The body's `client_order_id`. Signed here; **not** a member of `Order` at all. `parent_client_order_id` on a bracket leg is not signed either |
| `Order.triggerPrice`, `isMarket`, `tpsl` | Every order signs them — a plain order signs `"0"`, `false`, `""`                                                                             |

## Signing rules

Break any of these and the server recovers a different address — on trading-key actions that is a `403`, not a validation error.

### Numbers travel as strings, byte for byte

Prices, sizes, and amounts are typed as `string` in the typed data and hashed as UTF-8 — the server hashes what is in the body **verbatim**, so the string you sign must be byte-identical to the string you send.

Use canonical decimals: no trailing zeros, no exponent notation. `"0.5"` is correct; `"0.50"` and `"5e-1"` sign to different hashes. The app normalizes with `BigNumber.toFixed()`.

One exception: `Withdraw.amount` is a `uint256` in USDC base units — 25 USDC signs as `25000000` while the body sends `"25"`. See [Request a withdrawal](/developers/perps-api/funds#request-a-withdrawal).

### Every nonce is a fresh millisecond timestamp

`nonce` is typed as `uint256` and must be a Unix-millisecond timestamp, single-use per wallet. When you send a burst, increment it yourself so no two requests reuse a millisecond. A replayed nonce returns `409`.

### Signatures are 65-byte `r || s || v`

`v` is `27` or `28`. Most endpoints take the whole thing as one hex `signature` string; `/account/withdraw` takes it split into `{ r, s, v }`. The table above says which.

## Signature failures

Wallet-signed actions report a bad signature explicitly: `/auth/register` and `/auth/trading-keys/revoke` return `400 INVALID_SIGNATURE`, `/account/withdraw` returns `401 UNAUTHORIZED`.

Trading-key actions do not: a bad signature recovers to an unknown address, and every trading route answers `403 FORBIDDEN — signature does not match an active trading key`. The same message therefore means either *no active key for this account* or *signed payload ≠ sent payload*. `GET /auth/trading-keys?wallet=…` separates the two: if your agent is listed and unexpired, the payload is the problem.

## Creating a trading key

### Generate an agent keypair

Any secp256k1 keypair. Keep the private key; its address is `agent_wallet_address`.

### Sign `AuthorizeAgent` with your main wallet

`user` is your wallet, `agent` the address from step 1, `hyperflowEnvironment` `"Mainnet"`, `expiresAt` and `nonce` in Unix ms.

### Register it

```
POST /auth/register
```

```json
{
  "wallet_address": "0xYourMainWallet",
  "agent_wallet_address": "0xYourAgentAddress",
  "hyperflow_environment": "Mainnet",
  "signature_chain_id": 999,
  "expires_at": 1787672114951,
  "nonce": 1787067314951,
  "signature": "0x…65-byte r||s||v…"
}
```

| Field                   | Meaning                                                                                                      |
| ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| `wallet_address`        | The main wallet that signed — the account the key will trade for                                             |
| `agent_wallet_address`  | The public address of the keypair you generated in step 1                                                    |
| `hyperflow_environment` | `"Mainnet"` or `"Testnet"`, matching what you signed                                                         |
| `signature_chain_id`    | The `chainId` you used in the signing domain                                                                 |
| `expires_at`            | When the key stops working, Unix ms. **Required**, and must match the signed `expiresAt` exactly             |
| `nonce`                 | The same Unix-millisecond timestamp you signed. It makes this request single-use: replaying it returns `409` |
| `signature`             | The signature from step 2                                                                                    |

Optional: `name` (a label for the key, ≤64 chars) and `key_hint` (≤32 chars). An absent or `0` `expires_at` is rejected.

A success returns `data.expires_at` in Unix ms. Failures are all `400` with a specific code — `INVALID_REQUEST`, `INVALID_ENVIRONMENT` (wrong environment for this server), `INVALID_SIGNATURE`, `INVALID_EXPIRES_AT` (missing, past, or over 180 days), `INVALID_SIGNATURE_CHAIN_ID` — and `409` for a replayed nonce.

### Verify it

```
GET /auth/trading-keys?wallet=0xYourMainWallet
```

Your agent should be listed as `ACTIVE` with a future `expires_at`. An empty list means you registered against a different wallet or environment, and every order will return `403`. The wallet here must be the one your `account_id` derives from.

## Managing trading keys

### List active keys

```
GET /auth/trading-keys?wallet=0x…
```

Returns the wallet's ACTIVE, unexpired keys, each with `agent_wallet_address`, `status`, `created_at`, and `expires_at`. It is an open read — no signature needed.

### Revoke a key

```
POST /auth/trading-keys/revoke
```

Your main wallet signs:

```
RevokeAgent(address user, address agent, string hyperflowEnvironment, uint256 nonce)
```

The body is the `/auth/register` body without `expires_at`:

```json
{
  "wallet_address": "0xYourMainWallet",
  "agent_wallet_address": "0xYourAgentAddress",
  "hyperflow_environment": "Mainnet",
  "signature_chain_id": 999,
  "nonce": 1787067314958,
  "signature": "0x…"
}
```

Revocation takes effect immediately — the key's next order is rejected.

> **Note**
>
> The app mints trading keys with a **7-day** expiry; over the API you can sign an expiry of up to **180 days**. Re-registering the same agent address refreshes its expiry rather than creating a second key.

**Next:** [Orders](/developers/perps-api/trading)