Skip to main content
Every POST /exchange request must include a valid EIP-712 structured-data signature (secp256k1 ECDSA) over the action payload. The server recovers your Ethereum address from the signature at request time — there are no API keys, bearer tokens, or sessions to manage. If the recovered address belongs to a registered account (or to an API wallet approved by one), the operation proceeds. If the signature cannot be recovered, or recovers an address other than the one the action requires, the server returns 401 SIGNATURE_INVALID. If the signature is valid but the recovered address has no registered account and is not an approved API wallet, the server returns 400 BAD_REQUEST with the message signer has no registered account.

Domain

All signatures use this EIP-712 domain. Only chainId differs between environments:

Two Signing Paths

The signing struct is chosen by action.type:

Agent path

Trading and programmatic actions — order, cancel, cancelByCloid, modify, and most others. The entire canonical action is folded into a single actionHash carried by a generic Agent(string source, bytes32 actionHash) struct.

Typed path

Funds and permission actions — registerAccount, approveAgent, revokeAgent, lockCollateral, unlockCollateral, transferBetweenDeployers. Each has a field-level EIP-712 struct so a wallet can render human-readable values for review.
Both paths share the same domain, digest formula, and request envelope. Only the hashStruct differs. If an action type is not in the typed list above, use the Agent path.

Agent Path

Used for every action except the six typed-path ones — all trading and programmatic calls, such as order, cancel, and modify, plus the margin, leverage, TP/SL, and share-group actions. As a rule of thumb: if action.type isn’t in the typed list below, sign it via the Agent path.
1

Build the action object

Construct the action as a plain JSON object. Every action has a type field that identifies the operation.
2

Serialize to canonical JSON

Serialize the action to canonical JSON: sort keys alphabetically, use compact separators (, and :) with no whitespace. Fields whose value is null are not removed — they serialize as the literal null. Do not strip them on the client; the simplest approach is to leave null-valued fields out of the action altogether. In Python this is exactly json.dumps(action, sort_keys=True, separators=(",", ":")).
Key ordering is crucial. The server re-serializes the action using the same rules; any mismatch produces a different hash and SIGNATURE_INVALID.
3

Compute the actionHash

Concatenate the canonical JSON bytes with the nonce encoded as a big-endian 8-byte value, then keccak256:
4

Build the hashStruct

Wrap the actionHash in the generic Agent struct with source = "b":
5

Compute the EIP-712 digest and sign

See Digest & signature below — this step is identical for both paths.

Typed Path

Used for registerAccount, approveAgent, revokeAgent, lockCollateral, unlockCollateral, and transferBetweenDeployers. Each action encodes its fields directly into a struct — there is no canonical JSON step. The hashStruct is keccak256( typeHash(<Struct>) ‖ enc(field1) ‖ … ‖ enc(nonce) ), where each field is encoded as: Struct definitions (the nonce is always the final uint64 field):

Digest & Signature

Both paths finish identically. Build the domain separator, form the EIP-712 digest, and sign it directly.
1

Domain separator

where chainId is 9767 on Devnet and 9788 on Mainnet, and typeHash(EIP712Domain) = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)").
2

EIP-712 digest

Use Ethereum keccak256, not NIST SHA-3. The digest is already the final hash — sign it directly (prehash = false). Never hash it again.
3

Sign the digest

Sign the 32-byte digest with secp256k1 (RFC 6979 deterministic k). Set v = 27 + recovery_bit.

Signature Format

Include the signature in the signature field of the request envelope:
Both r and s are 32-byte values as 0x-prefixed lowercase hex strings (66 characters including the prefix). v is 27 or 28.

Request Envelope

The full POST /exchange body is the same for both paths:
vaultAddress is an optional top-level field for vault-proxy operations only (not currently enabled). Delegated agent trading does not use it — an agent signs with its own key, and the server routes to the master account from the recovered signer address, so trade actions ignore vaultAddress entirely.

Signing path by action

Every POST /exchange action uses one of two EIP-712 paths. The path is fixed per action.type — there is nothing to negotiate and nothing to infer.
The typed path covers funds movement and permission changes; the Agent path covers trading. An approved agent wallet can sign Agent-path actions for its master, but not the typed ones.

Test vectors

Verify your signer against these before sending anything. All were produced by the reference implementation and confirmed against the live API — the server recovered the signing address from each.
The key below is a throwaway fixture published for reproducibility. It holds nothing and must never be used for an account you care about.
These vectors use the Devnet domain: name="Exchange", version="1", chainId=9767, verifyingContract=0x0000000000000000000000000000000000000000. If your domain separator differs, stop — nothing below will match.
Encode verifyingContract as a 32-byte word, not 20 bytes. EIP-712 encodes an address field left-padded to 32 bytes, so the domain preimage contains 32 zero bytes here, not 20. Getting this wrong shifts every byte after it and produces a valid-looking separator that matches nothing — the most common reason a first signature fails.

Typed path

Agent path

The canonical JSON is shown because it is the exact byte string that is hashed — keys sorted, no whitespace. If your canonical form differs by even one byte, your digest will not match.
Reproduce the digest first — it needs no private key and isolates every encoding decision. Only once digests match should you compare signatures.

Browser wallet signing

The typed path builds its digest over the environment’s chainId — 9767 on Devnet, 9788 on Mainnet. Signing with a raw private key — an SDK, eth_account, or a viem local account — needs nothing extra: keep the default. Browser extension wallets are different. When signing through eth_signTypedData_v4, they require domain.chainId to equal the wallet’s currently active chain and refuse to sign otherwise. To support them, send the chain ID you actually signed with as an optional top-level envelope field:
string
signatureChainId — Hex string with a 0x prefix — the domain.chainId your client used to sign the typed struct. Defaults to the environment’s chain ID when omitted: 0x2627 (9767) on Devnet, 0x263c (9788) on Mainnet.
Rules:
  • Typed path only. The Agent path — order, cancel, and the rest — always uses the environment’s chain ID and ignores this field.
  • Not part of the signature. It sits at the top level beside action, signature, and nonce, and never enters the canonical JSON. The server rebuilds the domain with it before verifying; a value that disagrees with what you actually signed recovers the wrong address and returns 401 SIGNATURE_INVALID.
  • Omitting it makes the server rebuild the domain with the environment’s chain ID.
  • A malformed value — missing the 0x prefix, containing non-hex characters, or longer than 64 bits — returns 400 BAD_REQUEST.

Complete Signing Helper

A reusable helper that implements both paths. This is the reference implementation — copy, paste, and run.

Common Errors

When you receive SIGNATURE_INVALID, recompute the digest locally against the test vectors and compare the address your signer recovers with the address you expect; a mismatch means the request was signed with a different key or a different digest.