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 matches a registered account, the operation proceeds. If it doesn’t, the server returns SIGNATURE_INVALID.

Domain

All signatures use a fixed EIP-712 domain:

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, and exclude undefined/null fields. 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 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 on Devnet). 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.
The domain separator is fixed by name="Exchange", version="1", chainId=9767, verifyingContract=0x0000000000000000000000000000000000000000. If yours differs, stop — nothing below will match.

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 a domain whose chainId is 9767. 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
Hex string with a 0x prefix — the domain.chainId your client used to sign the typed struct. Defaults to 0x2627 (9767) when omitted.
Rules:
  • Typed path only. The Agent path — order, cancel, and the rest — always uses 9767 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 9767, so existing clients need no change.
  • 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, the error message includes the recovered address and the expected address, which helps pinpoint which key you actually signed with.