Authentication & signing

MNX uses two separate mechanisms, and a full trading integration needs both:

  1. Request authentication — an Authorization header on HTTP requests, proving who is calling. Endpoints that require it are marked with a lock (bearer auth) in the API reference.
  2. Order signing— an EIP-712 wallet signature attached to each order, proving the account holder authorized that exact trade. Signatures are verified against the market's on-chain contracts, so the exchange cannot forge or alter your orders.

Request authentication

The API accepts two Authorization header schemes, Key and Bearer:

HeaderCredential
Authorization: Key <api-key>A personal API key tied to your exchange account. Rotate it (and obtain the new value) with POST /v0/me/api-key/rotate, which requires both a valid credential and a fresh, single-use wallet challenge.
Authorization: Bearer <token>A Privy identity token, used by the web app for both external wallets and embedded wallets. Bots and other unattended clients use API keys.

On authenticated endpoints whose path contains a :user_id parameter, the ID must be your own — you can only read your own orders, balances, and positions.

Set up a bot account

  1. Sign in to the web app through Privy with the wallet that owns the bot account. External wallets such as MetaMask are supported.
  2. Complete account setup, then generate an API key in Settings. The wallet signs a fresh challenge to authorize this action.
  3. Store the API key in the bot's secret configuration and send it as Authorization: Key <api-key> for HTTP and WebSocket authentication.
  4. Use the wallet private key, or an authorized trading session key, to sign trading actions separately.

API keys have no automatic expiration. Each account has one current key; rotation replaces it. Client initialization reuses the configured key. Rotating a key uses POST /v0/auth/challenge withaction: "rotate_api_key", followed byPOST /v0/me/api-key/rotatewith the returned challenge ID and the owner's signature. The rotation request also needs a valid API key or Privy identity token.

The former 30-day UUID bearer tokens are no longer accepted. Sign in through Privy with the same wallet to access the existing exchange account. Trading session keys for one-click trading are separate and remain supported.

See Build a testnet order bot for a runnable example.

EIP-712 order signing

EIP-712 is an Ethereum standard for signing structured data instead of an opaque string of bytes. The data is a typed struct (named fields with types), and the signature also covers a domain — the contract name, version, chain ID, and contract address it is intended for. That binding means a signature for one order cannot be replayed as a different order, on a different market, or on a different chain, and wallets can display the individual fields for review before signing.

Orders are signed over this struct:

Order(
  bytes8   flags,        // packed salt + isBuy + reduceOnly bits
  uint128  quantity,     // base quantity, scaled by 1e18
  uint128  price,        // limit price scaled by 1e18 (0 for market orders)
  uint128  triggerPrice, // trigger price scaled by 1e18 (0 if not conditional)
  uint8    triggerCondition,
  uint128  leverage,     // whole-number leverage, scaled by 1e18
  address  maker,        // the account the order trades for
  uint128  expiration    // Unix seconds, 0 = good-til-canceled
)

with the EIP-712 domain:

{
  name: "IsolatedTrader",
  version: "2.0",
  chainId: <chain id>,          // MegaETH mainnet 4326, testnet 6343
  verifyingContract: <address>  // the market's trader_address
}

Notes on the fields:

  • verifyingContract is per-market: use the trader_address returned by GET /v0/markets for the market you are trading. Shared contract addresses are available from GET /v0/contracts/shared.
  • flags is exactly (salt << 4) | buyBit | reduceOnlyBit, encoded as 8 bytes. buyBit is 1 for LONG and 0 for SHORT; reduceOnlyBit is 2 when enabled and 0 otherwise. Use a nonnegative salt below 253, send the same integer as the request's decimal salt string, and leave the other two low bits clear.
  • The struct amounts are the 1e18-scaled integer versions of the human-readable numbers you send in the JSON request body. The two must describe the same order or the signature check fails.
  • Sign market orders with price: 0. Conditional market orders also sign a zero price, while their triggerPrice remains the 1e18-scaled trigger.
  • MARKET and LIMIT orders sign triggerCondition: 0. Conditional orders derive it server-side from order type and side: STOP_* LONG=1/SHORT=2, TAKE_PROFIT_* LONG=2/SHORT=1. Signing any other value fails verification.
  • Take-profit and stop-loss order types are always reduce-only. Set both the signed reduce-only bit and the request's reduce_only field to true for those orders.
  • Hash the typed data with TypedDataEncoder.hash, sign its bytes with wallet.signMessage, then append 01. That embedded signature-type byte selects personal-message prepend verification and is separate from the JSON request's signature_type: 1. The request's salt and expiration_seconds must also match the signed values.

Sign and place a testnet order with ethers v6

The MNX client packages are not currently distributed publicly. This example uses only ethers and Node's built-in fetch. Use the funded testnet wallet associated with your API key, keep its private key out of source control, and inspect GET /v0/markets if you want to select a market instead of using the first enabled one.

npm install ethers@6

export API_KEY="your-api-key"
export PRIVATE_KEY="your-funded-testnet-wallet-private-key"
node place-order.mjs

Save the following as place-order.mjs. It signs and submits a minimum-size LONG market order; change the side, quantity, or reduce-only value in both the signed message and request together.

import { TypedDataEncoder, Wallet, getBytes, parseUnits, toBeHex } from 'ethers'

const API = 'https://api.testnet.mnx.fi'
const { API_KEY, PRIVATE_KEY } = process.env

if (!API_KEY || !PRIVATE_KEY) {
  throw new Error('Set API_KEY and PRIVATE_KEY')
}

