Docs

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-7b3d5e9f0a11

Without 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.

SituationWhat you get
Same key, same body, first call finishedThe same response, replayed, with idempotent-replay: true
Same key, same body, first call still running409 IDEMPOTENCY_IN_FLIGHT — retry shortly
Same key, different body409 IDEMPOTENCY_KEY_REUSED
Same key, after a SEND_FAILEDA genuine new attempt — nothing reached the broker, so the key is released
Same key, after a rejectionThe 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 new Idempotency-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 brokerOrder becomes
Trades covering the full volumefilled
Trades covering part of itpartially_filled
No matching trade, and enough time has passedrejected
No matching trade yetstays unknown for now
More than one possible matchstays 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 (volumeMin in 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"}'
KindBuy waitsSell waits
limitbelow the marketabove it
stopabove the marketbelow it
stop_limitstop triggers, then places a limitsame

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 symbols and instruments together, 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, executing or degraded (409 ACCOUNT_NOT_READY otherwise).
  • Every quote with a tick carries time (UTC), brokerTime (the broker's own clock, as its terminal shows it) and ageMs, how old the tick was when the terminal was read at observedAt. Apply your own freshness rule from those.
  • A closed market answers its last price with marketOpen: false and stale: false, never an error.
  • stale is true only while the market is open and the last tick is over 60 seconds old.
  • marketOpen comes from the broker's own trading sessions. It is null (and stale is false) until the terminal has reported them, a minute or so after an account first comes online.
  • tradeMode is what the broker allows on the symbol right now: full, longOnly, shortOnly, closeOnly or disabled. closeOnly and disabled allow 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 ageMs and time tell 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_LIMITED with retry-after.
  • A terminal still running an older worker — rare — answers 501 CAPABILITY_UNAVAILABLE instead. Do not retry it; an open position's currentPrice is 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

StateMeaningResend?
acceptedTaken, not yet sent—
sendingIn flightNo
workingA pending order waiting at the broker. No position exists.No — it is already there
completedAn instruction that is not a trade finished: a cancel, a stop moved—
filledThe broker confirmed the full volumeNo
partially_filledPart filled; the rest may still fillNo
rejectedThe broker refused it. Nothing executed.Only if retryable
failedProven never to have reached the brokerYes
unknownOutcome unestablishedNever
cancelledWithdrawn before execution—
expiredA 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

barrierPolicyWhat happens
release-readyTrade the accounts that are online; skip the rest. A partial entry beats a late one.
all-or-nothingTrade none unless every account is online. For strategies that only make sense across the whole set.
waitWait 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:

accountsspread
1017 ms
5026 ms
10030 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.

On this page