Skip to main content

Placing orders

tip

Stable. The core order path — submit_order, batch_order, spot_order, and the cancel family — is committed.

The action catalog lists twenty-two order actions. Five of them cover almost every integration. This page gives you those five first, then tiers the rest so you know what to skip. For the field-level schema of any action, follow its link into POST /exchange.

TL;DR

  • One perp order is submit_order. It returns the real oid in the HTTP response.
  • Many perp orders are batch_orderone action, one signature, one status per leg.
  • Spot uses spot_order on a separate id space. Spot cannot batch: send one action per spot order.
  • A production client signs with an agent key, not with the master key.
  • The write side carries integers on two planes: price is × 1e8, size is × 10^sz_decimals. The read side returns whole-unit decimal strings. Mixing the two is the classic first-integration bug.

Place one limit order on a perp

Step 1 — read the market's ids and grids

The write path takes a numeric market id. Read it once at start-up, with the price and size grids, from markets_meta:

curl -X POST https://api.testnet.mtf.exchange/info \
-H 'content-type: application/json' \
-d '{"type":"markets_meta","coin":"BTC"}'
You needField on markets_metaExample
Market id for marketsigning_id0
Price gridtick_size (whole units)"0.1"
Size precisionsz_decimals5
Size gridstep_size (whole units)"0.00001"
Smallest ordermin_order (whole units)"0.00001"
warning

/info addresses markets by coin; /exchange takes the number. You query a read by symbol. asset_id is on no part of the wire today — not as a request argument, and not as a response field. Reading it gives you undefined.

The id lives on markets_meta, and it is named signing_id because it is the value a signed action carries in market (perp) or pair (spot). The dynamic markets read carries no id on its perp rows, so join the two reads on coin. Cache the map at start-up.

Step 2 — convert to the wire planes

limit_px = price × 1e8 → 50000.00 × 1e8 = 5000000000000
size = quantity × 10^sz_decimals → 0.1 × 1e5 = 10000

Both values must sit on the market's grid. The node rejects an off-grid price or size. It never snaps them — the signature binds the exact integers you sent, so snapping would execute a price you never signed.

Step 3 — sign and post the action

curl -X POST https://api.testnet.mtf.exchange/exchange \
-H 'content-type: application/json' \
-d '{
"signature": "0x<65-byte r||s||v>",
"nonce": 1735689600001,
"action": {
"type": "submit_order",
"order": {
"owner": "0x<your account>",
"market": 0,
"side": "bid",
"kind": "limit",
"size": 10000,
"limit_px": 5000000000000,
"tif": "gtc",
"stp_mode": "cancel_oldest",
"reduce_only": false,
"cloid": "0x0000000000000000000000000000ab01"
}
}
}'

This buys 0.1 BTC at 50000.00, good-till-cancelled.

FieldTypeWhat it does
ownerhex addressThe account the order trades for. The recovered signer must equal it, or be an approved agent of it. Required.
marketuint32The numeric market id from step 1
side"bid" / "ask"bid buys, ask sells
kind"limit" / "market"Use "limit". The trigger kinds need a trigger block — see trigger orders
sizeuint64Quantity on the 10^sz_decimals plane
limit_pxuint64Price on the 1e8 plane
tif"gtc" / "ioc" / "alo"Rest, take-then-cancel, or post-only. "aon" is rejected
stp_mode"cancel_oldest" / "cancel_newest" / "cancel_both"What happens when the order would match your own resting order. "reject" is rejected
reduce_onlybooltrue refuses to grow the position
cloidhex string | nullYour own id, 0x + 32 hex chars. Use one on every order — it is the retry key

The envelope around action carries three fields: signature, nonce, and action. An optional signed expires_after is the only other one. There is no top-level sender — the account comes from owner inside the body. See typed-data signing for how the digest is built.

Step 4 — read the response

{ "statuses": [ { "resting": { "oid": "12345", "cloid": "0x0000000000000000000000000000ab01" } } ] }

An order action waits for commit, then returns 200 OK, so the oid is real. The entries are at body.data.statuses — one per order placed. The union is:

EntryMeaning
{"resting":{"oid":N,"cloid":"0x…"}}On the book
{"filled":{"oid":N,"total_sz":"…","avg_px":"…"}}Matched
{"error":{"code":"…","message":"…"}}Rejected. The same error object the envelope carries — match on code, never on message
{"noop":{"reason":"…"}}Accepted, and it changed nothing — a reduce_only leg with nothing left to reduce. A success. Do not retry it. No oid. See noop
{"pending":{"action_hash":"0x…","nonce":N}}Admitted, no commit inside the wait window (5 s by default)

