Trading and history
Orders, closes, pending orders, execution waves, 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 ready. See the lifecycle for getting it
there.
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 obvious
error — a buy whose stop loss sits above the entry — because the broker
cannot: its distance rules pass such an order, since only you and we know you
meant to buy.
Distance and step rules are the broker's, and they are enforced by their
order_check before anything is sent.
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 |
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 tenant. 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 (502) — it never reached the broker
{"error":{"code":"SEND_FAILED","message":"The terminal could not be reached, and the order was never sent. It is safe to retry.",
"details":[{"orderId":"...","state":"failed","retryable":true}]}}We have proof: the connection to the terminal was refused, or it told us
explicitly that it sent nothing. 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 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 (502) — we do not know
{"error":{"code":"ORDER_UNRESOLVED","message":"The terminal did not answer, and we do not know whether the order reached the broker. It is being reconciled. 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}. Reconciliation moves the order out of unknown to
whatever actually happened; needsReconciliation is true for exactly as long
as that is outstanding. See Reconciliation below.
We are deliberately pessimistic 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 reconciliation; being wrong
in the other direction costs a position you never asked for.
Reconciliation
An order in unknown is settled by asking the broker what it actually
recorded. This runs on a schedule; POST /v1/accounts/{id}/reconcile runs 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.
How an order is matched
Three signals, in order of trust.
The magic number. MetaTrader's magic is an integer on every order and
deal, meant as the caller's own reference, and brokers leave it alone. Ours is
derived from the order id. This is the strongest signal and it is tried first.
The comment tag. ow: plus eight characters of the order id, which the
broker echoes onto the deals the order produces. The tag always goes first in
the field, so your own comment never displaces it — MT5 keeps 31 characters
and yours takes what is left.
Symbol, direction and time. The fallback, used only when neither of the above survived.
Why three: brokers do rewrite the comment, and they do it particularly on
stop-loss, take-profit and margin-call closes — which is exactly when
reconciliation matters. magic survives that.
When it will not decide
If two deals could both be your order — the same strategy firing twice on one
account, with the tag lost — the order stays in unknown and is counted in
ambiguous.
That is deliberate. Attaching a fill to the wrong order produces a history that looks settled and is wrong, which is worse than the gap it replaced. 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.
What it resolves to
| Broker history | Order becomes |
|---|---|
| Deals covering the full volume | filled |
| Deals covering part of it | partially_filled |
| No matching deal, and enough time has passed | rejected |
| No matching deal yet, within the grace period | stays unknown |
| More than one possible match | stays unknown, counted as ambiguous |
Reconciliation never resolves an order to failed. That state claims the
request never left our process, and reconciliation is not in a position to know
— it can only see whether a deal resulted. An order with no deal did not open a
position, which is what rejected records.
It also fills in your history
Every reconciliation writes down the broker's deals for the window, whether or not anything needed resolving — including deals no order of yours caused. Pulling history only when something went wrong would leave it full of holes shaped like the times nothing went wrong.
Positions are refreshed at the same time. A position the broker stops reporting is marked closed rather than deleted, because a position that existed and is now gone is a fact worth keeping.
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.
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 behind — the broker refuses that after the close, leaving a position too small to trade and too small to close.
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.
A stop on the winning side of the entry is refused here. The broker's distance rules would pass it, because only you and we know the position is long.
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. The wrong side is refused before anything is sent, with a
message naming the order you probably meant — the broker's own INVALID_PRICE
says nothing about which way round it should have been.
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. 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.
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.
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 watches for
# reconciliation and stop.
raise Unresolved(error["details"][0]["orderId"])
if code == "ORDER_REJECTED":
if error["details"][0].get("retryable"):
# The market moved. A real client re-decides the price here
# rather than blindly resending.
time.sleep(0.5)
continue
raise OrderError(error["message"])
raise OrderError(f"{code}: {error['message']}")
raise OrderError("gave up after 3 attempts")Execution waves
One instruction, many accounts, one moment.
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"
}'Terminals are warmed first, the wave waits at a barrier, then every leg is
dispatched at once. Poll GET /v1/execution-waves/{id}.
A wave is not atomic
There is no transaction across a hundred brokers. Legs fill at different prices, some are rejected for margin, some go unanswered.
So a wave never reports success or failure. It settles, and summary
counts what landed:
{"state":"settled","dispatchSpreadMs":30,
"summary":{"total":100,"filled":97,"rejected":2,"skipped":0,"unresolved":1}}Each leg carries its own orderId. A leg that filled is an ordinary order —
reconciled, audited and queried like any other. A wave schedules orders; it is
not a second kind of order with its own rules.
unresolved legs mean the same as an unresolved order: do not resend,
reconciliation owns them.
Barrier policies
| Policy | When some accounts are not ready |
|---|---|
release-ready | Fire the ready ones, skip the rest. A partial entry beats a late one. |
all-or-nothing | Send none. The strategy only makes sense applied across the whole set. |
wait | Hold past executeAt until everyone is ready, up to expiresAt. |
all-or-nothing is about dispatch, not outcome. A broker can still reject
a leg after it was sent; nothing can prevent that.
The barrier releases early when every account is ready, whatever the policy — waiting for a clock with nothing left to wait for only widens the spread.
Dispatch spread
dispatchSpreadMs is the gap between the first leg leaving and the last. It is
the number worth alerting on: a wave whose spread grows is a wave whose
accounts are getting different prices, which a customer experiences as slippage
they did not cause.
Measured against an instant broker, so it reflects our own contribution:
| accounts | spread |
|---|---|
| 10 | 17 ms |
| 50 | 26 ms |
| 100 | 30 ms |
Real brokers add their own latency on top.
Calling one off
POST /v1/execution-waves/{id}/cancel, but only while nothing has been
dispatched. Once legs are going out there is no cancelling them — cancelled
would claim nothing was sent.