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 in the API reference; each accepts the header schemes below, and signed tokens only where their scope allows.
  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 three Authorization header schemes, Token, Key and Bearer:

HeaderCredential
Authorization: Token <token>A signed token that your account wallet or one of its trading keys signs, lasting at most 8 hours. A bot that signed up from code uses these.
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. Revoke it with POST /v0/me/api-key/revoke, which needs only the key itself.
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 signed tokens or 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.

The read-only public wallet portfolio endpoints are the documented exception: they expose a limited public profile, margin, position, and profit-and-loss view without authentication so a wallet portfolio can be shared. Routes marked with a lock in the API reference require authentication. Some otherwise public endpoints require authentication for account-scoped parameters: for example, GET /v0/fills/by-market/:market_id is public, but adding its user_id query parameter requires authenticating as that user.

Set up a bot account

A bot gets its account in one of two ways:

  • From code. The account wallet signs up with one signature and registers a trading key, and the bot authenticates with signed tokens. No browser is involved. See Sign up from code; it is open on testnet and not yet on mainnet.
  • In the browser, with an API key, as follows.
  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. Send it as the Authorization: Key <api-key> header on HTTP requests, and as the authorization field of a WebSocket SUBSCRIBE message (see WebSockets).
  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, and revocation removes it. Client initialization reuses the configured key. To rotate a key through the API:

  1. POST /v0/auth/challenge with { "userAddress": "<owner wallet address>", "action": "rotate_api_key" }. No authentication is needed. The response contains challenge_id, message, and expires_at.
  2. With the owner wallet, sign the message text exactly as returned, as a personal message. In ethers v6: wallet.signMessage(message). The message is a Sign-In with Ethereum (EIP-4361) message: wallets show the site, account and purpose it names, and warn when another site asks for it. A signature over a hash of the message is refused.
  3. POST /v0/me/api-key/rotate with { "challenge_id": "...", "signature": "0x..." }, authenticated with the current API key or a Privy identity token. The response contains the new apiKey and a materialized flag. The new key works for orders and other writes at once, and the old one stops working for them. When materialized is false, the read servers have not caught up yet: for a short time, reads with the new key can return 401 while the old key still works for them. Retry such a read after a moment. WebSocket connections the old key authenticated are closed (code 4401, reason api_key_revoked); connect again with the new key, retrying after a moment if the subscription answers Authentication failed.

Revoke a leaked key

A key can revoke itself, so a bot whose key leaks can be cut off without the owner wallet. Send POST /v0/me/api-key/revoke with an empty JSON body, authenticated with that key; no wallet signs anything. A Privy identity token works too, and revokes whichever key the account holds. To have the request refused (409) when its credential belongs to another account than you mean, as a browser session another tab switched can, send { "expected_user_id": <your user id> } instead of an empty body. In the web app, use Revoke key under Settings, API key. The response contains success, revoked and materialized. A key revokes only itself: if a rotation replaces it while the request is in flight, the new key keeps working and revoked is false, as it is when the account had no key. The account then has no key until you generate a new one.

Orders, cancels and account changes refuse the revoked key at once. Other requests, a new WebSocket subscription among them, may still accept it for a short time, until the servers that answer them catch up; materialized: false means they had not yet. WebSocket connections the key authenticated are closed, with code 4401 and reason api_key_revoked, one opened with it before the servers caught up included. Once revoked, the key gets 401 from this route too, so a retry after a lost response answers 401.

Whenever a key is revoked, the account's inbox in the web app gets a notice, and so does the account's email address if it has one. The notice says when the key was revoked and how: with the key itself, which means whoever had the key did it, or from a signed-in session of the account. If you did not revoke it, treat the key as leaked and generate a new one in Settings; if it was revoked from a session, someone may also have your login. The notice never contains the key. These notices cannot be turned off. A request that revoked nothing sends none.

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.

Sign up from code