pending is not a failure. The action may still commit. Track it on the WS feed by cloid, and never fabricate an oid.

The rest of the everyday path

Many perp orders in one action

batch_order carries up to 1000 perp orders under one signature and one nonce:

{
"type": "batch_order",
"params": {
"owner": "0x<account the signer acts for>",
"orders": [
{ "owner": "0x…", "market": 0, "side": "bid", "kind": "limit",
"size": 10000, "limit_px": 4990000000000, "tif": "gtc",
"stp_mode": "cancel_oldest", "reduce_only": false },
{ "owner": "0x…", "market": 0, "side": "ask", "kind": "limit",
"size": 10000, "limit_px": 5010000000000, "tif": "gtc",
"stp_mode": "cancel_oldest", "reduce_only": false }
],
"grouping": "na"
}
}

The response carries one status entry per placed leg, in input order, each echoing its own cloid. A batch gives you the same per-order feedback a single order gives:

{ "statuses": [
{ "resting": { "oid": "12345" } },
{ "error": "reduce-only would grow position" }
] }

Three rules apply:

  • Legs are independent. Each leg runs the full order gate on its own. One rejected leg does not roll back the others.
  • The batch is block-atomic. Every leg sees the same begin-block state.
  • Only the batch-level owner routes. The per-leg owner is required by the schema but the node ignores it — it is not in the signed digest either. Set the account you act for at params.owner. Omit params.owner and the batch trades for the signer.

grouping links legs into an entry-plus-protection family. Its values are "na", "normalTpsl", and "positionTpsl" — the one camelCase corner of an otherwise snake_case wire. See order types.

A spot order

Spot is a token-for-token book with its own id space. It uses spot_order, which takes a pair id, not a market id:

{
"type": "spot_order",
"order": {
"pair": 110,
"side": "bid",
"size": 10000,
"limit_px": 5000000000000,
"tif": "gtc",
"stp_mode": "cancel_oldest",
"cloid": "0x0000000000000000000000000000ab02"
}
}

Read the pair id from markets_meta with kind: "spot" (spot.pairs[*].signing_id), and the base token's sz_decimals from spot.tokens[*].sz_decimals. The price plane stays 1e8.

warning

A perp batch cannot carry spot legs, and spot has no batch action. To place five spot orders you send five separate actions, each with its own signature and nonce. They are not atomic — some can rest while others fail. Plan for partial success.

By default the signer is the trader. A spot order also accepts an optional owner, so an approved agent can trade for the account it acts for.

Cancel

GoalActionAddress the order by
Cancel one perp ordercancel_ordermarket + oid
Cancel one spot orderspot_cancelpair + oid
{ "type": "cancel_order", "cancel": { "owner": "0x…", "market": 0, "oid": 12345 } }

List what is open with open_orders. Its rows carry oid and a symbol coin, so map the symbol back to the numeric id from step 1 before you cancel. A cancel of an order that already filled or already cancelled is refused with ORDER_NOT_FOUND and is harmless.

A cancel is not an order action: it returns the admission payload ({"data":{"accepted":true, …}}), not a statuses array. accepted: true reports MEMPOOL admission only — a cancel that fails at commit is reported on no channel, so confirm it by the order's absence from open_orders. See accepted is not committed.

One mental model, not twenty-two

Every order shape shares the same seven fields. Learn them once.

Shared fieldPresent onNote
market idevery shapemarket on perp, pair on spot — different id spaces
sideevery shape"bid" / "ask" on order bodies
sizeevery shape10^sz_decimals plane
limit_pxevery shape1e8 plane
tifevery shape"gtc" / "ioc" / "alo"
stp_modeevery shapeself-trade prevention
cloidevery shapeoptional, your retry key

Everything else belongs to one shape only:

FieldOnly onWhy
kind, triggerperp orderSpot has no trigger registry
reduce_onlyperp orderSpot has no positions
position_sideperp orderHedge mode leg selection
builderperp orderBroker fee. A spot order carries no such field, so it can charge no broker fee
groupingbatch_orderLinks legs into a TP/SL family
owner (required)submit_order, cancel_orderThe routing claim
owner (optional)batch_order, spot_order, spot_cancel, and most of tiers 2–3 (modify, cancel_by_cloid, cancel_all_orders, scale_order, chase_order, twap_order, and more)Absent = the signer acts for itself. See each action's page for whether owner is digest-bound

Two number planes

This is where first integrations break. The write side and the read side do not speak the same units.

