Trading and history
Orders, closes, pending orders, multi-account orders, and the history they leave.
Placing orders, and the durable record they leave.
If you read one section, read The three failures. It is the difference between a client that retries safely and one that occasionally opens two positions where you asked for one.
Placing a market order
curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/orders/market" \
-H "Authorization: Bearer $FXAPIS_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"symbol": "EURUSD",
"side": "buy",
"volume": "0.10",
"stopLoss": "1.0890",
"takeProfit": "1.1010",
"deviationPoints": 20,
"clientOrderId": "strategy-a-0417"
}'The account must be online (ready). An account in warm_on_demand mode comes
online by itself for the order; see Accounts.
Symbol or instrument
symbol is the broker's own name for the market, exactly as that broker spells it (XAUUSD.a, US30.cash). To trade the same market across accounts at different brokers, send instrument instead — XAUUSD, US30, BTCUSD — and each order trades that account's own symbol for it. See Instruments and symbols.
Volumes and prices are strings
"0.10", not 0.1. This is enforced — a JSON number is rejected with
INVALID_REQUEST, not quietly accepted.
A JSON number is an IEEE double. 0.1 + 0.2 is 0.30000000000000004, and a
volume that has been through a double has already lost precision we cannot
recover. Accepting it would mean silently trading a size you did not ask for,
so the API refuses it while you can still fix it.
Use a decimal type on your side, and serialise it as a string.
Stops are absolute prices
stopLoss: "1.0890" is a price, not a distance in pips. We check the
mistake the broker cannot see — stops on the wrong side for the direction you
meant — before anything is sent, and refuse it with 400 INVALID_REQUEST:
- A buy's stop loss must sit below its take profit (a sell's, above).
- A pending order's stops are checked against its own price — or, for a stop-limit, its limit price.
- A market order's fill price is not known yet, so its stops are checked against the market — the ask for a buy, the bid for a sell — when fxapis has a recent price for the symbol on that account, and otherwise left to the broker. A recent price is a live quote already read for that account, with a tick under 10 seconds old, or else the price of an open position on the symbol. The order never waits for a price to be fetched.
Distance and step rules are the broker's, enforced by its order_check before
anything is sent; a stop it refuses comes back as ORDER_REJECTED with the
broker's reason.
Always send an Idempotency-Key
Idempotency-Key: 8f14e45f-ea8d-4b1e-9c2a-7b3d5e9f0a11Without one, a request that times out leaves you stuck. You cannot tell whether the order was placed; retrying risks a second position, and not retrying risks none at all. With one, retrying is free.
| Situation | What you get |
|---|---|
| Same key, same body, first call finished | The same response, replayed, with idempotent-replay: true |
| Same key, same body, first call still running | 409 IDEMPOTENCY_IN_FLIGHT — retry shortly |
| Same key, different body | 409 IDEMPOTENCY_KEY_REUSED |
Same key, after a SEND_FAILED | A genuine new attempt — nothing reached the broker, so the key is released |
| Same key, after a rejection | The rejection is replayed. To try again, use a new key |
That last row is the subtle one, and it is deliberate. A key is only released when we know the broker never saw the order. If the outcome is unresolved, the key stays claimed and a retry replays the unresolved answer rather than placing a second order.
Keys are honoured for 24 hours and scoped to your workspace; after that a key is released and can be used again. Use a fresh one per order — a UUID is ideal.
The three failures
An order can fail in three ways, and the response tells you which.
SEND_FAILED (503) — it never reached the broker
{"error":{"code":"SEND_FAILED","message":"… the order was never sent. It is safe to retry.",
"details":[{"orderId":"...","state":"failed","retryable":true}]}}We know for certain that nothing was sent to the broker. The order is
failed.
Retry it. That is the only order state where resending is safe.
ORDER_REJECTED (422) — the broker said no
{"error":{"code":"ORDER_REJECTED","message":"The broker rejected the order: No money",
"details":[{"orderId":"...","state":"rejected","retryable":false}]}}The broker refused it, with a reason, and nothing executed. The order carries
the broker's own retcode — fetch it with GET /v1/orders/{id} and you will
see 10019 here, unmodified.
retryable tells you whether the reason was transient:
retryable: true— a requote (10004), a moved price (10020), no quotes right now (10021). The market moved; sending a new order — with a newIdempotency-Key— is correct. Look at the new price first.retryable: false— insufficient margin (10019), market closed (10018), trading disabled (10017), an invalid volume or stop. Retrying collects the same rejection.
ORDER_UNRESOLVED (503) — we do not know
{"error":{"code":"ORDER_UNRESOLVED","message":"… we do not know whether the order reached the broker. Do not resend it.",
"details":[{"orderId":"...","state":"unknown","retryable":false}]}}Do not resend this order. It may be live at the broker right now.
This happens when a send times out, the connection drops mid-request, or the
broker returns a code that does not say what happened — 10012 TIMEOUT cancels
our wait, not necessarily the order.
Poll GET /v1/orders/{id}. For an order that opens a position we confirm the
result with the broker automatically and move it out of unknown to what
actually happened; needsReconciliation is true until then. A close, stop
change or cancel that ends unknown is not settled automatically — its
stateDetail tells you to check the position or order at the broker. See
Confirming results below.
We are deliberately cautious here. An outcome we cannot confirm is unknown,
never "probably failed" — including for a return code we have never seen
before. Being wrong in this direction costs a short wait; being wrong in the
other direction costs a position you never asked for.
Confirming results
An opening order in unknown is checked against the broker's deal history
automatically, every minute. A close, stop change or cancel produces no
opening deal to match, so it stays unknown with a stateDetail asking you
to check at the broker — reading an absence as "it failed" could be wrong. POST /v1/accounts/{id}/reconcile does it immediately for one
account.
curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/reconcile" -H "Authorization: Bearer $FXAPIS_KEY"{"data":{"examined":1,"resolved":1,"stillUnknown":0,"ambiguous":0,
"dealsIngested":1,"positionsOpen":1,"positionsClosed":0,"skipped":null}}Safe to call at any time, and safe to call twice.
What it resolves to
| At the broker | Order becomes |
|---|---|
| Trades covering the full volume | filled |
| Trades covering part of it | partially_filled |
| No matching trade, and enough time has passed | rejected |
| No matching trade yet | stays unknown for now |
| More than one possible match | stays unknown, counted as ambiguous |
An unknown order never becomes failed: that state means the order provably
never left fxapis, and after the fact only the broker's record can say what
happened.
When it cannot be sure, it does not guess. If two trades at the broker
could both be your order, the order stays unknown and is counted in
ambiguous — attaching a fill to the wrong order would produce a history that
looks right and is not. Those orders need a person:
curl -sS "$FXAPIS_API/v1/orders?state=unknown" -H "Authorization: Bearer $FXAPIS_KEY"The order's stateDetail says how many candidates there were.
Your order comments
fxapis adds a short tag to the start of each order's MT5 comment, which is how
the broker's record is matched to your order. The comment holds 29 characters;
your own comment gets the 17 left after the tag, in plain ASCII. A longer
comment is cut to fit, never refused.
It keeps your history complete
Each check also records the broker's deals for the period whether or not anything needed confirming — including deals no order of yours caused — and refreshes open positions. A position the broker no longer reports is marked closed, not deleted.
Closing a position
curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/positions/2104151615/close" \
-H "Authorization: Bearer $FXAPIS_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" -d '{"volume": "0.05"}'Omit volume to close the whole thing.
The ticket to close is the position's brokerPositionId. For a position your
own order opened, that order carries it too, as order.brokerPositionId,
set from the fill itself when the order opens. A pending order that fills
later still gets it linked from the broker's positions and deals, usually
within a few minutes — and moves from working to filled at the same time
(see Order states). While it is still null, find the position in
GET /v1/accounts/{id}/positions: a new position carries the ticket of the
order that opened it, which is that order's brokerOrderId.
An idempotency key matters more here than on an opening order. A retried close does not close twice — on a hedging account it opens an opposing position, and the account ends up with exposure nobody chose.
A close is an order. It gets its own record with intent: "close", the same
three failure modes, and the same rule: ORDER_UNRESOLVED means do not
resend. An unresolved close is the case where you might believe you are flat
and not be.
Two things are refused before anything is sent:
- Closing more than the position holds. The message names both numbers.
- A partial close that would leave less than the symbol's minimum volume
behind (
volumeMinin its symbol list) — the broker refuses that after the close, leaving a position too small to trade and too small to close. Close it in full, or leave at least the minimum.
Moving a stop
curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/positions/2104151615/modify" \
-H "Authorization: Bearer $FXAPIS_KEY" -H "Content-Type: application/json" \
-d '{"stopLoss": "1.0890", "takeProfit": null}'null removes a level. Omitting the field leaves it unchanged. An omitted
field silently clearing a stop is how protection disappears without anybody
choosing it.
Recorded with intent: "modify", because widening a stop changes what the
account risks and belongs beside the trade it protects — a stop that moved
before a loss is exactly what an audit asks about.
Stops are checked against the current market price, not the entry: a buy's stop loss must sit below the market and its take profit above it (a sell's, the reverse). So moving a stop to break-even, or trailing it into profit, is allowed. A stop the market has already passed is refused before anything is sent, because it would close the position at once. Stops on a new order are checked against its entry instead — see Stops are absolute prices.
Pending orders
curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/orders/pending" \
-H "Authorization: Bearer $FXAPIS_KEY" -H "Content-Type: application/json" \
-d '{"symbol":"EURUSD","side":"buy","kind":"limit","volume":"0.10","price":"1.0900"}'| Kind | Buy waits | Sell waits |
|---|---|---|
limit | below the market | above it |
stop | above the market | below it |
stop_limit | stop triggers, then places a limit | same |
A limit waits for a better price; a stop waits for a worse one and is how a
breakout is traded. When fxapis has a recent price for the symbol on that
account (see Stops are absolute prices), the wrong side is refused before
anything is sent, with a message naming the order you probably meant. Without
one, the broker answers with its own INVALID_PRICE, which says nothing about
which way round it should have been.
Pending orders are part of paid plans; on a plan without them the request is
refused with 402 FEATURE_NOT_IN_PLAN.
To move a working pending order — its price, stops, stop-limit price or
expiry — use POST /v1/orders/{id}/modify. It keeps the order's ticket, so it
keeps its place in the broker's queue; an omitted field is left alone, and
"stopLoss": null clears a stop. Only a working order can be moved.
A placed pending order is working, not filled. That distinction is real
money: filled means a position exists. Some brokers answer a pending
placement with DONE, the same code a fill returns, and reading that as a
fill would tell you you are in the market when you are waiting to be.
Cancel one with POST /v1/orders/{id}/cancel. If it already triggered the
broker refuses with 10035 — at that point there is a position, not an order,
and what you want is a close. A successful cancel moves the pending order to
cancelled and records the instruction itself as completed.
Margin and profit, before you trade
curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/calculate" \
-H "Authorization: Bearer $FXAPIS_KEY" -H "Content-Type: application/json" \
-d '{"kind":"margin","symbol":"EURUSD","side":"buy","volume":"0.10","price":"1.1370"}'
# → { "data": { "kind": "margin", "value": "22.74000000" } }Opens nothing and is safe to call freely. The account must be online — an
offline one is refused with 409 NO_RUNTIME. This is how you find out an order
will be refused for margin before sending it, rather than collecting a
10019. "kind": "profit" needs a closePrice.
A null value means the broker would not answer, which is not the same as
zero.
Live prices
curl -sS "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/quotes?symbols=EURUSD&instruments=XAUUSD" \
-H "Authorization: Bearer $FXAPIS_KEY"
# → { "data": [
# { "symbol": "EURUSD", "instrument": null, "status": "ok", "bid": "1.08450000", "ask": "1.08462000",
# "spread": 12, "digits": 5, "brokerTime": "2026-10-15 15:00:00.123", "time": "2026-10-15T12:00:00.123Z",
# "observedAt": "2026-10-15T12:00:00.300Z", "ageMs": 177, "stale": false, "marketOpen": true, "tradeMode": "full" },
# { "symbol": "XAUUSD.a", "instrument": "XAUUSD", "status": "ok", "bid": "2650.10000000", … } ] }The prices come from the account's own broker. Each broker quotes differently, and these are the prices this account trades at.
- What you can ask for: up to 20 broker
symbolsandinstrumentstogether, comma-separated, all read in one call. An instrument is quoted as this account's own symbol for it. The account must be online —ready,executingordegraded(409 ACCOUNT_NOT_READYotherwise). - Every quote with a tick carries
time(UTC),brokerTime(the broker's own clock, as its terminal shows it) andageMs, how old the tick was when the terminal was read atobservedAt. Apply your own freshness rule from those. - A closed market answers its last price with
marketOpen: falseandstale: false, never an error. staleis true only while the market is open and the last tick is over 60 seconds old.marketOpencomes from the broker's own trading sessions. It isnull(andstaleisfalse) until the terminal has reported them, a minute or so after an account first comes online.tradeModeis what the broker allows on the symbol right now:full,longOnly,shortOnly,closeOnlyordisabled.closeOnlyanddisabledallow no new positions.- A symbol this broker does not have answers
status: "unknown_symbol"in its place; one it has sent no price for yet,status: "no_price". - Polling: there is no stream. Answers are shared for a quarter of a
second, so a quote may have been read up to 250 ms before you asked — that
is how old the read is, not the price, which is what
ageMsandtimetell you. Each account's terminal may be asked 300 times a minute with a burst of 50, across every caller and every key; shared answers are free. Past that:429 RATE_LIMITEDwithretry-after. - A terminal still running an older worker — rare — answers
501 CAPABILITY_UNAVAILABLEinstead. Do not retry it; an open position'scurrentPriceis the price meanwhile.
An order's stops are checked against this price when one was read recently: the ask for a buy, the bid for a sell (Stops are absolute prices).
Order states
| State | Meaning | Resend? |
|---|---|---|
accepted | Taken, not yet sent | — |
sending | In flight | No |
working | A pending order waiting at the broker. No position exists. | No — it is already there |
completed | An instruction that is not a trade finished: a cancel, a stop moved | — |
filled | The broker confirmed the full volume | No |
partially_filled | Part filled; the rest may still fill | No |
rejected | The broker refused it. Nothing executed. | Only if retryable |
failed | Proven never to have reached the broker | Yes |
unknown | Outcome unestablished | Never |
cancelled | Withdrawn before execution | — |
expired | A pending order lapsed at the broker | — |
failed and unknown are never interchangeable. failed is a claim we make
only with proof.
A pending order that triggers moves from working to filled (or
partially_filled) once its fill shows up in the broker's deal history or as
a position carrying its ticket — usually within a few minutes, with one
order.filled webhook. filledPrice and filledVolume come from its fill
deal; when only the position is known, they are taken from it on a hedging
account (the volume only while it still equals the order's) and left null
on a netting account, whose position may hold other orders' fills, until the
deal arrives. A pending order the broker cancels or lets expire on its own is
not yet detected: it stays working.
History
Three tables, because the questions are different.
Orders — what you asked for
curl -sS "$FXAPIS_API/v1/orders?state=unknown&limit=50" -H "Authorization: Bearer $FXAPIS_KEY"Every order, with the broker's return code preserved exactly as given. Filter
by accountId, state, symbol, and a since/until window.
state=unknown is the query worth building an alert on: it is everything whose
outcome is not yet established.
Deals — what the broker actually did
curl -sS "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/deals?since=2026-09-01T00:00:00Z" \
-H "Authorization: Bearer $FXAPIS_KEY"A deal is the broker's record of money changing hands. This is what a statement reconciles against, and it is not one row per order:
- One order can produce several deals. A partial fill leaves more than one.
- A deal can exist with no order of yours behind it. A stop loss firing at the broker, a swap charge, a deposit, a correction, or somebody trading the same login from the MetaTrader desktop.
That second point is why this endpoint exists separately. A history containing
only your own orders would show an account whose balance moves for no visible
reason. orderId is null on exactly those rows, so you can tell them apart.
Ordered by the broker's own timestamp, not ours. dealType is named rather
than numeric — balance, commission, buy — because 2 means nothing on a
statement.
Deals for one order: GET /v1/orders/{id}/deals.
Positions — what is open now
curl -sS "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/positions" -H "Authorization: Bearer $FXAPIS_KEY"A snapshot with observedAt saying when it was true. The broker is
authoritative; this is what we last saw, and the timestamp is there so you can
tell a current view from a stale one rather than assuming.
Pagination
Cursor, not offset:
curl -sS "$FXAPIS_API/v1/orders?limit=100" -H "Authorization: Bearer $FXAPIS_KEY"
# → { "data": [...], "page": { "hasMore": true, "nextCursor": "2026-09-28T14:02:11.482Z" } }
curl -sS "$FXAPIS_API/v1/orders?limit=100&cursor=2026-09-28T14:02:11.482Z" -H "Authorization: Bearer $FXAPIS_KEY"An offset shifts as new orders arrive, so page two would silently skip rows — in a trading history, exactly the rows added while you were paginating.
Retention
Orders and deals are kept for the life of the account and are not pruned. Disconnecting an account erases its credentials; it does not erase its history, because that history is what a dispute or an audit is settled with.
A worked client
import uuid, time, httpx
class OrderError(Exception): ...
class Unresolved(OrderError):
"""Do not resend. The order may be live."""
def place(client, account_id, **order):
key = str(uuid.uuid4())
for attempt in range(3):
response = client.post(
f"/v1/accounts/{account_id}/orders/market",
json=order,
# The SAME key on every attempt. That is the whole point: the
# server decides whether this is a retry or a new order, and it is
# the only party that can know.
headers={"Idempotency-Key": key},
)
if response.status_code == 201:
return response.json()["data"]
error = response.json()["error"]
code = error["code"]
if code == "SEND_FAILED":
# Proven not to have reached the broker.
time.sleep(2 ** attempt)
continue
if code == "IDEMPOTENCY_IN_FLIGHT":
time.sleep(1)
continue
if code == "ORDER_UNRESOLVED":
# Never retried. Hand the order id to whatever follows up on
# unresolved orders, and stop.
raise Unresolved(error["details"][0]["orderId"])
if code == "ORDER_REJECTED":
if error["details"][0].get("retryable"):
# The market moved: a new decision needs a NEW key. Resending
# with this one would only replay the rejection.
raise OrderError(f"retryable rejection: {error['message']}")
raise OrderError(error["message"])
raise OrderError(f"{code}: {error['message']}")
raise OrderError("gave up after 3 attempts")Multi-account orders
One trade, placed on many accounts at the same moment. The API calls them execution waves.
curl -sS -X POST "$FXAPIS_API/v1/execution-waves" \
-H "Authorization: Bearer $FXAPIS_KEY" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" -d '{
"accountIds": ["…", "…", "…"],
"symbol": "EURUSD", "side": "buy", "volume": "0.01",
"weights": {"<account-id>": "0.05"},
"barrierPolicy": "release-ready",
"executeAt": "2026-10-01T13:30:00Z"
}'Every account is brought online first; then all the orders go out together.
Poll GET /v1/execution-waves/{id} for the result.
weights sizes the trade per account — for accounts with different balances.
Each account reports its own result
Brokers are independent of one another, so a hundred accounts cannot fill as one transaction: fills come at slightly different prices, some accounts may be rejected for margin, and occasionally one goes unanswered.
So a multi-account order never reports a single success or failure. It
settles, and summary counts what happened:
{"state":"settled","dispatchSpreadMs":30,
"summary":{"total":100,"filled":97,"rejected":2,"skipped":0,"unresolved":1}}Each account's trade has its own orderId and is an ordinary order — queried,
confirmed and audited like any other. unresolved means the same as for a
single order: do not resend, we are confirming it with the broker.
When some accounts are not online in time
barrierPolicy | What happens |
|---|---|
release-ready | Trade the accounts that are online; skip the rest. A partial entry beats a late one. |
all-or-nothing | Trade none unless every account is online. For strategies that only make sense across the whole set. |
wait | Wait past executeAt until every account is online, up to expiresAt. |
all-or-nothing is about sending, not the outcome: a broker can still
reject one account's order after it was sent.
With executeAt, the orders go out at that time, not before. Without it, they
go out as soon as every account is online — accounts still logging in are
waited for, up to 60 seconds, after which the policy decides.
Spread
dispatchSpreadMs is the time between the first account's order going out and
the last. It is the number worth alerting on: a growing spread means accounts
are getting different prices. Every multi-account order reports its own; these
are from our benchmark against an instant broker, so a real broker adds its own
latency on top:
| accounts | spread |
|---|---|
| 10 | 17 ms |
| 50 | 26 ms |
| 100 | 30 ms |
Measured on our side; each broker adds its own latency on top.
Calling one off
POST /v1/execution-waves/{id}/cancel, but only before any order has gone out.
Once orders are being sent they cannot be recalled.