A wallet can create its exchange account with one Sign-In with Ethereum (EIP-4361) signature, without signing in on the web app. Only an ordinary wallet account (an externally owned account, or EOA) can sign; smart-contract wallets cannot. Wallet sign-up is open on testnet. It is not open on mainnet yet, where the sign-up request answers 403 with details.code WALLET_SIGN_UP_CLOSED. The wallet can later sign in on the web app through Privy and reaches the same account.

Before it signs up, the wallet needs at least $5 of USDM in MarginBank, the exchange's collateral contract. The exchange reads that balance on chain when it checks the signature. Three ways to meet it:

  • Deposit USDM yourself: approve the MarginBank address from GET /v0/contracts/shared to take it, then call MarginBank's depositToBank(wallet, amount). The wallet pays the gas.
  • Where deposits from other chains through Unifold are on, ask for deposit addresses before the account exists: POST /v0/wallet/unifold/deposit-addresses with a signed deposit_addresses challenge, which works like the sign-up challenge below. A deposit to one of them reaches MarginBank as USDM for the wallet, and the account takes the addresses over once it exists. Asking for addresses needs no balance: the minimum applies only to the sign-up request. The route answers 503 where Unifold deposits are off, as on testnet, and is refused wherever wallet sign-up is closed. A wallet that already has an account gets 409 ACCOUNT_EXISTS and uses POST /v0/account/unifold/deposit-addresses with its credential instead.
  • On testnet, POST /v0/testnet/faucet/wallet with a signed testnet_funds challenge deposits test USDM straight into MarginBank for the wallet, with no gas needed. A wallet that already has an account gets 409 ACCOUNT_EXISTS and uses POST /v0/testnet/faucet with its wallet token, which sends test USDM and testnet gas to the wallet.

To sign up:

  1. POST /v0/auth/challenge with { "userAddress": "<wallet address>", "action": "sign_up" }, adding invite_code or referral_code if you have one. No authentication is needed. The response's message is the text to sign; it expires after five minutes. The deposit_addresses and testnet_funds actions take no codes.
  2. Check the message before signing it. It must name an MNX web site as the site asking (testnet.mnx.fi on testnet, mnx.fi or app.mnx.fi on mainnet), your wallet, the chain ID and API URL you are calling, no Request ID, and under Resources exactly the codes you sent (urn:mnx:invite:<code> and urn:mnx:referral:<code>), or no resources when you sent none. Refuse anything else, so that a forged response cannot obtain your signature for another purpose. Each action has its own statement, so a signature for one never serves another.
  3. Read the statement. It accepts the MNX Terms of Service and Risk Disclaimer at the version it names, and confirms that you are not a Restricted Person as the Terms define it. Signing it makes that acceptance and that statement for the account, and the exchange keeps the signed text. The API does not check where you are; the statement is your confirmation.
  4. Sign the exact text as a personal message (EIP-191; in ethers v6, wallet.signMessage(message)) and send { "message": "...", "signature": "0x..." } to POST /v0/users/sign-up, with no authentication. The response contains user_id, eoa_address, terms_version and ready.
  5. Mint a wallet token and ask GET /v0/me/readiness with it until ready is true. The exchange holds a new account until it has set it up on chain, and holds names what still holds it. Bound the wait, for example one request a second for up to two minutes, and register a trading key or place orders only once the account is ready.

Refusals from the sign-up request:

Status and codeMeaning
409 ACCOUNT_EXISTSThe wallet already has an account: it is signed up. Continue with a token.
409 PRIVY_SECONDARY_WALLETThe wallet is linked to an MNX web login as one of its other wallets. Use that login's account instead.
400 SIGN_UP_BALANCE_TOO_LOWMarginBank holds less than the minimum for the wallet (details.minimum_e18_raw and details.balance_e18_raw). Deposit, then ask for a new challenge.
401 SIGN_UP_CHALLENGE_INVALIDThe challenge expired, was changed, is for another environment, or came from an exchange server that has restarted since. Ask for a new one. A signature that is not the wallet's over the exact text is also refused with 401.
429 SIGN_UP_IP_LIMITED, SIGN_UP_RATE_LIMITED or SIGN_UP_BUILDER_LIMITEDToo many sign-ups from your address, across the exchange, or through your builder's quota. Retry after the Retry-After header's delay. An app registered as a builder may send its API key with the request so that its users' sign-ups count against its own quota.
403 WALLET_SIGN_UP_CLOSEDWallet sign-up is not open in this environment.