const wallet = new Wallet(PRIVATE_KEY)
const marketsResponse = await fetch(API + '/v0/markets')
if (!marketsResponse.ok) throw new Error(await marketsResponse.text())

const markets = await marketsResponse.json()
const market = markets.find((item) => item.trading_enabled)
if (!market) throw new Error('No trading-enabled market is available')

const side = 'LONG'
const reduceOnly = false
const leverage = 1
const quantity = market.min_order_size
const salt = BigInt(Date.now()) // below 2^53; use once
const expiration = Math.floor(Date.now() / 1000) + 60
const buyBit = side === 'LONG' ? 1n : 0n
const reduceOnlyBit = reduceOnly ? 2n : 0n
const flags = toBeHex((salt << 4n) | buyBit | reduceOnlyBit, 8)

const domain = {
  name: 'IsolatedTrader',
  version: '2.0',
  chainId: 6343,
  verifyingContract: market.trader_address,
}
const types = {
  Order: [
    { name: 'flags', type: 'bytes8' },
    { name: 'quantity', type: 'uint128' },
    { name: 'price', type: 'uint128' },
    { name: 'triggerPrice', type: 'uint128' },
    { name: 'triggerCondition', type: 'uint8' },
    { name: 'leverage', type: 'uint128' },
    { name: 'maker', type: 'address' },
    { name: 'expiration', type: 'uint128' },
  ],
}
const message = {
  flags,
  quantity: BigInt(market.min_order_size_e18_raw),
  price: 0n,
  triggerPrice: 0n,
  triggerCondition: 0,
  leverage: parseUnits(String(leverage), 18),
  maker: wallet.address,
  expiration: BigInt(expiration),
}

const orderDigest = TypedDataEncoder.hash(domain, types, message)
const signature = (await wallet.signMessage(getBytes(orderDigest))) + '01'

const orderResponse = await fetch(API + '/v0/orders', {
  method: 'POST',
  headers: {
    Authorization: 'Key ' + API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    market_id: market.market_id,
    side,
    order_type: 'MARKET',
    price: 0,
    quantity,
    leverage,
    reduce_only: reduceOnly,
    expiration_seconds: expiration,
    salt: salt.toString(),
    signature,
    signature_type: 1,
  }),
})

const result = await orderResponse.json()
if (!orderResponse.ok) throw new Error(JSON.stringify(result))
console.log(result)

See Placing orders for limit and conditional request fields.

Trading session keys

Signing every order with your main wallet is slow for interactive or automated trading — hardware wallets and browser wallets prompt on every signature. A session keyis a fresh, locally generated keypair that you authorize to sign on your account's behalf. It is separate from the Privy identity token or API key authenticating your requests. The application checks its expiry and trading permission. Owner-wallet actions such as withdrawals and authorizing another key use separate checks.

An expired trading key cannot authorize trades or margin or leverage changes through the app or API, even with a valid account login. Those contract calls also require the exchange's settlement operator; holding a trading key alone does not let someone execute them directly. Contracts enforce their own permissions, rather than API login checks. Limited direct actions, such as self-revocation and closing a position after a market is delisted and its waiting period has elapsed, remain possible. Delisted-position settlement returns funds to the owner's account.

AccountManager stores the key's contract approval until it is revoked. Application expiry and deleting the local key do not clear that approval. The signed forward request's deadline limits when that request can execute, not how long the resulting approval lasts.

Managing browser trading keys

Most users do not need to manage these keys manually. To inspect or revoke one, open /keys in the exchange app, for example Trading keys on testnet. The page is intentionally absent from Settings and app navigation.

Active keys appear by default. Choose Historyto see expired and revoked keys, ten per page. The list contains public addresses registered to your account across browsers and sessions; it does not give this browser another browser's private keys.

Trading access expires 30 days after a key was first registered. Expired keys remain revocable. Choose Revoke to remove the contract approval and check that the operation completes. Forget only deletes the local copy; Use in this browser only controls local signing. Neither action revokes contract approval.

Registering a key through the API

Registration is gasless, relayed on-chain by the exchange:

  1. Generate a new keypair locally and keep the private key.
  2. POST /v0/session-keys/prepare with the new public address and operation: "register". The response contains the fields of a forward request (the meta-transaction pattern where a relayer submits your signed call and pays the gas): from, to, gas, nonce, data, deadline, plus the chain_id and the AccountManager / MinimalForwarder contract addresses involved.
  3. Sign that forward request with your main wallet (an EIP-712 signature).
  4. POST /v0/session-keys/relay with the forward-request fields, your signature, the session public address, approved: true. Permissions and application lifetime are server-defined; do not send expiration_seconds orpermissions. The exchange relayer submits the registration on-chain and returns an operation_id and its status. A pending response must be reconciled before using the key.

Once registration is confirmed active, sign orders with the session key exactly as described above — the maker field stays your account address; only the signer changes. The exchange accepts the signature if the recovered signer is one of your registered session keys that is active, unexpired, and has the required permission.

A pending relay is not an active grant. If a response is lost, retain the key and registration request and reconcile their status before generating a replacement. Revocation likewise completes only after confirmation.

List your keys with GET /v0/session-keys. Status is derived from observed contract approval and the key's lifetime:ACTIVE, EXPIRED, or REVOKED. Omit the status filter to include history. To revoke a key with the owner wallet, prepare with operation: "revoke", then relay the signed request with approved: false and the session_key_id. Revocation requires confirmation; neither application expiry nor removal from this browser proves it.