POST /info — read & query endpoint
Status. stable shape. Query types are added over time; the envelope is committed.
One name for the account: address. Every account-scoped query takes it.
Four queries shipped with user instead — broker_state, referral_state,
spot_margin_state and user_interest. Those still accept user, and their
replies carry the account under both names. Send address in new code.
TL;DR
Single endpoint, multi-type. Dispatches on the request body's type field. Read-only — never mutates state, never requires a signature.
Split by product. Perp-market read queries are on perpetual queries; spot, spot-margin, and Earn read queries are on spot & margin queries; closed-position lifecycle queries are on position history; governance queries are on governance queries. This page covers the envelope, conventions, and account/vault/validator reads.
URL
POST https://api.<net>.mtf.exchange/info
| Path | Wire shape |
|---|---|
POST /info (gateway) | MTF-native (this document) |
The gateway serves the MTF-native /info. Running the node yourself, the same
native /info is served directly at http://localhost:8080.
Envelope
Every /info response is one envelope. A success carries data. A failure
carries error. The two keys never appear together.
Request
{ "type": "<query_type>", /* type-specific args */ }
Success — 200 OK. The type discriminator is echoed INSIDE data:
{
"data": {
"type": "<query_type>",
/* type-specific payload */
}
}
The payload fields keep their old path. A field you read at body.data.fills
before is still at body.data.fills. Only type moved: it was a sibling of
data, and it is now the first key of data.
Every read carries type inside data, the history-archive reads included.
The archive lane differs from the envelope above in one place only: the shape of
a rejection. See the archive lane below.
A success has no error key. Do not test error === null — test whether
the key is present.
A data of null is a SUCCESS. A read can succeed with no content. That
answers {"data": null} with status 200. Treat it as an empty result, not as
a failure.
Failure — no data key, and the HTTP status that the code maps to:
{
"error": {
"code": "UNKNOWN_TYPE",
"message": "unknown info type: markest"
}
}
| Field | Presence | Meaning |
|---|---|---|
code | always | The stable contract. Match on this. |
message | always | Prose for a human. It can change in any release. Never match on it. |
details | optional | The bound the request broke: {"field","limit","actual"}. It is omitted when the rejection carries no bound — never sent as {} |
The HTTP status keeps its normal meaning. One code always answers with one status. Every code, its status, and the caller action for it are in the error reference.
Two common /info failures: an unknown type answers 400 with
UNKNOWN_TYPE; an unknown named resource, such as a vault id, answers 404
with NOT_FOUND.
The history-archive reads reject with a bare string
A group of reads is served by the history archive rather than by the node. A success answers in the envelope above. A rejection does not.
The lane is portfolio, historical_orders, user_funding,
user_funding_by_time, user_position_history,
user_position_history_by_time, user_non_funding_ledger_updates,
recent_blocks,
recent_transactions, validator_votes and
user_volume_history.
portfolio takes an interval alongside address, and it accepts exactly one
value: 1d. Any other value is rejected 400 invalid interval: <value>,
and the rejection now names 1d.
A rejection puts a bare STRING in error, not the {code, message}
object. This applies to the reads in the lane that take an address. Two
strings occur, both with status 400:
| String | Cause |
|---|---|
missing field: address | The request carries no address |
invalid user address: <value> | address is present but does not parse |
A handler that branches on error.code reads undefined on this lane.
Test the type of error before you read code.
The collection key, per read. The rows sit under a key named for what they
are, not under a generic data[]. Every key is snake_case, like the rest of
this wire:
| Read | Collection key |
|---|---|
historical_orders | orders |
user_funding, user_funding_by_time | fundings |
user_position_history, ..._by_time | positions |
user_non_funding_ledger_updates | ledger_updates |
recent_transactions | txns |
portfolio | points |
user_volume_history | days |
⚠️
ledger_updateswasledgerUpdates. It was the one camelCase key on this wire. A client readingdata.ledgerUpdatesnow getsundefined— readdata.ledger_updates.
⚠️
user_ledgeranduser_ledger_by_timeare REMOVED. Both were narrower views of the same source thatuser_non_funding_ledger_updatesalready serves, and its name states what it holds where theirs did not.
What user_non_funding_ledger_updates means. Every NON-TRADING movement of
an account's money, and nothing else. Funding is excluded because it has its own
read, user_funding; a fill's realized PnL is excluded because
it is trading, and it already has user_fills.
Every row carries a kind and a signed delta. delta is signed from the
side the holder could spend a moment earlier: money that leaves that side is
negative, money that arrives is positive. coin names the token. These are the
kinds:
kind | What produced it | Extra fields |
|---|---|---|
deposit | A MetaBridge inbound credit, a system spot credit, or an EVM→Core credit | chain on a bridge credit |
withdraw | A withdrawal to an EVM chain | |
transfer | An account, sub-account or spot transfer, and a VAULT deposit or withdrawal | counterparty when the move has one |
liquidation | A forced-close settlement, or a delist settlement | market, mark_px |
staking_deposit | MTF moves from spot into the staking free pool | |
staking_withdraw | MTF returns from the free pool to spot | |
delegate | MTF moves from the free pool to a validator | |
undelegate | MTF leaves a validator for the unbonding window | |
staking_reward | A CLAIMED staking reward, credited to the free pool | |
earn_deposit / earn_withdraw | An Earn deposit or withdrawal |
A vault movement is a transfer, not a kind of its own. It carries no
counterparty, because the other side is the vault.
market on a liquidation row is the perp market ID as a NUMBER, not a
symbol. It is the one market reference on this read that is not resolved for
you. Map it with markets_meta.
A forced close and a delist settlement
write the same liquidation row, and this read carries no cause to tell them
apart. Read the cause from ledger_updates.
NOT LIVE YET: a live node settles no position at a delist.
delegate and undelegate do not change what the account holds in total. They
move MTF between what it can withdraw and what it cannot, and that is a
movement this read must show.
⚠️ Handle the full table. A
kindyour client does not know must never throw.
Membership of this lane is a deployment fact, not a wire guarantee. Do not hard-code the list; write one handler that accepts both rejection shapes.
A malformed request body answers with no error key at all
The two shapes above both reject a request the server could read. A request the
server cannot even parse is refused earlier, and that answer carries no
error key. Two forms occur, on every read alike:
| Body | Status | Cause |
|---|---|---|
A bare JSON string, such as "Failed to deserialize the JSON body into the target type: ..." | 422 | Valid JSON, but a field holds the wrong type — for example limit as a string |
Plain text, such as Failed to parse the request body as JSON: ... | 400 | The body is not valid JSON |
Both messages are prose for a human and can change in any release. Never match
on them. Parse the body only after you check the status, and treat any 4xx
whose body has no error object as a bug in your own request.
Not every wrong type is refused. A field the read treats as optional, such as
detail on account_state, falls back to its default rather
than failing. Only a field with a declared numeric or typed binding rejects.
Empty is not the same as absent
A read that answers nothing and a request that asks the wrong question look alike inside a client. The first is a fact about the account. The second is a bug in your code. Three cases produce a plausible-looking zero — rule each one out before you report a holding as missing.
1. You sent a retired type name. A name this API no longer serves NEVER
answers with an empty body. It answers UNKNOWN_TYPE, in one of two forms:
| What you sent | Status | Body |
|---|---|---|
spot_clearinghouse_state, oracle_sources, sub_accounts, and every other name in removed reads that is not in the row below | 400 | {"error":{"code":"UNKNOWN_TYPE","message":"unknown info type: <name>"}} — no details |
account_overview, action_outcome, bridge_chain_configs, bridge_user_outbox, encode_action, evm_contract_bindings, gov_history, gov_proposals, gov_state, pm_summary | 410 | the same code, plus details.use naming the read to call instead |
spot_meta, all_mids, active_asset_ctx, user_events | 400 today, 410 at the next node release | Not live yet. These four answer a bare 400 on the running chain even though this reference names a replacement for each. They move to 410 with details.use at the next release — see next release |
So a 200 carrying an empty array IS an answer about a real account. A client
that shows "no balances" after calling spot_clearinghouse_state swallowed a
4xx. Check the status before you read the body.
2. You read a key that is not there. An absent key reads as undefined, and
undefined renders as empty almost everywhere. The spot ledger is the one that
catches people. It lives at data.spot.balances. There is no balances and
no spot_balances at the top level of an
account_state body; both paths read as "this account holds
nothing", and both are wrong. Every field that moved is listed in
where every field went.
3. The read's source is not deployed on the endpoint you called. The
archive-lane reads answer with a typed empty body —
{"orders":[]}, {"fundings":[]} — when no history archive is configured
behind that endpoint. On the wire that is identical to an account with no
history. The public endpoints run the archive, so this is a self-hosted concern.
The one-line control. Send the same request with a type you KNOW is wrong,
such as "type":"nope". If your client reports the same empty result it
reported before, it is hiding the error, not reading an empty account.
Query types
Every type the read endpoint accepts, grouped by what it answers. Click a
type for its request fields and response schema.
Removed reads
Each name here answers UNKNOWN_TYPE. The read surface is cut so that each
question has exactly one read: two reads for one question force a choice, and
a wrong choice is silent. For the release a removal landed in, see
migration.
The status splits the two kinds of removal:
400— the name never named a read on this API, or its answer is gone.410— the name was public and its answer MOVED. Ten names get this today:account_overview,action_outcome,bridge_chain_configs,bridge_user_outbox,encode_action,evm_contract_bindings,gov_history,gov_proposals,gov_stateandpm_summary. The error carriesdetails.use, naming the read to call instead, so a client can follow the move without reading this table. Four more join them at the next node release —spot_meta,all_mids,active_asset_ctxanduser_eventsanswer a bare400until then, which is wrong for a name this reference gives a replacement for. Branch on the status AND onerror.code, never on the status alone.
details.use does not always name another /info type. action_outcome
and encode_action both answer "use": "/exchange", which is an ENDPOINT.
Read the value as prose for a human, not as a type you can post back.
| Removed | Call this instead |
|---|---|
abstraction_state | Nothing. Its kind / value pair was per-kind free-form, so a value had no wire-defined meaning |
account_overview, web_data | account_state with detail: "overview" — the same body |
action_outcome | POST /exchange — the submit call already waits for the commit and returns the verdict. See the section above |
agents | account_state with detail: "overview" — agents |
block_info | account_state for the committed height / time stamp; the archive-backed recent_blocks read for the block head. (The explorer_block WS channel that used to answer this is removed — a validator must not serve a per-block firehose) |
bridge_chain_configs | Nothing. No public read carries the deployment row. A node publishes it on its node_bridge_outbox stream, and the custody address per chain is in the Deployments table |
bridge_finalized_cosignatures, bridge_outbound_queue | bridge_withdrawal_history for one account's own withdrawals. The whole-chain queue and the raw validator cosignature bytes are not part of this API |
delegator_history | Nothing. No delegation event log is committed |
delegator_summary | account_state with detail: "overview" — staking.summary |
dynamic_risk | markets_meta — risk_override |
encode_action | Nothing. The multisig inner blob takes the ordinary {type, params} wire action — see signing the inner action |
evm_contract_bindings | markets_meta with kind: "spot" — evm_contract |
gov_state, gov_proposals, gov_history | validator_votes — status: "voting" for open votes, status: "enacted" for history. A parameter VALUE is on the read that owns it: markets_meta, fee_schedule, exchange_status |
leading_vaults | vault_summaries — filter the rows on leader |
margin_summary | account_state with detail: "margin" |
market_info | markets with coin, plus markets_meta with coin |
max_builder_fee | approved_brokers — look the builder up in the list |
max_market_order_ntls, perps_at_open_interest_cap | markets_meta — max_market_order_ntl is the served headroom, one row per market. null = uncapped, "0" = at the cap. Do not rebuild it from open_interest and oi_cap: an uncapped row OMITS oi_cap, so that arithmetic reads uncapped as zero headroom |
node_info | Nothing on this API. Per-node identity is not consensus state, so two honest nodes answer differently. The chain id is fixed per network — see networks |
oracle_sources | Nothing. The per-market bitmask it served is not read by the price aggregator. The static source facts are prose — see oracle prices |
perp_dex_limits | perp_dexs — limits |
pm_summary | account_state — perp.pm_maint_margin, perp.pm_concentration_penalty and the top-level pm_net_value, with abstraction: "portfolio" as the enrolment flag |
predicted_fundings | markets — each row's funding block carries the charged rate and the next boundary |
protocol_metrics | markets, markets_meta and staking_state carry every public fact it held. The rest was node diagnostics |
recent_trades, trades_by_time | trades — un-ranged for the recent window, ranged for a time window |
spot_clearinghouse_state | account_state — spot.balances is the whole token ledger |
spot_deploy_state | spot_deploy_auction — the same read, renamed |
staking_apr | staking_state — pending_validator_pool_usdc and total_stake. It never served an APR |
sub_accounts | account_state with detail: "overview" — sub_accounts |
token_info | markets_meta with kind: "spot" |
user_fees | fee_schedule with address — it resolves the effective maker / taker bps |
user_fills_by_time | user_fills with start_time / end_time |
user_role | account_state with detail: "overview" — role |
user_to_multi_sig_signers | account_state with detail: "overview" — multisig |
user_vault_equities | account_state with detail: "overview" — vault.equities |
web_data2 | account_state for margin and balances, clearinghouse_state for positions, detail: "overview" for vault equities; open_orders for resting orders; exchange_status for status. The WS channel is removed too, and answers {"channel":"error","data":{"error":"unknown channel: web_data2"}} |
Reads gated by their capability
These two reads exist on the wire already. Each answers with the same error an unknown type gets, not because it is restricted to an operator, but because the capability it reads is not reachable yet. Each ships publicly the day that capability does.
| Read | Ships when |
|---|---|
mip3_deployer_oracle | The mip3_deployer_oracle protocol feature is armed on the target chain |
fba_batch_state | The FBA engine becomes reachable from /exchange |
Errors
Read the full list, with the caller action for each code, in the
error reference. These are the codes /info produces:
| HTTP | error.code | Cause |
|---|---|---|
| 200 | — | Success. An unknown address on account_state and its siblings is a 200 with a zeroed record, NOT a 404 |
| 400 | INVALID_REQUEST | No type discriminator, a required type-specific argument omitted, or a malformed address |
| 400 | UNKNOWN_TYPE | The type names no read. It is misspelled, or the read is removed |
| 410 | UNKNOWN_TYPE | The type names a read whose answer MOVED. details.use names the read to call instead — see removed reads |
| 404 | MARKET_NOT_FOUND | The coin symbol is unknown (markets, l2_book and other market reads) |
| 404 | NOT_FOUND | A named resource is unknown, such as a vault address on vault_state |
| 429 | RATE_LIMITED | No retry hint is sent — see rate limits |
| 500 | INTERNAL | Our defect, not your request. Retry, then report it |
A 405 carries no envelope: the endpoint is POST-only, and the router refuses
another method before the envelope exists.
There is no account not found error: account-keyed readers (account_state,
open_orders, user_rate_limit, staking_state, …) return a 200 zeroed
record for an address that has never appeared on-chain — they never 404.
Read-after-write consistency
/info reads from the most recent committed block. A POST /exchange admitted at time T is not visible in /info until the leader commits the block containing it — one committed block later. Block cadence is a governed, per-deployment target, not a fixed duration; measure your own deployment's committed-round rate if you need a wall-clock estimate.
For read-your-writes semantics, subscribe to order_updates (order lifecycle) and fills (executions); committed events arrive in commit order, removing the need to poll.
Sequence — query an account, see your own order
See also
POST /exchange— write pathPOST /faucet— testnet test-fund grant (USDC + MTF)- WS subscriptions — push equivalents
FAQ
Show FAQ
Q: How do I address a market — by id or by name?
A: By coin symbol ("BTC"). The legacy numeric asset_id / market_id request
arguments were removed; only coin is accepted, and responses render coin symbols
everywhere. (The signed /exchange write path still uses the numeric asset —
that field is consensus-frozen and unrelated to these read args.)
Q: Do user_fills / trades need an external indexer?
A: No. Both read a committed on-node tape (a bounded per-account fill ring and per-market trade ring folded into the AppHash), so any node serves real records directly — no external indexer required. The rings are bounded, so they hold a recent window; for an unbroken live feed subscribe to the WS channels. History PAST the ring is a different question: the archive holds it, and a RANGED ask (one that carries start_time) reaches it. An un-ranged ask always answers from the ring — see Deep history, past the ring.
Q: Is the response deterministic across nodes?
A: Yes. Any honest node returns identical responses for the same query at the same committed height. Nodes with different commit heights may differ, so compare the height / time stamp account_state carries before you call two answers inconsistent. gossip_root_ips is the one field that is NOT consensus state: it reads each node's own config, so nodes that carry the same roster answer identically, and nodes that do not may differ.