Then register a trading key and authenticate with its tokens. The bot quickstart has a runnable script for the whole sequence.

Signed tokens

A signed token is a short text that your account wallet or one of its trading keys signs as a personal message, sent as Authorization: Token <token>. The exchange stores no token: it checks the text and the signature, and on every use that the signer still has the account's authority.

SignerWhat its token can do
Account wallet (a wallet token)Read the account and manage its trading keys. It never trades, so a wallet signature obtained by phishing cannot trade.
Trading key (a trading-key token)read, or trade: also send cancels, and orders, margin changes and withdrawals, which still carry their own signatures.

The wallet signs a Sign-In with Ethereum message, which wallets display with the site asking. A trading key signs plain text in code, with no prompt:

<site> wants you to sign in with your Ethereum account:
<account wallet>

Sign in to the MNX API: read this account and manage its trading keys for up to 8 hours.

URI: <API URL>
Version: 1
Chain ID: <chain ID>
Nonce: <8 to 64 letters or digits>
Issued At: <time>
Expiration Time: <time, at most 8 hours later>
MNX trading key token
Account: <account wallet>
Key: <trading key>
Scope: <read or trade>
URI: <API URL>
Chain ID: <chain ID>
Nonce: <8 to 64 letters or digits>
Issued At: <time>
Expiration Time: <time, at most 8 hours later>
  • <site> is testnet.mnx.fi on testnet, and mnx.fi or app.mnx.fi on mainnet.
  • <API URL> and <chain ID> are https://api.testnet.mnx.fi and 6343 on testnet, and https://api.app.mnx.fi and 4326 on mainnet, exactly. Testnet shares its chain with development environments, so the URL names the environment.
  • Addresses are EIP-55 checksummed (ethers getAddress). Times are written as JavaScript's toISOString() writes them, with milliseconds: 2026-10-30T12:00:00.000Z. Lines end with a single \n, and the text has no trailing newline. The exchange refuses any other spelling.
  • Issued At may be at most a minute ahead of the exchange's clock, and the expiry must come after it, by at most 8 hours.

The token is the text in URL-safe base64 without padding, a dot, and the 65-byte signature in lower-case hex without 0x:

const signature = await signer.signMessage(text) // ethers v6
const token =
  Buffer.from(text).toString('base64url') + '.' + signature.slice(2).toLowerCase()

Sign the next token before the current one expires and use each token for its whole life. Each signer has a budget of 30 new tokens, refilled at one a minute, so a client that signs a token for every request is soon refused with 429. Failed tokens also spend a budget of your address. Routes declare which tokens they take:

  • Reads, including GET /v0/me/readiness, and routes that only compute take any token.
  • Orders, cancels, margin changes and withdrawals take a trade token.
  • Trading-key management (session-keys/prepare, session-keys/relay, session-keys/switch-off) takes a wallet token.
  • API-key rotation and revocation, terms acceptance, notification settings and a few other account settings take no token and answer 403 to one.

A token the exchange cannot read, for another environment or site, not yet valid, expired, lasting too long or with a signature that is not its signer's gets 401 Invalid token. with details.reason. A token whose signer has lost the authority gets 401 Token refused., with details.reason such as key_revoked, key_expired or key_switched_off.

On a WebSocket, send the token as the authorization field of SUBSCRIBE ("Token <token>"). A connection lasts no longer than the latest token it was sent: send the next token in a SUBSCRIBE at least a minute before the old one expires. Otherwise the server closes the connection with code 4401 and reason token_expired, or token_refused once its key is switched off or revoked.