DirectionFieldUnit
Write (/exchange)limit_px, px_low, px_high, trigger_pxinteger, price × 1e8
Write (/exchange)size, total_sizeinteger, quantity × 10^sz_decimals
Read (/info, WS)px, sz, mark_px, tick_size, step_sizewhole-unit decimal string

Two habits keep you safe:

  1. Never parse a money field to a float. Read it as a string and use a decimal type. A float loses precision above 2^53 and rounds prices you must reproduce exactly.
  2. Convert at the edge only. Hold whole-unit decimals in your strategy. Scale to integers in the one function that builds the action, and scale back in the one function that parses a read.

The read side also renames things. A resting order comes back as {"oid":…, "coin":"BTC", "side":"B", "px":"50000", "sz":"0.1", "cloid":…}"B" / "A" on the read side, "bid" / "ask" on the write side.

Sign with an agent key

Real integrations do not sign orders with the master key. The master key approves an agent key once; the agent key signs every order after that.

  1. The master signs approve_agent for the agent address.
  2. Wait one block.
  3. The agent key signs each order. The body still names the master at owner (or at params.owner for a batch).

An agent may place, modify, and cancel orders. It may not withdraw funds, create sub-accounts, or approve another agent. Full walkthrough: agent wallets in practice.

Which action do I need?

Read tier 1. Skip the rest until you need it.

Tier 1 — the everyday core

ActionReach for it when
submit_orderYou place one perp order
batch_orderYou place two or more perp orders together
spot_orderYou place one spot order
cancel_orderYou cancel one perp order and you know its oid
spot_cancelYou cancel one spot order

Tier 2 — order lifecycle

Reach for these once you keep orders resting.

ActionReach for it when
batch_cancelYou cancel many perp orders under one signature
cancel_by_cloidYou must cancel before the oid reaches you
cancel_all_ordersYou flatten the whole book, or one market
modifyYou re-price or re-size a resting order in place
batch_modifyYou re-price a whole quote ladder at once
schedule_cancelYou want a dead-man's switch that cancels all at a future block

Tier 3 — the node runs the order for you

One signature buys a behaviour that would otherwise cost a client loop.

ActionReach for it whenAvailability
twap_order · twap_cancelYou spread one large order over timelive — one-way accounts only, see below
scale_order · cancel_scaleYou want N rungs across a price band from one signaturelive
chase_order · cancel_chaseYou want one post-only leg the node re-prices to the touchlive

Grid snapping on a synthesized fire

The chain rounds a synthesized fire price onto the tick grid, always toward the mark, so the fire cannot leave its slippage band. It floors a TWAP slice size onto the lot grid. A parent whose whole remainder is smaller than one lot retires instead of running out its schedule. A schedule that ends with no fill reports Terminated, not Finished.

Historical parents — before block 13,350,001

A parent that ended below that height reports Finished even when no slice ever filled. Read Finished on a historical parent as "the schedule ended", never as "it executed". The size refused below the height is permanent: nothing is credited back and no order is replayed.

danger

A hedge-mode account must send position_side on twap_order; a one-way account must omit it. The slices inherit the leg the parent names, so a hedge parent that names none is refused, and a one-way parent that names one is refused too. The refusal happens at commit, and a commit-time refusal of a non-order action is reported on no channel: the HTTP reply already said accepted: true, and the TWAP never starts. Read position_mode from account_state once at session start and set the field from it.

The same commit-time silence applies to every non-order action. See accepted is not committed before you build a retry loop on accepted: true.

Tier 4 — specialist venues

Skip these on a first integration. They are separate venues, not variations on a limit order.

ActionReach for it whenAvailability
rfq_request · rfq_quote · rfq_acceptYou negotiate a block trade off the booklive
fba_submitYou want a uniform batch clearing price instead of the bookmarket must set fba_enabled
submit_encrypted_orderYou hide an order until a target blocktestnet preview

Not order actions

These sit next to the order actions in the catalog and are easy to mistake for them:

ActionWhat it really is
update_leverageMargin setting for a market
vault_modifyVault configuration for a vault leader
noopA deliberate no-op that burns a nonce

Common first-run errors

SymptomCauseFix
401 signer is neither the owner nor an approved agentWrong chainId, or the agent approval has not committedMatch chainId to the node; wait one block after approve_agent
Order rejected off-gridlimit_px not on tick_size, or size not on step_sizeSnap client-side before you sign
The price looks 1e8 times too smallA whole-unit price sent as limit_pxMultiply by 1e8 before you sign
400 duplicate cloidThe same cloid was already admitted for this accountThe first order is live. Look it up by cloid
{"pending":…} on every orderThe wait window elapsed before commitTrack by cloid on the WS feed; do not resubmit blindly

See also