To learn whether the server took the new token, repeat in that SUBSCRIBE a private topic the connection already follows, such as user:<user_id>. The server answers SUBSCRIBED naming the topic once it accepts the token, or SUBSCRIBE_REJECTED with retryable: true when it refuses it, and the connection keeps the old token; send the new one again a few seconds later. A SUBSCRIBE with an empty topic list also carries the token, but it is answered SUBSCRIBED either way.

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; future, at most 14 days ahead (30 for TP/SL)
)

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.
  • The request's salt and expiration_seconds must match the signed values.

Signature format

An order's signature is 66 bytes: the 65-byte signature your wallet returns, followed by one byte that says how it was signed. Append the byte that matches your signing call (ethers v6):

  • 00 after wallet.signTypedData(domain, types, message). Wallets can show the order's fields before signing.
  • 01 after wallet.signMessage(getBytes(orderHash)), where orderHash is TypedDataEncoder.hash(domain, types, message). Use this for a signer that can only sign a hash or a personal message, and sign the hash's 32 bytes, not its hex text.

Both calls start from the same order hash. signMessage adds Ethereum's personal-message prefix before signing, and 01 tells the exchange to add it too when checking. A mismatched pair, such as signTypedData with 01, fails verification. The byte is separate from the request's signature_type field: send signature_type: 1 with either call.

Registering a trading key is different: its forward request is signed with signTypedData and sent exactly as the wallet returns it, with no byte appended (see Registering a key through the API).

An accepted order's order.order_hash is that orderHash, so you can compute it before placing the order.

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 testnet wallet associated with your API key. It must already have collateral deposited in MarginBank; a funded wallet alone is not enough (the bot quickstart covers the faucet and deposit). 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 LONG market order worth a little over the $5 minimum order value at the book's best ask; change the side, quantity, or reduce-only value in both the signed message and request together.

import { Wallet, 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 && item.mark_price_e18_raw
)
if (!market) throw new Error('No trading-enabled market is available')

const side = 'LONG'
const reduceOnly = false
const leverage = 1
// An order without reduce-only must be worth at least $5, and a market order
// is valued at the price the book clears its whole quantity at when it
// arrives (a sell on a binary market at one minus it). A buy clears at or
// above the best ask: take the smallest step quantity worth $5 at 95% of the
// ask read here, leaving room for the book to move, and at least the
// market's minimum size. Quantity and price are 18-decimal integers; their
// product has 36 decimals.
const bookResponse = await fetch(
  API + '/v0/markets/' + market.market_id + '/book'
)
if (!bookResponse.ok) throw new Error(await bookResponse.text())
const book = await bookResponse.json()
if (!book.best_ask_e18_raw) throw new Error('The book has no ask to buy from')
const ONE_E18 = 10n ** 18n
const askRaw = (BigInt(book.best_ask_e18_raw) * 95n) / 100n
const stepRaw = BigInt(market.step_size_e18_raw)
let quantityRaw = (5n * ONE_E18 * ONE_E18 + askRaw - 1n) / askRaw
quantityRaw = ((quantityRaw + stepRaw - 1n) / stepRaw) * stepRaw
const minimumSizeRaw = BigInt(market.min_order_size_e18_raw)
if (quantityRaw < minimumSizeRaw) quantityRaw = minimumSizeRaw
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: quantityRaw,
  price: 0n,
  triggerPrice: 0n,
  triggerCondition: 0,
  leverage: parseUnits(String(leverage), 18),
  maker: wallet.address,
  expiration: BigInt(expiration),
}

// signTypedData signs the order hash itself, so append 00 (see Signature format).
const signature = (await wallet.signTypedData(domain, types, message)) + '00'

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_e18_raw: quantityRaw.toString(),
    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.

Rejected signatures

When the signer recovered from an order is neither your account's wallet nor one of its trading keys, placement answers 400 with a message that starts Order signature does not match this order for account 0x…: it recovers 0x…. Unless a trading key whose registration is not yet confirmed signed the order, this is a signing mistake: the exchange rebuilds the order from your request, and a signature over anything else recovers an unrelated address. Check that:

  • the appended byte matches your signing call;
  • chainId belongs to the environment you call and verifyingContract is this market's trader_address;
  • maker is the wallet of the account your credential belongs to, even when a trading key signs;
  • the signed quantity, price, trigger price, and leverage are the request's values scaled by 1018, with price 0 for any market order type;
  • triggerCondition follows the rule above;
  • flags encodes the request's salt, side, and reduce-only value, and the signed expiration equals expiration_seconds.

To check the signing step on its own, recover the signer locally before sending. Using the example's variables:

import { TypedDataEncoder, getBytes, verifyMessage, verifyTypedData } from 'ethers'

const sig = signature.slice(0, 132) // without the appended byte
const signer = signature.endsWith('00')
  ? verifyTypedData(domain, types, message, sig)
  : verifyMessage(getBytes(TypedDataEncoder.hash(domain, types, message)), sig)
// Compare with the trading key's address instead if one signs.
console.log(signer === wallet.address)

This confirms only that you signed what you meant to sign. The exchange still rebuilds the order from the request, so check the values above too.

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 key is a fresh, locally generated keypair that you authorize to sign on your account's behalf. It is separate from the credential authenticating your requests, though it can sign its own tokens. The application checks its expiry and whether it is switched on. 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 History to 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 the MNX app first registered a key; a key registered through the API can last up to 180 days. Expired keys remain revocable. Choose Revoke to remove the contract approval and check that the operation completes. Revoking a key also stops the open orders it signed, take-profit and stop-loss orders included: they still show as open, and when one would fill, the batch fails and every order your account has in that batch is cancelled, including orders your wallet or other keys signed. Cancel your open orders before revoking and place them again afterwards. 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

The exchange pays the gas for every registration, so before you register a key your account needs a current balance of at least $0.50, counting margin reserved for your open orders and margin held in your open positions. Until then, both the prepare and relay steps below answer 400 with details.code set to TRADING_KEY_REGISTRATION_UNFUNDED and the message "Deposit at least $0.50 before enabling trading". Deposit, wait for the deposit to appear in your balance, and prepare again. Revoking a key does not require funds.

An account can register at most 10 keys in 24 hours, and the MNX app sets one up for each sign-in that trades. Past that, both steps answer 429 with details.code set to TRADING_KEY_DAILY_LIMIT and details.resume_at giving the time a key can be registered again. The limit applies only to registering keys: orders signed by your wallet or by a key that is already active are still accepted. In the MNX app, a sign-in that needs a new key cannot get one until that time, so the app shows it as when you can trade again.

Each registration sent to relay, including a retry after one failed, also spends from a budget of 10 per account that refills at 10 a day. Over that budget, both steps answer 429 with details.error rate_limited, details.op_class session_key_management, and a Retry-After header, without details.code.

Registration is gasless, relayed on-chain by the exchange. Both steps below need the account's credential: a wallet token, an API key or a Privy identity token. A trading key's token cannot register keys.

  1. Generate a new keypair locally and keep the private key.
  2. POST /v0/session-keys/prepare with session_public_address set to 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 chain_id, account_manager_address, and minimal_forwarder_address.
  3. Sign that forward request with your main wallet as EIP-712 typed data (for example, ethers signTypedData; no suffix byte):
    domain = {
      name: "MinimalForwarder",
      version: "0.0.2",
      chainId: <chain_id>,
      verifyingContract: <minimal_forwarder_address>
    }
    ForwardRequest(
      address from,
      address to,
      uint256 gas,
      uint256 nonce,
      uint256 deadline,
      bytes   data
    )
  4. POST /v0/session-keys/relay with the forward request's from, to, gas, nonce, deadline, and data, your signature, session_public_address, and approved: true. Unknown fields are rejected with a 400, including the chain_id and contract addresses the prepare step returns, so do not send the prepare response back unchanged. Permissions are server-defined; do not send expiration_seconds or permissions. 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: ask GET /v0/session-keys until the key's status is ACTIVE.

A key lasts 30 days from its registration unless the relay request also sends lifetime_days, a whole number of days from 30 to 180. An app that registers one key per user can ask for 180 so its users register again at most twice a year; the MNX app keeps 30. A key keeps the lifetime of its first registration: registering the same address again changes neither its age nor its lifetime. Switching a key off or revoking it stops it at any time, so a longer lifetime does not slow the response to a stolen 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, the key's lifetime and whether it is switched on: ACTIVE, SWITCHED_OFF (approved and within its lifetime, but switched off), EXPIRED, or REVOKED. Each key also lists its lifetime_days and expires_at. The optional status filter accepts ACTIVE, SWITCHED_OFF or REVOKED; omit it to list every key. 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. A key that is already revoked cannot be revoked again: both steps answer 400 with details.code set to TRADING_KEY_ALREADY_REVOKED.

Switching a key off

Switching a trading key off stops it at once without touching its approval on chain: its tokens get 401 (key_switched_off) and its new orders are refused. It costs no gas, and the owner can switch the key back on. Orders the key signed earlier stay open, unless the switch-off also cancels every open order of the account: the exchange does not record which key signed an order, so it cannot cancel only that key's. The key is switched off before the cancel starts, so it cannot place an order in between. Registering a switched-off key again does not switch it on.

  • The owner, with a credential: POST /v0/session-keys/switch-off with { "session_public_address": "<key>", "cancel_orders": true } (cancel_orders is optional), authenticated with a wallet token, an API key or a Privy identity token. A trading key's token cannot switch keys.
  • The owner, with a signature: the account wallet signs a Sign-In with Ethereum message, sent with no login to POST /v0/session-keys/switch as { "message": "...", "signature": "0x..." }. This is the only way to switch a key back on.
  • The key itself: whoever holds the trading key signs a switch-off text with it and sends it to the same route, with no login and no owner wallet, so a program can stop its own leaked key. A key cannot switch itself back on.

The owner's message is laid out like a wallet token, with one of these statements, a nonce of at least 16 letters or digits, and an expiry at most 5 minutes after it is issued:

Switch off trading key <key> of this account.
Switch off trading key <key> of this account and cancel the account's open orders.
Switch on trading key <key> of this account.

The key signs this text, spelled as for tokens:

MNX trading key switch-off
Account: <account wallet>
Key: <trading key>
Cancel orders: <yes or no>
URI: <API URL>
Chain ID: <chain ID>
Issued At: <time>

A signed command must have been issued in the last 5 minutes, allowing a minute of clock difference, and works once. The exchange refuses a command issued no later than the key it names last switched with 409 and details.code TRADING_KEY_SWITCH_REPLAYED, whose details.not_before says when a new one may be signed. Each switch is stamped on the key a minute ahead of the exchange's clock, the clock difference a signer may have, so a command for that key signed within a minute after its switch is refused until then. That way no command signed before a switch can take effect after it, even after the owner switches the key back on. Commands for other keys are unaffected. Do not resend a refused command: wait until not_before, sign a new one, and send it once. A revoked or expired key's own command is refused with 403 and details.code TRADING_KEY_NOT_LIVE. The renounce routes' limit per address applies before the signature is checked, and their limit per key after it.

Both routes answer with the key's status after the switch, changed (false when the key was already switched that way) and cancel: null when no cancel was asked for, the counts cancelled_count and cancel_pending_finalize_count, or refused with the status, message and code of a cancel that was refused, for example over its rate budget. A refused cancel never undoes the switch-off; send a new signed command to retry it.

If a trading key is stolen

  1. Switch it off with the cancel, by the key itself or by the owner. This stops its tokens and new orders at once and cancels the account's open orders.
  2. Check that the cancel finished: the answer's cancel has counts rather than refused, and GET /v0/orders/by-user/:user_id?status=ACTIVE lists no orders.
  3. Then remove its approval on chain: revoke it with the owner wallet (prepare with operation: "revoke" and relay), or have the key renounce itself through POST /v0/session-keys/renounce.
  4. Place your orders again with another key or the wallet.

Revoking or renouncing first would leave the orders the key signed open: each fails when it would fill, and takes the account's other orders in the same batch with it.